CSRF 공격이란? SameSite 시대에도 뚫리는 구성과 방어법
CSRF 공격의 원리와 XSS와의 차이, SameSite=Lax만 믿으면 방어가 빠지는 구성을 짚고, 인증 방식별로 토큰·Origin 검증 중 무엇이 필요한지 코드와 체크리스트로 정리합니다.
CSRF(Cross-Site Request Forgery) 공격은 로그인한 사용자의 브라우저가 공격자가 유도한 요청을 보내게 만들고, 브라우저가 쿠키를 자동으로 붙이는 동작을 이용해 사용자 권한으로 상태를 바꾸는 공격입니다. 공격자는 쿠키를 훔치지 않습니다. 서버가 "이 요청이 진짜 우리 화면에서 나왔는가"를 확인하지 않는 빈틈을 이용합니다.
결론부터 말하면, 인증에 쿠키를 쓰는 서비스는 브라우저 기본값 SameSite=Lax만 믿으면 안 됩니다. 기본값이 Lax가 아닌 브라우저가 있고, Lax여도 GET 상태 변경과 서브도메인 요청은 막지 못합니다. SameSite를 쿠키에 명시하고, 서버에서 CSRF 토큰이나 Origin·Fetch Metadata 검증 중 하나를 함께 두어야 합니다. 반대로 Authorization 헤더로만 인증하는 API는 CSRF 토큰이 필요 없는 경우가 많습니다.
OWASP와 MDN 모두 SameSite를 단독 방어가 아닌 보조 방어(defense in depth)로 분류합니다. 아래에서는 어떤 구성에서 방어가 빠지는지 살펴보고, 인증 방식별로 필요한 조치를 정리합니다.
CSRF 동작 원리와 XSS와의 차이
사용자가 app.example.com에 로그인해 세션 쿠키를 가진 상태에서 다른 사이트를 열었다고 해 봅시다. 그 페이지에 아래 같은 폼이 숨어 있으면 브라우저는 요청을 대상 서버로 보냅니다. 쿠키가 조건에 맞으면 함께 붙습니다.
<!-- evil.example 에 있는 페이지라고 가정한 교육용 예시 -->
<form action="https://app.example.com/account/email" method="POST">
<input type="hidden" name="email" value="[email protected]" />
</form>
<script>document.forms[0].submit()</script>
서버가 쿠키만 확인한다면 이 요청은 정상 요청과 구별되지 않습니다. 공격이 성립하는 조건은 세 가지입니다.
- 인증 정보가 브라우저에 의해 자동으로 전송된다 (쿠키 등).
- 상태를 바꾸는 요청의 파라미터를 공격자가 미리 알 수 있다.
- 서버가 요청의 출처를 검증하지 않는다.
XSS와 자주 혼동되지만 성격이 다릅니다.
| 구분 | CSRF | XSS |
|---|---|---|
| 실행 위치 | 공격자 사이트에서 요청만 유도 | 우리 사이트 안에서 스크립트 실행 |
| 공격자가 얻는 것 | 상태 변경 (응답은 보통 읽지 못함) | 화면·토큰·데이터 접근과 임의 요청 |
| 핵심 방어 | 요청 출처 검증 | 출력 인코딩, CSP |
중요한 관계가 하나 있습니다. OWASP는 XSS가 있으면 토큰, double submit, Origin 기반 CSRF 방어가 모두 무력화될 수 있다고 설명합니다. 같은 출처에서 실행되는 스크립트는 토큰을 읽어 정상 요청을 보낼 수 있기 때문입니다. 그래서 CSRF 방어는 XSS 방어를 대신하지 못합니다. XSS 쪽 점검은 XSS 취약점 방어 가이드에서 이어서 볼 수 있습니다.
SameSite=Lax가 막는 것과 막지 못하는 것
MDN 기준으로 SameSite의 세 값은 다음과 같습니다.
Strict: 같은 사이트에서 시작된 요청에만 쿠키를 보냅니다.Lax: 같은 사이트 요청에 더해, 교차 사이트 요청 중 최상위 내비게이션이면서 안전한 메서드(GET 등)인 경우에 보냅니다.fetch(),<img>, iframe 요청에는 붙지 않습니다.None: 교차 사이트 요청에도 보냅니다.Secure가 필수입니다.
Lax는 위의 숨은 POST 폼을 막습니다. 다만 "기본값 Lax"에 기대면 세 가지 문제가 남습니다.
첫째, 모든 브라우저의 기본값이 아닙니다. MDN 호환성 데이터 기준으로 Chrome은 80부터 Lax가 기본이지만, Firefox는 환경설정 플래그 뒤에 있고 Safari는 지원하지 않는 것으로 표시되어 있습니다. 이 값은 바뀔 수 있으므로 쿠키에 직접 명시하는 편이 안전합니다.
둘째, 기본 Lax는 명시적 Lax보다 느슨합니다. MDN에 따르면 기본값으로 적용된 Lax는 쿠키가 설정된 지 2분 이내라면 POST 요청에도 쿠키를 포함합니다. Chromium 문서는 이 Lax+POST 동작을 임시 조치로 설명합니다. 로그인 직후 2분 동안 창이 열리는 셈이고, 쿠키에 SameSite=Lax를 명시하면 이 예외에서 벗어납니다.
셋째, 판단 기준이 Origin이 아니라 Site입니다. Origin은 스킴·호스트·포트가 모두 같아야 하지만, Site는 스킴과 eTLD+1만 같으면 됩니다. blog.example.com과 app.example.com은 서로 다른 Origin이지만 같은 Site입니다. SameSite는 이 둘을 구별하지 않습니다.
Lax만으로 방어가 빠지는 구성
GET으로 상태를 바꾸는 엔드포인트
Lax는 최상위 내비게이션 GET에 쿠키를 보냅니다. 링크 클릭이나 리다이렉트 한 번으로 아래 핸들러가 실행됩니다.
// 취약: GET이 상태를 바꾼다
app.get('/subscription/cancel', requireSession, async (req, res) => {
await cancelSubscription(req.user.id);
res.redirect('/settings');
});
// 수정: POST로 옮기고 CSRF 검증을 통과해야 실행
app.post('/subscription/cancel', requireSession, csrfGuard, async (req, res) => {
await cancelSubscription(req.user.id);
res.status(204).end();
});
이메일 속 "원클릭 수신거부"나 "로그아웃" 링크, 관리자 화면의 ?action=delete가 대표적입니다. 메일 링크는 확인 화면을 띄운 뒤 POST로 처리합니다.
메서드 오버라이드도 같은 문제를 만듭니다. PortSwigger는 Symfony의 _method 파라미터처럼 GET 요청을 다른 메서드로 처리하게 해 주는 기능을 사례로 듭니다. 라우트는 POST인데 실제로는 GET으로 도달할 수 있는 경우입니다. 쓰지 않는다면 끄고, 쓴다면 POST 본문에서만 허용합니다.
서브도메인을 함께 운영하는 구성
랜딩 페이지, 블로그, 외부 SaaS에 연결한 help.example.com이 있다면 이들은 모두 app.example.com과 같은 Site입니다. 서브도메인 한 곳에 XSS가 있거나 방치된 DNS 레코드로 서브도메인 탈취가 일어나면, 그곳에서 보낸 POST에는 SameSite=Strict 쿠키도 붙습니다.
쿠키 덮어쓰기도 문제입니다. Django 문서는 서브도메인이 도메인 전체에 쿠키를 설정할 수 있어 쿠키와 토큰을 맞춘 뒤 CSRF 보호를 우회할 수 있다고 명시합니다. 대응은 세 가지입니다.
- 쿠키를
Domain=.example.com으로 넓게 설정하지 않습니다. - 세션과 CSRF 쿠키에
__Host-접두사를 씁니다.Domain없이Path=/,Secure로만 설정되어 다른 서브도메인이 덮어쓰기 어렵습니다. - 서버의 Origin 검증에서 "*.example.com" 대신 정확한 Origin 목록을 씁니다.
Set-Cookie: __Host-session=…; Path=/; Secure; HttpOnly; SameSite=Lax
__Host-는 쿠키 덮어쓰기를 막을 뿐, 서브도메인에서 보낸 요청에 쿠키가 붙는 것까지 막지는 못합니다. 그래서 Origin 검증이 함께 필요합니다.
쿠키 기반 인증 SPA
"JSON API라서 안전하다"는 판단이 가장 많이 빠지는 지점입니다. Content-Type: application/json 요청은 교차 출처에서 프리플라이트를 거치므로 CORS가 막아 주는 것은 맞습니다. 그런데 서버가 다른 타입도 JSON으로 파싱하거나 CORS가 Origin을 그대로 허용하면 이 효과는 사라집니다. OWASP는 text/plain을 받는 JSON API가 CSRF에 취약할 수 있다고 경고합니다. text/plain은 프리플라이트 없이 보낼 수 있습니다.
// 취약: 어떤 Content-Type이든 JSON으로 파싱, 요청 Origin을 그대로 허용
app.use(express.json({ type: '*/*' }));
app.use(cors({ origin: true, credentials: true }));
// 수정: JSON만 받고, 허용 Origin을 고정
app.use(cors({ origin: ['https://app.example.com'], credentials: true }));
app.use('/api', express.json());
app.use('/api', (req, res, next) => {
const unsafe = !['GET', 'HEAD', 'OPTIONS'].includes(req.method);
if (unsafe && !req.is('application/json')) return res.status(415).end();
next();
});
프런트와 API가 서로 다른 사이트(app.example.com과 api.example-cloud.net)라면 SameSite=None이 필요하므로 SameSite 방어는 없는 것과 같습니다. 이때는 서버 측 검증이 유일한 방어입니다. 가능하면 API를 같은 사이트로 옮기거나 프런트 서버를 경유하게 바꾸는 편이 낫습니다.
토큰을 쿠키에 담은 JWT도 같습니다. 인증 정보가 쿠키로 자동 전송되는 이상 CSRF 관점에서는 세션 쿠키와 다르지 않습니다. "JWT니까 csrf.disable()"이 흔한 설정 실수입니다. 관련 내용은 Spring Security 설정 실수에서도 확인할 수 있습니다.
그 밖에 놓치기 쉬운 곳
- 로그인 폼: OWASP는 로그인 CSRF를 별도로 다룹니다. 피해자를 공격자 계정으로 로그인시켜 입력 정보를 가로채는 방식이므로, 로그인 전 세션에도 토큰을 둡니다.
- WebSocket: 핸드셰이크에 쿠키가 붙으므로 서버에서 Origin을 확인합니다.
- 클라이언트 측 CSRF: URL 프래그먼트 등 공격자가 제어하는 값으로 JS가 요청을 만들면 정상 출처에서 나간 요청이 되어 토큰과 SameSite로 막히지 않을 수 있습니다. 입력값 검증이 답입니다.
서버 측 방어: 토큰과 Fetch Metadata 중 무엇을 고를까
선택 순서는 단순합니다. OWASP의 첫 권고도 프레임워크 내장 보호를 먼저 확인하라는 것입니다.
- 프레임워크 내장 CSRF 보호가 있으면 켠 채로 씁니다. 서버 렌더링 폼은 이것으로 대개 해결됩니다.
- 세션이 서버에 있으면 synchronizer token, 없으면 서명된 double submit을 씁니다. 서명 없는 double submit은 쿠키를 쓸 수 있는 공격자에게 우회되므로 OWASP가 권하지 않습니다. HMAC으로 세션과 묶어야 합니다.
- 신규 API라면 Fetch Metadata + Origin 검증이 관리 비용이 낮습니다. 폼마다 토큰을 넣을 필요가 없습니다. Go는 1.25부터 표준 라이브러리에
http.CrossOriginProtection을 제공합니다.
Fetch Metadata는 브라우저가 붙이는 Sec-Fetch-Site 헤더(same-origin, same-site, cross-site, none)로 판단합니다. 주의할 점은 두 가지입니다. HTTPS 등 신뢰할 수 있는 출처로만 전송되므로 OWASP는 HTTPS 전면 적용과 Origin 검증 폴백을 필수로 봅니다. 또 same-site는 형제 서브도메인을 신뢰할 때만 허용해야 합니다. MDN 예제는 이를 허용하지만, 서브도메인이 많다면 막는 쪽을 기본으로 삼으세요.
const TRUSTED_ORIGINS = new Set(['https://app.example.com']);
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
export function csrfGuard(req, res, next) {
if (SAFE_METHODS.has(req.method)) return next();
const origin = req.get('origin');
// 문자열 완전 일치. "null"이나 유사 도메인은 통과하지 못한다
if (origin && TRUSTED_ORIGINS.has(origin)) return next();
const site = req.get('sec-fetch-site');
if (site) {
if (site === 'same-origin' || site === 'none') return next();
return res.status(403).json({ error: 'cross-origin request blocked' });
}
// Sec-Fetch-Site가 없는 구형 브라우저: Origin과 Host 비교
if (origin === `https://${req.get('host')}`) return next();
return res.status(403).json({ error: 'cross-origin request blocked' });
}
프록시 뒤에서는 Host가 바뀔 수 있으니 실제 값을 확인해야 합니다. 이 미들웨어는 쿠키 인증 경로에만 적용합니다. 헤더가 모두 없는 요청의 처리는 자료마다 다릅니다. Go 구현은 비브라우저 요청으로 보고 허용하지만, OWASP는 헤더가 없다고 동일 출처로 볼 수 없다고 합니다. 위 코드는 거부하는 쪽을 택했으므로, 서버 간 호출은 쿠키가 아닌 별도 인증 경로로 분리합니다.
Origin: null 처리가 실제로 문제가 된 사례도 있습니다. Next.js의 CVE-2026-27978은 샌드박스 iframe 등에서 오는 null Origin을 "없는 값"으로 취급해 Server Actions의 CSRF 검증을 건너뛸 수 있던 결함으로, 16.1.7에서 수정되었습니다. Next.js는 Server Actions에서 Origin과 Host를 비교하지만 이 설명은 Server Actions에 관한 것입니다. 쿠키로 인증하는 route.ts는 별도로 점검해야 합니다.
Spring Security는 기본값으로 CSRF 보호가 켜져 있습니다. SPA에서는 공식 문서(7.1 기준)의 설정을 쓰고, 클라이언트는 쿠키 값을 헤더로 보냅니다. 구버전은 해당 버전 문서를 확인하세요.
http.csrf((csrf) -> csrf.spa());
인증 방식별 필요한 조치
| 인증 구성 | CSRF 위험 | 필요한 조치 |
|---|---|---|
| 세션 쿠키 + 서버 렌더링 | 있음 | 프레임워크 토큰 + SameSite=Lax 명시 |
| 쿠키 + SPA, 같은 Origin | 있음 | csrf.spa()류 토큰 또는 csrfGuard, JSON만 허용 |
| 쿠키 + SPA, 서브도메인 API | 있음 (Lax가 도움 안 됨) | Origin 정확 일치 목록, 서명 토큰, __Host-, CORS 고정 |
| 쿠키 + SPA, 다른 사이트 API | 높음 (SameSite=None) | 서버 검증 필수, 구조 변경 검토 |
| 쿠키에 담은 JWT | 세션 쿠키와 동일 | 위와 동일 |
Authorization: Bearer 헤더만 사용 | 낮음 | CSRF 토큰 불필요. 토큰 탈취 대비 XSS 방어에 집중 |
| 모바일 앱·서버 간 API 키 | 낮음 | 브라우저 미사용 경로로 분리 |
표에서 주의할 부분은 Bearer 행입니다. 브라우저가 자동으로 붙이지 않으니 위험이 낮지만, 같은 API가 쿠키도 인증 수단으로 받는다면 쿠키 구성으로 취급해야 합니다. "웹은 쿠키, 앱은 Bearer"로 함께 운영하면서 앱 때문에 CSRF 보호를 전체 해제한 경우를 점검하세요. Spring 문서도 브라우저 트래픽을 받지 않는 백엔드에 한해 비활성화를 고려할 수 있다고 설명합니다.
브라우저가 자동으로 붙이는 다른 인증(HTTP Basic, 인증서, 사내망 IP 신뢰)도 쿠키와 같은 위험이 있습니다. 내부 관리자 도구를 볼 때 놓치지 마세요.
출시 전 CSRF 점검 체크리스트
- 모든 세션·인증 쿠키에
SameSite,Secure,HttpOnly가 명시되어 있다. - GET·HEAD 핸들러 중 데이터를 바꾸는 것이 없다. 메서드 오버라이드는 꺼져 있거나 POST 본문으로 제한했다.
- 쿠키 인증을 쓰는 모든 비안전 메서드 라우트에 토큰 또는 Origin·Fetch Metadata 검증이 적용되어 있다.
- 프레임워크의 CSRF 예외 목록을 사람이 검토했다 (
ignoringRequestMatchers,csrf_exempt등). - JSON API가
text/plain, 폼 인코딩 본문을 거부한다. - CORS가 고정 목록이고, Origin 반사나 서브도메인 정규식이 없다.
credentials는 필요한 곳에만 켰다. - 쿠키에 불필요한
Domain속성이 없고, 가능한 곳은__Host-를 쓴다. - 쓰지 않는 서브도메인의 DNS 레코드를 정리했다.
- 로그인, 비밀번호·이메일 변경, 결제, 권한 변경에 재인증을 적용할지 결정했다.
- WebSocket 핸드셰이크에서 Origin을 확인한다.
스테이징에서는 유효한 세션 쿠키로 위조 Origin 요청을 보내 403이 나오는지 확인합니다. 서버 검증만 보는 시험이므로 실제 브라우저의 SameSite 동작은 별도로 확인합니다. Chrome 기준으로 쿠키 설정 후 2분 이내와 이후 양쪽을 시험해 보는 것이 좋습니다.
curl -i -X POST https://staging.example.com/api/profile/email \
-H 'Origin: https://evil.example' \
-H 'Content-Type: application/json' \
-H 'Cookie: __Host-session=테스트_세션값' \
-d '{"email":"[email protected]"}'
자주 묻는 질문
SameSite=Strict로 설정하면 CSRF 토큰이 필요 없나요?
서브도메인이 하나뿐이고 신뢰할 수 있으며 GET 상태 변경이 없다면 위험은 크게 줄어듭니다. 그래도 형제 서브도메인과 사이트 내부 리다이렉트 경로는 막지 못하고, 외부 링크로 들어온 사용자가 로그아웃 상태로 보이는 부작용도 있습니다. OWASP와 MDN 모두 보조 방어로 분류하므로 서버 검증을 함께 두는 것이 좋습니다.
로그인 기능이 없는 서비스에도 CSRF 방어가 필요한가요?
보호할 세션이 없다면 일반적인 CSRF의 영향은 작습니다. 다만 관리자 로그인이 있다면 그 경로는 대상입니다. 사내망 IP를 신뢰하는 내부 도구도 같은 조건입니다.
토큰을 쿠키에 저장하면 XSS에 노출되지 않나요?
SPA용 CSRF 쿠키는 JS가 읽어야 하므로 HttpOnly를 쓸 수 없습니다. 세션 쿠키와 달리 CSRF 토큰만으로는 인증이 되지 않기 때문에 일반적인 구성입니다. 세션 쿠키는 HttpOnly로 분리해 두고, XSS는 별도로 막습니다.
정리
CSRF 공격은 브라우저가 쿠키를 자동으로 보내는 동작을 이용하므로, 쿠키로 인증하는 서비스라면 대상이 됩니다.
SameSite=Lax는 유용하지만 브라우저마다 기본값이 다르고 GET 상태 변경, 서브도메인, SameSite=None 구성을 막지 못합니다. 값을 명시하고 보조 방어로 쓰세요.
서버 측에서는 프레임워크 토큰을 우선 쓰고, 신규 API는 Fetch Metadata와 Origin 정확 일치 검증을 검토합니다. Bearer 헤더만 쓰는 API는 토큰 대신 XSS 방어에 집중합니다.
직접 점검이 어렵다면 무료 분석 신청으로 현재 구성을 확인받아 볼 수 있습니다.
참고 자료
- OWASP Cross-Site Request Forgery Prevention Cheat Sheet
- MDN: Cross-site request forgery (CSRF)
- MDN: Set-Cookie header
- MDN browser-compat-data: Set-Cookie
- PortSwigger Web Security Academy: Bypassing SameSite cookie restrictions
- web.dev: Understanding same-site and same-origin
- Chromium: SameSite Frequently Asked Questions
- Filippo Valsorda: Cross-Site Request Forgery
- Go net/http: CrossOriginProtection
- Spring Security Reference: Cross Site Request Forgery
함께 읽으면 좋은 글
웹서비스 출시 전 보안 점검표: 배포 환경에서 놓치는 10가지
출시 전 보안 점검을 코드 밖 배포 환경에 집중해 정리했습니다. 디버그 모드, .git, 백업 파일, 스테이징, 기본 계정 등 10가지를 curl로 확인하고 고치는 방법을 얻을 수 있습니다.
Express 보안 설정 가이드: 배포 전 고칠 미들웨어 실수 7가지
Express 보안 설정에서 자주 틀리는 trust proxy, 미들웨어 순서, 본문 크기 제한, 에러 스택 노출 등 7가지를 잘못된 코드와 고친 코드로 비교해 배포 전 점검 기준을 제시합니다.
AWS 보안 설정 가이드: 초기 스타트업이 하루에 잠글 5가지
AWS 보안 설정을 루트 계정, IAM, S3, 보안 그룹, 감사 로그 순으로 정리했다. 소규모 팀이 하루 안에 점검하고 고칠 수 있는 명령어와 수정 전후 예시, 판단 기준을 담았다.