신입 시절에 자주 들었던 말 중 하나가 "폼 만들 때 CSRF 토큰 까먹지 마라"였습니다. 스프링 시큐리티든 장고든, 토큰 한 줄 빠뜨려서 POST 요청이 403으로 튕겨 나간 경험, 다들 한 번씩은 있으실 거예요. 그런데 요즘은 또 분위기가 좀 다릅니다. 브라우저들이 쿠키 SameSite 기본값을 Lax로 바꾸면서 "이제 CSRF 토큰 안 써도 되는 거 아니냐"는 얘기가 슬슬 나오거든요. 오늘은 그 CSRF가 정확히 뭘 노리는 공격인지, 그리고 SameSite 쿠키 하나로 정말 막을 수 있는지를 정리해 볼게요.
개념 / 원리
CSRF(Cross-Site Request Forgery)는 사용자가 이미 로그인한 상태라는 점을 악용하는 공격입니다. 비유하자면 이렇습니다. 은행 창구에서 신분 확인이 끝난 직후, 잠깐 자리를 비운 사이에 누가 내 명의로 송금 신청서를 슬쩍 밀어 넣는 상황이죠. 창구 직원(서버)은 신분증(쿠키)만 보고 정상 요청이라고 판단합니다.
핵심은 쿠키는 요청 주체가 누구든 자동으로 따라간다는 점입니다. 사용자가 bank.com에 로그인한 상태에서 공격자가 만든 evil.com에 방문하면, evil.com이 bank.com으로 보내는 요청에도 bank.com의 세션 쿠키가 자동으로 실립니다. 사용자는 그저 이상한 페이지 한 번 클릭했을 뿐인데, 서버 입장에서는 "본인 인증된 사용자의 정상 요청"으로 보이는 거예요.
이걸 막는 전통적인 방법이 CSRF 토큰이었습니다. 서버가 폼을 그릴 때 랜덤한 토큰을 같이 내려주고, 폼 제출 시 그 토큰이 함께 와야 통과시키는 방식이죠. 공격자는 다른 출처라서 토큰 값을 읽을 수 없으니 위조가 어렵습니다.
SameSite 쿠키는 이걸 브라우저 차원에서 거들어 주는 장치입니다. 쿠키에 SameSite=Lax 또는 Strict를 붙여 두면, 다른 사이트에서 시작된 요청에는 쿠키가 아예 실리지 않습니다. 2020년 즈음부터 크롬을 시작으로 주요 브라우저가 기본값을 Lax로 바꿨고요.
| SameSite 값 | 다른 사이트에서 시작된 요청에 쿠키 전송? |
|---|---|
None |
전송됨 (Secure 플래그 필수) |
Lax |
GET 같은 최상위 이동만 허용, POST는 차단 |
Strict |
전부 차단 (외부 링크로 들어와도 미전송) |
실전 예제
먼저 공격이 어떻게 들어오는지부터 보겠습니다. 사용자가 bank.com에 로그인한 상태에서, 공격자가 운영하는 페이지에 방문했다고 가정해 볼게요.
<!-- evil.com/attack.html -->
<html>
<body onload="document.forms[0].submit()">
<form action="https://bank.com/transfer" method="POST">
<input name="to" value="attacker_account">
<input name="amount" value="1000000">
</form>
</body>
</html>
페이지가 열리는 즉시 폼이 자동 제출됩니다. 브라우저는 bank.com으로 POST를 보내면서 세션 쿠키를 같이 실어 보내고요. 서버가 아무 검증도 안 하면 그대로 송금이 되는 거죠.
방어 1단계는 쿠키에 SameSite 속성을 명시하는 겁니다. 스프링 부트라면 application.yml에서 한 줄로 처리됩니다.
server:
servlet:
session:
cookie:
same-site: lax
secure: true
http-only: true
Node.js(Express + express-session)는 이렇게 설정합니다.
app.use(session({
secret: process.env.SESSION_SECRET,
cookie: {
sameSite: 'lax',
secure: true,
httpOnly: true,
maxAge: 1000 * 60 * 60
}
}));
방어 2단계는 CSRF 토큰입니다. 스프링 시큐리티는 기본으로 켜져 있고, 폼 안에 hidden 토큰이 자동 삽입돼요.
<form method="post" action="/transfer">
<input type="hidden" name="_csrf" th:value="${_csrf.token}">
<input name="to">
<input name="amount">
<button>송금</button>
</form>
API 서버라면 헤더로 받는 방식이 더 깔끔합니다. 클라이언트가 토큰을 미리 받아 뒀다가 X-CSRF-Token 헤더에 실어 보내는 식이죠. 이때 토큰은 세션과 묶인 랜덤 값이어야지, 단순히 쿠키에 똑같이 박힌 값을 그대로 보내면 의미가 없습니다.
주의할 점
여기서부터가 진짜입니다. "SameSite=Lax니까 CSRF 토큰 빼도 되겠지" 하면 안 되는 이유들이에요.
1) Lax는 GET 요청을 막아주지 않습니다. 외부 링크 클릭이나 <a href> 이동은 Lax에서도 쿠키가 그대로 따라갑니다. 그러니까 GET으로 상태를 바꾸는 API(예: /delete?id=42)를 만들어 두면 Lax로도 안전하지 않아요. 상태 변경은 무조건 POST/PUT/DELETE라는 원칙이 그래서 중요합니다.
2) 일부 브라우저는 여전히 None이 기본일 수 있습니다. 최신 크롬/엣지/파이어폭스는 Lax로 바뀌었지만, 오래된 브라우저나 일부 환경에서는 아닙니다. 쿠키 발급 시 SameSite를 명시적으로 지정해 두는 게 안전합니다.
3) 서브도메인은 같은 사이트로 취급됩니다. app.example.com과 attack.example.com이 같은 회사 도메인이면 SameSite로는 못 막아요. 멀티 테넌시 환경에서 특히 조심해야 하는 부분입니다.
4) JSON API + Content-Type: application/json은 비교적 안전합니다. 브라우저가 단순 폼 제출(application/x-www-form-urlencoded)이 아닌 요청에는 preflight를 보내거든요. 하지만 CORS 설정이 느슨하면 의미가 없어집니다. Access-Control-Allow-Origin: *에 Access-Control-Allow-Credentials: true까지 켜 두는 실수, 의외로 자주 봅니다.
저는 예전에 사내 어드민에서 SameSite=Lax만 믿고 토큰을 안 넣었다가, 한 페이지에서 GET으로 권한 변경하는 엔드포인트가 발견돼서 식은땀 흘린 적이 있습니다. 결국 토큰은 같이 쓰는 게 마음이 편합니다.
마무리
정리하면 세 줄입니다.
- CSRF는 "쿠키가 자동으로 따라간다"는 브라우저 동작을 악용하는 공격이고, 토큰이나
SameSite로 막는다. SameSite=Lax는 POST를 막아주지만 GET·서브도메인·CORS 설정 실수까지는 못 막는다.- 실무 권장 조합은
SameSite=Lax+Secure+HttpOnly+ CSRF 토큰. 둘 중 하나만 믿지 말고 같이 쓰자.
'보안' 카테고리의 다른 글
| XSS - Stored, Reflected, DOM-based 세 종류와 막는 법 (0) | 2026.06.11 |
|---|---|
| OAuth 2.0 Authorization Code Grant - 구글 로그인 뒤에서 일어나는 일 (0) | 2026.06.05 |
| [보안] HSTS와 보안 헤더 - HTTPS를 정말 강제하는 방법 (0) | 2026.05.22 |
| [보안] Let's Encrypt와 ACME 프로토콜 - 인증서 자동 발급과 갱신의 동작 원리 (0) | 2026.05.21 |
| [보안] 인증서 검증의 전 과정 - 체인 추적부터 OCSP·CRL 폐기 확인까지 (0) | 2026.05.20 |