티스토리 뷰

반응형

브라우저에서만 갑자기 막히는 이유 — CORS, 쿠키, CSRF를 한 번에 이해하는 실전 백엔드 가이드 (FastAPI · Spring Boot · Node.js)

백엔드만 테스트할 때는 잘 됐어요.
Postman에서는 로그인도 되고, 토큰 재발급도 되고, 보호 API도 잘 열립니다.

근데 프론트 붙이는 순간 갑자기 이런 게 터지죠.

  • No 'Access-Control-Allow-Origin' header...
  • 쿠키가 안 실림
  • 로그인은 됐는데 브라우저 새로고침하면 인증이 사라짐
  • refresh token을 쿠키로 바꿨더니 이번엔 CSRF 얘기가 나옴
  • 로컬에선 되는데 배포하면 또 안 됨

이 구간이 진짜 헷갈립니다.
특히 처음엔 CORS랑 인증 문제랑 쿠키 문제랑 CSRF가 다 섞여 보여서, 어디부터 봐야 할지 감이 안 와요.

그래서 이번 글에서는 이걸 한 번에 정리해보겠습니다.

  • CORS가 정확히 뭘 막는지
  • 왜 브라우저에서만 문제가 생기는지
  • JWT를 헤더로 보낼 때와 쿠키로 보낼 때 뭐가 달라지는지
  • 쿠키 기반 refresh token이면 왜 CSRF를 같이 봐야 하는지
  • FastAPI · Spring Boot · Node.js에서 어디까지 설정하면 되는지

MDN은 CORS를 “브라우저에서 다른 origin의 리소스에 접근할 때 적용되는 HTTP 헤더 기반 메커니즘”으로 설명합니다. FastAPI도 CORS를 “브라우저에서 돌아가는 프론트엔드가 다른 origin의 백엔드와 통신할 때 생기는 상황”이라고 설명합니다. (MDN)


먼저 제일 중요한 오해 하나

CORS는 서버-서버 통신을 막는 게 아닙니다.
브라우저가 막는 거예요.

그래서 Postman, curl, 서버 간 호출에서는 잘 되는데
브라우저에서만 막히는 겁니다.
FastAPI 문서도 CORS를 “frontend running in a browser”가 다른 origin backend와 통신하는 상황으로 설명합니다. (FastAPI)

이 감각이 먼저 잡혀야 해요.
CORS는 백엔드 프레임워크의 예외가 아니라, 브라우저 보안 모델의 일부입니다. (MDN)


Origin이 뭐길래 그렇게 민감할까

origin은 단순히 “도메인”만이 아닙니다.
프로토콜 + 호스트 + 포트 조합이에요.

예를 들면 이건 전부 다른 origin입니다.

  • http://localhost:3000
  • http://localhost:5173
  • https://localhost:5173

FastAPI 문서도 origin을 protocol, domain, port의 조합으로 설명하고, 같은 localhost라도 포트나 프로토콜이 다르면 다른 origin이라고 안내합니다. (FastAPI)

그래서 로컬 개발에서 아주 흔한 조합인
프론트 http://localhost:5173 + 백엔드 http://localhost:8000
이것만으로도 이미 cross-origin입니다. (FastAPI)


CORS가 정확히 뭘 허용하는 걸까

반응형

보통 CORS를 너무 막연하게 생각하는데, 실제론 서버가 이런 걸 응답 헤더로 알려주는 겁니다.

  • 어떤 origin을 허용할지
  • 어떤 HTTP 메서드를 허용할지
  • 어떤 요청 헤더를 허용할지
  • 쿠키 같은 credentials를 실어도 되는지
  • 어떤 응답 헤더를 프론트 JS가 읽을 수 있게 할지

Spring Framework 문서의 예시도 allowedOrigins, allowedMethods, allowedHeaders, exposedHeaders, allowCredentials, maxAge를 함께 설정하는 형태를 보여줍니다. Express의 cors 미들웨어도 credentials, exposedHeaders 같은 옵션을 제공합니다. (Home)

즉 CORS는 “열까 말까” 한 줄짜리 문제가 아니라,
무엇을 어느 범위까지 허용할지 정하는 정책에 가깝습니다. (Home)


simple request와 preflight를 구분하면 훨씬 쉬워진다

이걸 이해하면 갑자기 머리가 맑아집니다.

simple request

브라우저가 별도 사전 확인 없이 바로 보내는 요청입니다.
MDN은 이런 요청이 예전 HTML form 제출과 호환되는 종류라서, 서버는 원래부터 CSRF를 방어해야 했다고 설명합니다. (MDN)

preflight request

본 요청 전에 브라우저가 OPTIONS 요청으로
“이 요청 보내도 돼?” 하고 먼저 확인하는 겁니다.
MDN은 preflight가 실제 요청 전에 허용 메서드/헤더 등을 확인하는 용도라고 설명합니다. (MDN)

실무에서는 보통 이런 경우 preflight가 잘 붙습니다.

  • Authorization 헤더를 보냄
  • Content-Type: application/json
  • 커스텀 헤더 사용
  • PUT, PATCH, DELETE 같은 메서드 사용

그래서 “JWT를 Authorization 헤더로 보내는 SPA”는 preflight를 자주 맞습니다.
이건 이상한 게 아니라 자연스러운 동작이에요. (MDN)


credentials가 들어가면 CORS가 더 민감해진다

여기서 credentials는 보통 이런 걸 말합니다.

  • 쿠키
  • HTTP 인증 정보
  • TLS client cert 같은 자격 정보

MDN은 preflight 요청 자체에는 credentials가 포함되면 안 되지만, 실제 요청에서 credentials를 허용하려면 서버가 Access-Control-Allow-Credentials: true를 응답해야 한다고 설명합니다. (MDN)

그리고 여기서 아주 중요한 포인트가 하나 더 있어요.

credentials를 허용할 때는 보통 * 와일드카드 origin 전략이 안 맞습니다.
Spring 문서 예시도 allowCredentials(true)를 쓸 때 구체적인 origin들을 지정하는 형태고, Express cors 예시도 credentials 사용 시 특정 origin을 명시합니다. (Home)

즉,

  • 헤더 기반 JWT만 쓸 때는 상대적으로 단순
  • 쿠키까지 실으면 CORS 설정이 훨씬 엄격해져야 함

이렇게 이해하면 됩니다. (MDN)


JWT를 Authorization 헤더로 보낼 때와 쿠키로 보낼 때 차이

이건 실제 설계에서 꽤 중요합니다.

Authorization 헤더 방식

보통 SPA나 모바일 API에서 많이 씁니다.

장점:

  • 브라우저가 자동으로 붙이지 않음
  • CSRF 표면이 상대적으로 작음
  • API 중심 구조와 잘 맞음

단점:

  • 프론트에서 토큰 보관 전략 고민 필요
  • XSS에 약한 저장소를 잘못 고르면 위험

MDN의 CSRF 문서도 fetch() 같은 JS 요청에서 커스텀 헤더를 사용하는 non-simple request는 기본적으로 cross-origin에서 바로 허용되지 않기 때문에, 이런 구조는 CSRF 방어 측면에서 유리할 수 있다고 설명합니다. (MDN)

쿠키 방식

특히 refresh token을 httpOnly 쿠키로 두는 전략에서 많이 씁니다.

장점:

  • JS에서 직접 읽지 못하게 하기 쉬움
  • refresh token 보관에 자주 쓰임

단점:

  • 브라우저가 자동으로 쿠키를 보냄
  • 그래서 CSRF를 같이 봐야 함
  • CORS credentials 설정까지 정확해야 함

OWASP와 MDN 모두, 브라우저가 자동으로 보내는 자격 정보가 있는 상태에서는 CSRF를 반드시 고려해야 한다고 설명합니다. (OWASP Cheat Sheet Series)


그래서 제일 많이 쓰는 실전 조합은?

저는 이 조합을 자주 봅니다.

  • access token: 응답 바디로 받고 메모리/상태관리에서 사용
  • refresh token: httpOnly 쿠키
  • 보호 API: Authorization: Bearer <access token>
  • /refresh: 쿠키의 refresh token으로 호출

이 방식의 장점은 균형이 좋아요.

  • access token은 API 방식과 잘 맞고
  • refresh token은 JS에서 직접 못 읽게 하기 쉽고
  • 둘의 역할이 분리됩니다

다만 이 구조에서도 refresh token이 쿠키에 있으면 CSRF를 완전히 잊으면 안 됩니다.
OWASP CSRF Cheat Sheet는 쿠키 기반 자격 정보가 자동 전송되는 구조에서는 CSRF 토큰, origin 검증, SameSite 같은 방어를 함께 고려하라고 설명합니다. (OWASP Cheat Sheet Series)


왜 쿠키 기반 refresh token이면 CSRF를 같이 봐야 할까

이게 핵심입니다.

브라우저는 쿠키를 자동으로 보낼 수 있어요.
즉 사용자가 우리 서비스에 로그인된 상태에서 악성 사이트를 열면, 그 사이트가 우리 서버로 요청을 유도할 수 있습니다.

MDN은 simple request, 특히 form 제출처럼 보이는 cross-origin 요청은 원래 웹에서 가능했기 때문에, 서버는 CSRF 방어를 해야 한다고 설명합니다. 또 non-simple request는 기본적으로 cross-origin에서 바로 허용되지 않지만, 서버가 CORS로 완화하면 다시 위험이 생길 수 있다고 설명합니다. (MDN)

즉 refresh token이 쿠키에 있다면
/refresh, /logout, /change-password 같은 상태 변경 요청은 CSRF 관점에서도 봐야 합니다. (MDN)


SameSite만 있으면 끝일까

이것도 자주 나오는 오해예요.

OWASP는 SameSite가 유용한 defense-in-depth 수단이긴 하지만, 대부분의 배포 환경에서 proper CSRF defense를 완전히 대체하지는 않는다고 설명합니다. 특히 SameSite=Lax는 top-level navigation의 safe method에는 쿠키가 갈 수 있고, 애플리케이션에 상태를 바꾸는 GET이 있으면 위험하다고 경고합니다. (OWASP Cheat Sheet Series)

즉 SameSite는 좋지만, 단독 만능 해법으로 보면 안 됩니다.
특히 이 세 가지는 같이 기억해야 해요.

  • 상태 변경은 GET으로 하지 말기
  • 쿠키 기반 인증이면 CSRF 토큰 또는 origin 검증 고민하기
  • SameSite=None이면 Secure가 필요함

OWASP는 SameSite=None 쿠키에는 Secure 플래그가 필요하다고도 설명합니다. (OWASP Cheat Sheet Series)


1) FastAPI에서 CORS와 쿠키/헤더 전략 잡기

FastAPI는 CORSMiddleware를 공식적으로 안내하고 있고, preflight와 simple request 처리도 함께 설명합니다. (FastAPI)

추천 상황 1: SPA + Authorization 헤더 access token

이 경우는 비교적 단순합니다.

from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware

app = FastAPI()

origins = [
    "http://localhost:5173",
    "https://app.example.com",
]

app.add_middleware(
    CORSMiddleware,
    allow_origins=origins,
    allow_credentials=False,
    allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
    allow_headers=["Authorization", "Content-Type"],
    expose_headers=["X-Request-Id"],
)

이 구조는 access token을 Authorization 헤더로 보내는 경우에 잘 맞습니다.
FastAPI 문서는 CORS 설정에 allow_origins, allow_methods, allow_headers 등을 두고, origin을 명시적으로 잡는 방식을 안내합니다. (FastAPI)

포인트

  • Authorization 헤더를 허용해야 함
  • 보통 preflight가 발생할 수 있음
  • 쿠키를 안 쓰면 allow_credentials=False로 더 단순하게 갈 수 있음 (MDN)

추천 상황 2: refresh token을 httpOnly 쿠키로 보낼 때

이 경우는 CORS가 더 엄격해져야 합니다.

from fastapi import FastAPI, Response
from fastapi.middleware.cors import CORSMiddleware

app = FastAPI()

origins = [
    "http://localhost:5173",
    "https://app.example.com",
]

app.add_middleware(
    CORSMiddleware,
    allow_origins=origins,
    allow_credentials=True,
    allow_methods=["GET", "POST", "OPTIONS"],
    allow_headers=["Content-Type", "X-CSRF-Token"],
)

@app.post("/api/auth/login")
def login(response: Response):
    refresh_token = "sample-refresh-token"
    response.set_cookie(
        key="refresh_token",
        value=refresh_token,
        httponly=True,
        secure=True,
        samesite="lax",
        path="/api/auth",
    )
    return {"message": "login success"}

여기서 핵심

  • 쿠키를 cross-origin 요청에 실으려면 allow_credentials=True
  • origin은 구체적으로 명시
  • CSRF 대응용 커스텀 헤더를 함께 쓸 수 있음
  • 쿠키 경로를 /api/auth처럼 좁히는 것도 실전적으로 좋음 (MDN)

FastAPI 자체는 CSRF 전용 기능을 강하게 제공하는 프레임워크는 아니라서,
쿠키 기반 상태 변경 엔드포인트라면 설계를 직접 명확히 가져가야 합니다.
즉 “CORS 열었으니 끝”이 아닙니다. (MDN)


2) Spring Boot에서 CORS와 CSRF를 같이 보기

Spring Framework는 CORS 설정을 WebMvcConfigurer#addCorsMappings 등으로 구성할 수 있고, Spring Security는 CSRF를 기본적으로 중요한 보호 기능으로 다룹니다. (Home)

Spring Security 문서는 CSRF 보호를 설명하면서, 실제 CSRF 토큰은 브라우저가 자동으로 포함하지 않는 위치, 예를 들면 form field나 HTTP header 같은 곳에 포함되어야 한다고 설명합니다. (Home)

추천 상황 1: 순수 API + Authorization 헤더 JWT

@Configuration
public class WebConfig implements WebMvcConfigurer {

    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/api/**")
                .allowedOrigins("http://localhost:5173", "https://app.example.com")
                .allowedMethods("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")
                .allowedHeaders("Authorization", "Content-Type")
                .exposedHeaders("X-Request-Id")
                .maxAge(3600);
    }
}

Spring 문서 예시도 이와 유사하게 allowedOrigins, allowedMethods, allowedHeaders, exposedHeaders, allowCredentials, maxAge를 설정합니다. (Home)

이 경우에는 보통 쿠키 기반 세션 인증이 아니라 Bearer JWT 중심이므로,
Spring Security에서 CSRF를 완화하거나 꺼두는 구성이 자주 나옵니다.
다만 “정말 쿠키 기반 자격 정보가 없는지”를 확인하고 해야 합니다. (Home)

@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
    return http
            .csrf(csrf -> csrf.disable())
            .authorizeHttpRequests(auth -> auth.anyRequest().permitAll())
            .build();
}

포인트

  • 헤더 기반 JWT만 쓸 때는 CSRF 표면이 줄어듦
  • 하지만 쿠키 기반 인증이나 자동 전송 자격 증명이 있으면 얘기가 달라짐 (MDN)

추천 상황 2: 쿠키 기반 refresh token 또는 세션 인증

이 경우 Spring Security CSRF를 쉽게 꺼버리면 나중에 문제되기 쉽습니다.

Spring Security 문서는 synchronizer token pattern을 설명하고, CSRF 토큰은 브라우저가 자동으로 넣지 않는 위치에 함께 보내야 한다고 설명합니다. HTML form의 hidden input 예시도 보여줍니다. (Home)

예를 들어 SPA에서 쿠키 기반 refresh token을 쓰고 /refresh 같은 상태 변경 엔드포인트를 보호하려면:

  • CSRF 토큰을 프론트에 전달
  • 프론트가 X-CSRF-Token 헤더로 함께 보냄
  • 서버가 그 토큰 검증

이런 흐름을 같이 설계해야 합니다. (Home)

그리고 CORS는 credentials 허용으로 맞춰야 합니다.

@Configuration
public class WebConfig implements WebMvcConfigurer {

    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/api/**")
                .allowedOrigins("https://app.example.com")
                .allowedMethods("GET", "POST", "OPTIONS")
                .allowedHeaders("Content-Type", "X-CSRF-Token")
                .allowCredentials(true)
                .maxAge(3600);
    }
}

여기서 핵심

  • 쿠키가 자동으로 가므로 CSRF 같이 봐야 함
  • allowCredentials(true)면 origin은 구체적으로 지정
  • Spring Security CSRF 기본 보호를 이해하고 설계해야 함 (Home)

3) Node.js에서 Express cors와 쿠키 전략 잡기

Express는 cors 미들웨어로 CORS를 다루는 게 일반적이고, 공식 문서도 설정 옵션을 따로 설명합니다. credentials: true로 Access-Control-Allow-Credentials 헤더를 보낼 수 있다고 나옵니다. (Express)

추천 상황 1: Authorization 헤더 기반 API

import express from "express";
import cors from "cors";

const app = express();

app.use(cors({
  origin: ["http://localhost:5173", "https://app.example.com"],
  methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
  allowedHeaders: ["Authorization", "Content-Type"],
  exposedHeaders: ["X-Request-Id"],
  credentials: false,
}));

app.use(express.json());

포인트

  • Authorization 헤더 허용
  • 보통 preflight 있음
  • 쿠키 안 쓰면 상대적으로 단순 (Express)

추천 상황 2: refresh token 쿠키 기반

import express from "express";
import cors from "cors";
import cookieParser from "cookie-parser";

const app = express();

app.use(cors({
  origin: ["http://localhost:5173", "https://app.example.com"],
  credentials: true,
  methods: ["GET", "POST", "OPTIONS"],
  allowedHeaders: ["Content-Type", "X-CSRF-Token"],
}));

app.use(express.json());
app.use(cookieParser());

app.post("/api/auth/login", (req, res) => {
  res.cookie("refresh_token", "sample-refresh-token", {
    httpOnly: true,
    secure: true,
    sameSite: "lax",
    path: "/api/auth",
  });

  res.json({ message: "login success" });
});

Express cors 문서는 credentials 옵션을 제공한다고 설명하고, origin을 동적으로도 설정할 수 있다고 안내합니다. (Express)

여기서 핵심

  • 브라우저가 쿠키를 자동 전송할 수 있으므로 CSRF 같이 고려
  • credentials true면 origin을 * 대신 명시적 지정
  • 쿠키 scope를 최소화하는 습관이 좋음 (Express)

CORS와 CSRF를 한 줄로 구분하면

이 구분이 진짜 중요합니다.

CORS

“이 브라우저 JS 요청을 cross-origin으로 허용할까?”

CSRF

“브라우저가 자동으로 보내는 자격 정보(쿠키 등)를 악용한 요청을 어떻게 막을까?”

MDN은 simple request가 원래 cross-origin form 제출과 비슷한 특성을 가지므로 CSRF 방어가 필요하다고 설명하고, non-simple request는 기본적으로 cross-origin에서 허용되지 않지만 CORS로 완화하면 다시 위험해질 수 있다고 설명합니다. (MDN)

즉,

  • CORS 통과했다고 안전한 게 아님
  • CORS 막았다고 CSRF가 자동 해결되는 것도 아님

둘은 다른 문제예요. (MDN)


제가 추천하는 아주 현실적인 선택 기준

1. SPA + API 서버 + access token은 헤더

이건 꽤 무난합니다.

  • access token: Authorization 헤더
  • refresh token: httpOnly 쿠키 또는 별도 저장 전략
  • CORS: 허용 origin 명시
  • CSRF: refresh 쿠키 쓴다면 같이 고려

2. 관리자 페이지/전통적 웹앱/세션 인증

쿠키와 세션이 자연스럽기 때문에 CSRF를 반드시 같이 설계해야 합니다.
Spring Security 기본 CSRF 보호를 이해하고 끄지 않는 쪽이 종종 더 안전합니다. (Home)

3. “쿠키니까 무조건 안전”

아닙니다.
XSS 측면에선 httpOnly가 도움이 되지만, CSRF 관점에선 자동 전송이라는 다른 문제가 생깁니다. (MDN)

4. “Authorization 헤더니까 CORS만 맞추면 끝”

대체로 CSRF 표면은 줄지만, CORS preflight/허용 헤더/프론트 fetch 옵션까지 같이 봐야 합니다. (MDN)


실무에서 자주 하는 실수

첫 번째는 allow_origins=["*"]로 편하게 열어두고 끝내는 것입니다.
특히 credentials가 필요한 구조에선 더 안 맞습니다. Spring과 Express 예시도 credentials와 함께는 구체적 origin을 씁니다. (Home)

두 번째는 상태 변경을 GET으로 두는 것입니다.
OWASP는 SameSite=Lax 같은 방어도 상태 변경 GET이 있으면 무력화될 수 있다고 경고합니다. (OWASP Cheat Sheet Series)

세 번째는 쿠키를 쓰면서 CSRF를 완전히 잊는 것입니다.
이건 정말 흔합니다. 쿠키 자동 전송 구조면 CSRF는 같이 봐야 합니다. (MDN)

네 번째는 브라우저 문제를 백엔드 토큰 로직 문제로만 오해하는 것입니다.
실제로는 CORS preflight나 credentials 설정, SameSite 쿠키 정책 문제인 경우가 많아요. (MDN)


이번 글 핵심 정리

이번 글의 핵심은 이겁니다.

브라우저와 연결되는 인증 구조에서는 CORS, 쿠키, CSRF를 따로 보지 말고 같이 설계해야 한다.

정리하면 이렇게 보면 됩니다.

  • CORS는 브라우저 cross-origin 요청 허용 정책이다
  • 쿠키 기반 인증/refresh는 credentials 설정이 필요하다
  • credentials가 들어가면 origin은 구체적으로 잡는 편이 맞다
  • 쿠키가 자동 전송되면 CSRF를 같이 봐야 한다
  • SameSite는 좋지만 만능은 아니다
  • access token 헤더 방식과 refresh token 쿠키 방식은 서로 다른 장단점이 있다

스택별 감각은 이렇습니다.

  • FastAPI: CORSMiddleware로 깔끔하게 시작하되, 쿠키 기반이면 CSRF 설계를 직접 챙겨야 한다. (FastAPI)
  • Spring Boot: CORS와 Spring Security CSRF를 함께 보는 게 중요하다. 특히 세션/쿠키 기반 구조와 잘 맞물린다. (Home)
  • Node.js: cors 미들웨어는 쉽지만, 쿠키/credentials/CSRF까지 같이 봐야 진짜 실전 설정이 된다. (Express)

다음 글 예고

다음 글에서는 이 흐름을 이어서
파일 업로드, 이미지 업로드, 대용량 요청 처리를 다뤄보겠습니다.

즉,

  • multipart/form-data는 왜 JSON 요청과 감각이 다른지
  • 이미지 업로드에서 어디까지 검증해야 하는지
  • 파일 크기 제한, MIME 타입, 저장 위치를 어떻게 설계할지
  • FastAPI · Spring Boot · Node.js에서 업로드 API를 어떻게 안전하게 만들지

이걸 실전 기준으로 풀어보겠습니다.

출처

  • FastAPI 공식 문서 — CORS (CORSMiddleware). (FastAPI)
  • Spring Framework 공식 문서 — Web MVC CORS. (Home)
  • Express 공식 문서 — cors middleware. (Express)
  • MDN — CORS guide, simple requests, preflight, credentials. (MDN)
  • MDN — CSRF guide, simple requests와 fetch/CORS 관계. (MDN)
  • Spring Security 공식 문서 — CSRF protection. (Home)
  • OWASP CSRF Prevention Cheat Sheet — SameSite limitations, CSRF defenses. (OWASP Cheat Sheet Series)

백엔드개발, FastAPI, SpringBoot, Nodejs, Express, CORS, CSRF, 쿠키인증, 브라우저보안, 백엔드시리즈

※ 이 포스팅은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2026/05   »
1 2
3 4 5 6 7 8 9
10 11 12 13 14 15 16
17 18 19 20 21 22 23
24 25 26 27 28 29 30
31
글 보관함
반응형