
"쿠키(Cookie)를 인증 수단으로 사용할 때, SameSite 속성을 Lax나 Strict로 설정하는 것만으로 CSRF 공격을 완벽히 막을 수 있을까요? 추가적으로 'CSRF 토큰'이나 'Custom Header'가 필요한 이유는 무엇이라고 생각하시나요?"
"사용자가 우리 서비스에 로그인한 상태로, 해커가 만든 악성 사이트에 접속했습니다. 그 사이트에는 우리 서비스의 '비밀번호 변경' API를 자동으로 호출하는 폼(Form)이 숨겨져 있습니다. 사용자가 모르는 사이에 비밀번호가 바뀌는 이 공격을 프론트엔드와 백엔드에서 어떻게 협업하여 막으시겠습니까?"
CSRF(Cross-Site Request Forgery) 공격의 목적
사용자의 권한을 도용해 비밀번호 변경, 결제, 게시글 삭제 등 서버의 상태를 변조하는 것입니다.
기본적으로 GET 요청은 자원의 변경하지 않기 때문에 문제가 될 일은 없다만, 만에 하나 GET 메소드로 잘못 지정하거나 잘못 사용하고 있는 서버 자원을 변경하는 API가 있다면 공격의 대상이 될 수 있습니다.
CSRF PUT/POST 공격 시나리오
CSRF POST/PUT 공격은 사용자의 브라우저가 대상 서버로 요청을 보낼 때 인증 쿠키를 자동으로 포함하는 특성을 악용합니다.
공격자는 사용자가 로그인한 상태를 유지하고 있다는 점을 악용하여, 사용자 모르게 서버 상태를 변경하는 요청을 보내게 합니다.
예를 들어 사이트에 로그인한 상태에서 해커의 사이트에 접속하게 하여 form 을 제출하게 합니다.
근데 외부 요청은 서버에서 설정한 CORS 정책으로 다 막히지 않나요?
<form> 태그는 CORS 정책을 무시합니다.
브라우저는 폼 제출을 페이지 전환의 일종으로 간주하기 때문에 다른 도메인으로 POST 요청 보내는 것에 대해서는 무시합니다.
form 태그는 method="POST" 와 GET 만 지원하기에 해커의 공격 대상은 POST, GET 뿐입니다.
일단 외부에서 우리 사이트로 요청이 들어오는 경우
브라우저 레벨에서 SameSite Cookie 설정을 통해 외부 사이트에서 쿠키 자체가 넘어가지 못하게 하여 대부분 방어가 됩니다.
SameSite는 "다른 사이트(Cross-site)에서 발생한 요청에 이 쿠키를 실어 보낼 것인가?"를 결정합니다.
SameSite=Strict 가장 엄격합니다.
사용자가 google.com에 있다가 링크를 타고 naver.com으로 넘어올 때조차 쿠키를 보내지 않습니다.
(사용자 경험이 나빠질 수 있음 - 매번 다시 로그인 등)
SameSite=Lax (기본값) 조금 유연합니다.
외부 사이트에서 우리 사이트로 오는 '하위 리소스 요청(img, iframe 등)'은 차단하지만, '안전한 요청(GET)'이나 '탑 레벨 내비게이션(링크 클릭)', 즉 브라우저의 주소창이 바뀌는 a 태그 클릭이나 window.location.href 이동은 쿠키 전송을 허용합니다.
하지만 POST, PUT, DELETE 같은 상태 변경 요청에는 쿠키를 보내지 않습니다.
애플리케이션 레벨에서는 CSRF Token 로 방어가 가능합니다.
서버가 페이지 렌더링 시 매번 무작위 토큰을 발급하고, 클라이언트는 POST/PUT 요청 시 이를 헤더나 바디에 포함합니다.
해커는 사용자의 쿠키는 브라우저를 통해 보낼 수 있지만, 자바스크립트로 우리 서버의 토큰값을 읽어올 수 없으므로(SOP 정책) 공격이 실패합니다. 보통 초고보안이 필요한 환경에서 이렇게 합니다.
SPA 에서는 매번 서버가 토큰을 보내줄 수 없으니 로그인시 무작위 생성된 CSRF 토큰을 쿠키에 넣어둡니다.
HttpOnly: true 가 아닌 false 설정하여, 자바스크립트에서 읽어와서 API 요청 보낼때 별도의 헤더로 실어 보내는 것입니다.
왜 그러냐면 브라우저가 자동으로 실어 보내는 쿠키를 맹신적으로 믿어서는 공격 당할 수 있기 때문에 별도의 헤더를 쓰는 것입니다.
인프라/API 레벨에서는 Custom Header 검증이 가능합니다.
X-Requested-With 같은 고정된 커스텀 헤더를 하나 추가하여
- 왜 Custom Header가 CSRF를 막는가? (핵심 원리)
핵심은 "브라우저는 교차 출처(Cross-Origin) 요청 시, 커스텀 헤더가 포함되면 반드시 Preflight(OPTIONS) 요청을 보낸다" 는 점에 있습니다.
기본 헤더 (Simple Request): Content-Type: text/plain, Accept 등 표준 헤더만 사용하면 브라우저는 바로 서버로 요청을 보냅니다.
커스텀 헤더: X-Requested-With, X-App-Version 같은 표준이 아닌 헤더를 추가하면, 브라우저는 이 요청을 '잠재적으로 위험한 요청' 으로 간주합니다.
Preflight 발생: 브라우저는 실제 요청을 보내기 전에 OPTIONS 메소드로 서버에 "이 헤더 써도 돼?"라고 먼저 물어봅니다.
CORS 차단: 해커의 사이트(evil.com)에서 우리 API로 커스텀 헤더를 실어 보내려 하면, 서버는 Preflight 응답에서 evil.com을 허용하지 않을 것이고, 브라우저는 실제 요청 자체를 보내지 않고 차단합니다.
① 클라이언트(프론트엔드) 설정
모든 API 요청에 공통 헤더를 주입합니다. 보통 axios 인스턴스나 fetch 래퍼 함수에서 설정합니다.
import axios from 'axios';
const api = axios.create({
baseURL: 'https://api.bank.com',
headers: {
// 관례적으로 많이 쓰는 헤더명입니다. 값은 무엇이든 상관없습니다.
'X-Requested-With': 'XMLHttpRequest',
'X-App-Service-Id': 'my-frontend-app'
},
withCredentials: true // 쿠키를 포함하여 전송
});
② 서버(백엔드) 설정
서버는 두 가지를 수행해야 합니다.
CORS 설정: Access-Control-Allow-Headers에 우리가 정의한 커스텀 헤더명을 추가하여 허용합니다.
헤더 검증 미들웨어: 상태를 변경하는 요청(POST, PUT, DELETE 등)이 올 때, 해당 헤더가 존재하는지 확인합니다.
// Express.js 예시 미들웨어
app.use((req, res, next) => {
// GET 요청은 대개 제외하고, 상태 변경 요청에서만 체크
if (['POST', 'PUT', 'DELETE'].includes(req.method)) {
const customHeader = req.headers['x-requested-with'];
if (!customHeader) {
// 헤더가 없으면 우리 프론트엔드에서 보낸 것이 아니라고 판단
return res.status(403).json({ error: 'Invalid Request: Missing Custom Header' });
}
}
next();
});
CSRF GET 공격 시나리오
로그인한 사용자가 a 태그를 클릭하게 만들고, 그 주소가 'https://bank.com/transfer?to=hacker&amount=10000' 이와 같을 경우, 쿠키가 sameSite=LAX 이면 외부 사이트여도 a 링크니까 쿠키가 보내질거고, CORS 의 영향을 받지도 않겠네요?
그럴 수 있습니다.
SameSite=Lax 에서 a 태그를 이용한 탑 레벨 내비게이션은 쿠키 전송이 허용되므로, 보안이 허술한 서비스라면 CSRF 공격에 노출될 수 있습니다.
CORS는 '스크립트(JS)'가 다른 도메인의 데이터를 읽으려 할 때 작동하는 보안 책입니다.
사용자가 직접 링크를 클릭해서 페이지를 이동하는 것은 브라우저의 기본 동작이므로 CORS 정책의 적용 대상이 아닙니다.
사용자가 이미 은행에 로그인되어 있다면, 브라우저는 "사용자가 직접 링크를 눌러서 은행으로 가겠다는데, 당연히 인증 쿠키를 보내줘야지"라고 판단합니다.
CSRF GET 방어 방법
이를 방어하기 위해 두 가지 원칙을 고수해야합니다.
첫째, 상태를 변경하는 모든 API는 반드시 POST/PUT/DELETE를 사용하고,
둘째, 단순 쿠키 인증에만 의존하지 않고 CSRF 토큰이나 Custom Header 검증을 병행합니다.
특히 금융권처럼 민감한 서비스라면 결제/이체 직전에 다중 인증(MFA) 레이어를 추가하여 사용자 의사를 최종 확인하는 설계가 필수적입니다."
CSRF Token의 원리
CSRF 토큰은 "이 요청이 우리 프론트엔드에서 보낸 것이 맞다"라는 일종의 신분증입니다.
- 사용자가 접속하면 서버는 무작위 토큰을 발행하여 세션에 저장하고, 프론트엔드에 전달합니다.
- 프론트엔드는 POST/PUT 요청을 보낼 때 HTTP 헤더(예: X-CSRF-TOKEN)에 이 값을 실어 보냅니다.
- 서버는 쿠키(자동 전송됨)와 헤더의 토큰(프론트가 직접 넣어줘야 함)을 비교합니다.
- 해커의 악성 사이트에서는 사용자의 쿠키는 (브라우저가) 실어 보낼 수 있어도, 우리 서버가 발행한 고유 토큰값은 알 방법이 없으므로 공격이 차단됩니다.
정리해보자면, 쿠키 sameSite=LAX 인 경우 a tag 의 GET 요청으로 CSRF 공격이 들어올 수 가 는데
외부 사이트는 CORS 정책으로 막히거나 sameSite=LAX 에 의해 쿠키 자체가 넘어가지 않겠지만
a tag 로 넣은 주소는 same site 이므로, 해당 사이트에 사용자가 로그인한 상태라면 쿠키 정보가 넘어갈 수 있게됩니다.
근데 이때 서버단에서 Custom Header 검증이 있다면, a tag 를 통해 넘어왔을때 우리 도메인의 클라이언트에서 Custom Header 를 집어넣어준 것이 아니기 때문에 검증이 실패할 것이고 방어를 할 수 있다. 이렇게 정리가 되겠습니다.
'프론트엔드 개발 > 시니어 시리즈' 카테고리의 다른 글
| [시니어 FE] 동시성(Concurrency) 에 대해 알아보자 (0) | 2026.03.05 |
|---|---|
| [시니어 FE] Web Worker 에 대해 알아보자 (Comlink) (0) | 2026.03.05 |