
이 장을 마치면
- 인증과 인가를 구분하고, "로그인 상태" 가 실제로는 누가 무엇을 기억하는 것인지 설명할 수 있습니다.
- 서버 세션 방식과 토큰 방식의 요청 흐름을 그림으로 그리고, 둘의 장단점을 비교할 수 있습니다.
Set-Cookie헤더를 읽고, 각 속성(Domain·Path·Expires·Max-Age·Secure·HttpOnly·SameSite)이 브라우저의 동작을 어떻게 바꾸는지 직접 실험으로 확인할 수 있습니다.- 출처(origin) 와 사이트(site) 를 구분하고, 두 주소가 같은 출처인지·같은 사이트인지 판정할 수 있습니다.
- 브라우저 개발자 도구로 쿠키가 어디에 저장되고 어느 요청에 실리는지 확인할 수 있습니다.
들어가며
로그인 기능을 처음 만들 때 대부분은 "로그인 API 를 부르면 토큰이 오고, 그 토큰을 붙여서 요청하면 된다" 에서 출발합니다. 이 설명이 틀린 것은 아닙니다. 다만 이 수준에서 멈추면 머리말에서 던진 질문들, 곧 토큰을 어디에 저장해야 하는지, 탭을 두 개 열면 왜 로그아웃되는지, SameSite 만 켜면 왜 부족한지에 답할 수 없습니다. 이 질문들은 모두 한 가지 사실에서 갈라져 나옵니다. HTTP 는 아무것도 기억하지 않고, 그래서 누군가가 대신 기억해야 한다는 사실입니다.
이 장은 그 "기억" 을 누가, 어디에, 어떤 형태로 맡는지를 다룹니다. 서버가 기억하면 세션 방식이 되고, 클라이언트가 서명된 증명서를 들고 다니면 토큰 방식이 됩니다. 그리고 브라우저에서 무언가를 기억하게 하는 가장 오래된 장치가 쿠키입니다. 이 책의 설계에서 리프레시 토큰은 쿠키로 오가고, CSRF 방어의 첫 겹도 쿠키 속성(SameSite)입니다. 쿠키를 정확히 이해하지 못하면 6장 이후의 코드를 "왜 이렇게 쓰는지" 모른 채 따라 치게 됩니다.
마지막으로 출처와 사이트를 구분합니다. 이 책에서 가장 자주 헷갈리는 개념이고, 8·9장의 CSRF 실습 전체가 이 구분 위에 서 있습니다. http://localhost:5173 과 http://localhost:4000 은 다른 출처지만 같은 사이트입니다. 이 문장이 왜 참인지 이 장이 끝날 때 설명할 수 있어야 합니다.
이 장과 다음 장은 원리만 다룹니다. auth-lab 의 코드는 3장부터 씁니다. 이 장에서 돌리는 것은 쿠키 동작을 눈으로 보기 위한 작은 실험 서버 하나뿐입니다.
1.1 인증과 인가
먼저 두 낱말을 갈라 둡니다. 영어로는 authentication 과 authorization 이라 철자가 비슷하고, 줄여서 둘 다 "auth" 라고 부르는 바람에 자주 섞입니다.
인증(authentication) 은 "당신은 누구인가" 에 답하는 일입니다. 이메일과 비밀번호를 받아 그 조합이 맞는지 확인하고, 맞으면 이 요청을 보낸 사람이 사용자 kim@example.com 이라고 결론 내립니다. 인증의 결과물은 신원입니다.
인가(authorization) 는 "당신은 이것을 해도 되는가" 에 답하는 일입니다. 신원이 확인된 사용자가 다른 사람의 세션을 끊으려 할 때 막는 것, 관리자만 볼 수 있는 페이지를 일반 사용자에게 숨기는 것이 인가입니다. 인가는 인증의 결과를 입력으로 받습니다. 누구인지 모르면 무엇을 허락할지도 정할 수 없습니다.
HTTP 상태 코드도 이 구분을 따릅니다.
| 상태 코드 | 이름 | 뜻 | 이 책의 예 |
|---|---|---|---|
401 | Unauthorized | 이름과 달리 인증 실패. "누구인지 모르겠다" | 액세스 토큰이 없거나 만료됨 → ACCESS_TOKEN_INVALID |
403 | Forbidden | 인가 실패. "누구인지는 알지만 이건 안 된다" | 허용되지 않은 출처에서 온 요청 → CSRF_ORIGIN_REJECTED |
401 의 이름이 Unauthorized 인 것은 역사적인 실수에 가깝습니다. 뜻은 "인증되지 않음" 입니다. 이 책은 FE 가 401 을 받으면 "토큰을 새로 받아 다시 시도" 하고, 403 을 받으면 "다시 시도해도 소용없다" 로 다룹니다. 두 코드를 섞어 쓰면 FE 의 재시도 로직이 엉킵니다.
💡 이 책이 만드는 것은 주로 인증입니다. 인가는 "자기 세션만 끊을 수 있다"(7장의 SESSION_NOT_FOUND) 정도로 가볍게만 등장합니다. 역할(role)이나 권한 체계는 다루지 않습니다.
1.2 HTTP 는 아무것도 기억하지 않는다
HTTP 는 무상태(stateless) 프로토콜입니다. 요청 하나는 그 자체로 완결되어야 하고, 서버는 앞선 요청과 지금 요청이 같은 사람에게서 왔는지 프로토콜 차원에서는 알 수 없습니다. 같은 TCP 연결을 재사용하더라도 마찬가지입니다. 연결은 성능 최적화일 뿐이고, 연결 하나에 여러 사용자의 요청이 섞일 수도(프록시를 거치면) 있고, 한 사용자의 요청이 여러 연결로 흩어질 수도 있습니다.
그래서 다음 대화는 HTTP 로는 성립하지 않습니다.
브라우저: POST /login { email, password }
서버 : 200 OK — 확인했습니다, kim 님
브라우저: GET /users/me
서버 : ...누구시죠?
두 번째 요청에는 "나는 방금 로그인한 그 사람이다" 라는 정보가 없습니다. 서버가 첫 요청의 결과를 두 번째 요청과 이으려면 요청 자체에 신원을 증명할 무언가가 실려 와야 합니다. 이 "무언가" 를 무엇으로 할지, 그리고 그것의 의미를 누가 기억할지가 인증 설계의 출발점입니다.
선택지는 크게 둘입니다.
- 서버가 기억한다. 로그인에 성공하면 서버가 "세션" 이라는 기록을 만들고, 그 기록을 가리키는 번호표(세션 ID)만 클라이언트에 줍니다. 클라이언트는 매 요청에 번호표를 보여 주고, 서버는 자기 장부에서 그 번호를 찾아 누구인지 알아냅니다.
- 클라이언트가 증명서를 들고 다닌다. 로그인에 성공하면 서버가 "이 사람은 사용자 X 이고, 이 증명서는 10분 뒤 만료된다" 라는 내용을 적고 서명한 증명서를 줍니다. 클라이언트는 매 요청에 증명서를 보여 주고, 서버는 장부를 보지 않고 서명만 확인해 누구인지 알아냅니다.
1번이 서버 세션 방식, 2번이 토큰 방식입니다. 두 방식의 차이는 "기억이 어디에 있는가" 하나로 요약됩니다. 이 차이가 확장성, 폐기 가능성, 저장 크기 같은 성질을 모두 결정합니다.
1.3 서버 세션 방식
서버 세션 방식은 웹의 오래된 기본값입니다. PHP 의 PHPSESSID, Java 서블릿의 JSESSIONID, Express 의 express-session 이 모두 이 방식입니다.
브라우저 서버 세션 저장소
──────── ──── ──────────
① POST /login {email, password} ───────▶ 비밀번호 확인
세션 생성 ─────────────────────▶ sid=a8f3… → { userId: 42,
createdAt, … }
◀── 200 + Set-Cookie: sid=a8f3…; HttpOnly
② GET /users/me ────────────────────────▶ 쿠키에서 sid 꺼냄
Cookie: sid=a8f3… (브라우저가 자동으로) sid 로 조회 ──────────────────▶ { userId: 42 }
◀──────────────────────────────
◀── 200 { id: 42, … }
③ POST /logout ─────────────────────────▶ 세션 삭제 ─────────────────────▶ sid=a8f3… 삭제
◀── 200 + Set-Cookie: sid=; Max-Age=0
④ (탈취된 sid 로) GET /users/me ────────▶ sid 로 조회 ──────────────────▶ 없음
◀── 401
세션 ID 자체에는 아무 의미가 없습니다. a8f3… 는 충분히 긴 난수일 뿐이고, 사용자 42 라는 정보는 서버의 저장소에만 있습니다. 이런 값을 불투명(opaque) 하다고 합니다. 밖에서 봐서는 속을 알 수 없다는 뜻입니다.
이 방식의 가장 큰 장점은 ④에 있습니다. 서버가 장부에서 한 줄을 지우면 그 순간부터 해당 세션 ID 는 아무 쓸모가 없어집니다. 로그아웃, 비밀번호 변경 후 다른 기기 끊기, 관리자의 강제 로그아웃이 모두 "행 하나 지우기" 로 끝납니다.
대가는 모든 요청이 저장소를 조회한다는 점입니다. 서버가 한 대면 메모리에 두면 되지만, 여러 대로 늘리면 모든 서버가 같은 장부를 봐야 하므로 Redis 같은 공유 저장소가 필요합니다. 그리고 세션 ID 가 쿠키로 오가기 때문에 브라우저가 쿠키를 자동으로 붙인다는 성질을 그대로 떠안습니다. 다른 사이트가 유도한 요청에도 쿠키가 붙을 수 있다는 뜻이고, 이것이 CSRF 의 뿌리입니다(8장).
1.4 토큰 방식
토큰 방식에서 서버는 장부 대신 서명을 믿습니다.
브라우저 서버 (비밀 키 K 보유)
──────── ──────────────────────
① POST /login {email, password} ───────▶ 비밀번호 확인
토큰 = { sub: 42, exp: 10분 뒤 } + 서명(K)
◀── 200 { accessToken: "eyJhbGciOi…" }
(JS 가 토큰을 어딘가에 보관)
② GET /users/me ────────────────────────▶ 헤더에서 토큰 꺼냄
Authorization: Bearer eyJhbGciOi… K 로 서명 확인 → 맞음
(JS 가 직접 붙임) exp 확인 → 아직 유효
sub 를 읽음 → 42
◀── 200 { id: 42, … } (저장소 조회 없음)
③ "로그아웃" ── 서버에 알릴 것이 없다. 클라이언트가 토큰을 버릴 뿐.
④ (탈취된 토큰으로) GET /users/me ──────▶ 서명 맞음, exp 안 지남
◀── 200 ← exp 까지는 막을 방법이 없다
토큰 안에 "누구인가(sub)" 와 "언제까지 유효한가(exp)" 가 들어 있고, 서버의 서명이 그 내용이 위조되지 않았음을 보증합니다. 서버는 비밀 키만 있으면 되므로 여러 대로 늘려도 공유 저장소가 필요 없습니다. 이런 토큰을 자기 완결(self-contained) 토큰이라 부르고, 대표적인 형식이 JWT 입니다. JWT 의 구조는 2장에서 손으로 뜯어봅니다.
대가는 ④입니다. 서버가 장부를 보지 않으므로 이미 발급한 토큰을 취소할 방법이 없습니다. 탈취된 토큰은 exp 가 지날 때까지 유효합니다. 이 문제를 줄이는 표준적인 답이 "액세스 토큰은 아주 짧게, 대신 새 액세스 토큰을 받는 수단을 따로 둔다" 이고, 그 수단이 리프레시 토큰입니다. 2장의 주제입니다.
또 하나 눈여겨볼 차이는 전달 방식입니다. 위 그림에서 토큰은 Authorization 헤더로 갑니다. 이 헤더는 브라우저가 자동으로 붙여 주지 않고 JS 가 직접 붙여야 합니다. 그래서 다른 사이트가 유도한 요청에는 실리지 않고, CSRF 의 대상이 되지 않습니다. 반면 JS 가 토큰을 읽을 수 있어야 하므로 어딘가(메모리, localStorage 등)에 보관해야 하고, 그 보관 장소가 XSS 에 노출됩니다. 보관 장소 비교는 2장에서 표로 정리합니다.
두 방식 비교
| 관점 | 서버 세션 | 토큰(자기 완결) |
|---|---|---|
| 기억이 있는 곳 | 서버 저장소 | 토큰 자체(서명으로 보증) |
| 클라이언트가 가진 것 | 의미 없는 난수(불투명) | 내용을 읽을 수 있는 증명서 |
| 요청당 서버 작업 | 저장소 조회 1회 | 서명 검증(계산만) |
| 서버를 여러 대로 늘릴 때 | 공유 저장소 필요 | 비밀 키만 공유하면 됨 |
| 즉시 폐기 | 쉬움 — 행 삭제 | 어려움 — exp 까지 유효 |
| 크기 | 수십 바이트 | 수백 바이트 이상 |
| 흔한 전달 방식 | 쿠키 (자동 전송 → CSRF 대상) | Authorization 헤더 (수동 → XSS 에 보관 장소 노출) |
| 다른 서비스가 검증 | 저장소에 접근해야 함 | 키만 있으면 됨 (공개키 서명이면 공개키만) |
⚠️ 표의 마지막에서 두 번째 줄을 "세션은 쿠키, 토큰은 헤더" 로 외우면 안 됩니다. 무엇을 기억하느냐(세션 vs 토큰) 와 어떻게 보내느냐(쿠키 vs 헤더) 는 서로 다른 축입니다. JWT 를 쿠키에 담을 수도 있고, 불투명한 세션 ID 를 헤더로 보낼 수도 있습니다. 전달 방식을 바꾸면 위험의 종류가 바뀝니다. 쿠키에 담으면 CSRF 를, 헤더로 보내면 보관 장소의 XSS 노출을 신경 써야 합니다.
이 책은 둘을 섞는다
이 책의 설계는 두 방식의 장점을 나눠 가집니다.
- 액세스 토큰은 자기 완결 JWT 입니다. 수명이 10분이라 폐기를 못 해도 피해가 10분으로 묶입니다. 헤더로 보내므로 CSRF 대상이 아닙니다.
- 리프레시 토큰은 사실상 서버 세션입니다. 불투명한 난수이고, 서버가 DB 에 (해시로) 기록해 두며, 행을 폐기하면 즉시 무효가 됩니다. 쿠키로 오가므로 CSRF 방어를 세 겹 두릅니다.
즉 "매 요청" 은 저장소 없이 빠르게 처리하고, "10분에 한 번" 만 저장소를 봐서 폐기 여부를 반영합니다. 이 균형이 왜 합리적인지는 2장의 2.8절에서 수명·노출 빈도·폐기 가능성으로 나눠 따집니다.
1.5 쿠키 해부
쿠키는 서버가 브라우저에게 "이 값을 기억했다가 조건이 맞는 요청마다 다시 보내 달라" 고 부탁하는 장치입니다. 부탁은 응답의 Set-Cookie 헤더로 하고, 브라우저는 조건이 맞는 요청에 Cookie 헤더를 붙여 돌려보냅니다. 규칙은 RFC 6265 와 그 개정판(RFC 6265bis 초안)에 있습니다.
Set-Cookie 문법
Set-Cookie: <이름>=<값>[; <속성>[=<값>]]*
예를 들면 이렇습니다.
Set-Cookie: refresh_token=Q0y1ECht…; Path=/auth; Expires=Wed, 07 Oct 2026 03:00:00 GMT; HttpOnly; Secure; SameSite=Strict
처음의 이름=값 이 쿠키의 본체이고, 세미콜론 뒤는 브라우저에게 주는 지시입니다. 몇 가지 규칙이 있습니다.
Set-Cookie헤더 하나에 쿠키 하나입니다. 쿠키 세 개를 심으려면 헤더를 세 줄 보냅니다. 쉼표로 이어 붙이면 안 됩니다.Expires의 날짜 형식에 쉼표가 들어 있기 때문에 쉼표로는 경계를 가를 수 없습니다.- 브라우저가 돌려보내는
Cookie헤더에는 이름과 값만 있습니다.Cookie: refresh_token=Q0y1ECht…; csrf_token=…처럼 속성은 전혀 오지 않습니다. 서버는 받은 쿠키의Path나 만료 시각을 알 수 없습니다. 쿠키를 지우려는 서버가 "설정할 때 준 속성을 스스로 기억해야" 하는 이유가 여기 있습니다. - 값에 공백·쉼표·세미콜론 같은 문자를 그대로 넣을 수 없습니다. 이 책의 쿠키 값은 모두 base64url(2장)이라 이 문제를 비켜 갑니다.
이제 속성을 하나씩 봅니다. 각 속성은 결국 "이 쿠키를 어느 요청에 실을 것인가" 또는 "누가 이 쿠키를 읽을 수 있는가" 를 정합니다.
실험 서버 준비
속성의 효과를 눈으로 보려면 쿠키를 심는 서버가 필요합니다. auth-lab 의 코드는 3장부터 쓰므로, 여기서는 Node.js 만으로 도는 실험용 서버를 파일 없이 띄웁니다. 아무 디렉터리에서 다음을 실행하세요.
$ node - <<'EOF'
const http = require('node:http');
const COOKIES = [
'plain=1; Path=/',
'hidden=1; Path=/; HttpOnly',
'admin=1; Path=/admin',
'short=1; Path=/; Max-Age=20',
'secure=1; Path=/; Secure',
'strict=1; Path=/; SameSite=Strict',
'lax=1; Path=/; SameSite=Lax',
'none=1; Path=/; SameSite=None; Secure',
];
const server = http.createServer((req, res) => {
const { pathname } = new URL(req.url, 'http://localhost');
if (pathname === '/set') {
res.setHeader('Set-Cookie', COOKIES);
res.end('cookies set\n');
return;
}
if (pathname === '/from') {
res.setHeader('Content-Type', 'text/html; charset=utf-8');
res.end(`<p>이 페이지의 출처: <b id="o"></b></p>
<script>o.textContent = location.origin</script>
<p><a href="http://localhost:8765/echo">링크로 이동 (GET)</a></p>
<form action="http://localhost:8765/echo" method="POST"><button>폼 전송 (POST)</button></form>`);
return;
}
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
res.end(`${req.method} ${pathname}\nCookie: ${req.headers.cookie ?? '(없음)'}\n`);
});
server.listen(8765, () => console.log('http://localhost:8765 에서 대기 중'));
EOF
http://localhost:8765 에서 대기 중
서버가 하는 일은 셋입니다. /set 은 속성이 서로 다른 쿠키 여덟 개를 심고, /from 은 다른 주소로 가는 링크와 폼이 있는 페이지를 주고, 그 밖의 모든 경로는 받은 Cookie 헤더를 그대로 보여 줍니다. 서버를 끄려면 터미널에서 Ctrl+C 를 누릅니다. 8765 포트가 이미 쓰이고 있으면 코드의 8765 를 전부 다른 번호로 바꾸세요.
Chrome 에서 http://localhost:8765/set 을 한 번 연 뒤 http://localhost:8765/echo 를 엽니다.
GET /echo
Cookie: plain=1; hidden=1; short=1; secure=1; strict=1; lax=1; none=1
(예시 출력. 쿠키 순서는 브라우저에 따라 다를 수 있습니다.)
여덟 개 중 일곱 개가 왔습니다. 빠진 하나(admin)와 나머지의 차이를 아래에서 속성별로 따라갑니다.
⚠️ curl 로 이 실험을 하면 결과가 다릅니다. curl 은 쿠키 저장소(-c/-b)를 흉내 낼 수는 있지만 SameSite 같은 브라우저 정책을 적용하지 않습니다. 쿠키 실험은 브라우저로 합니다.
Domain — 어느 호스트에 보낼까
Domain 속성은 쿠키를 받을 호스트의 범위를 정합니다.
Domain을 생략하면 쿠키는 그것을 심은 호스트에만 갑니다. 이런 쿠키를 호스트 전용(host-only) 쿠키라 부릅니다.api.example.com이 심었다면api.example.com에만 가고app.example.com이나example.com에는 가지 않습니다.Domain=example.com을 주면example.com과 모든 하위 도메인(api.example.com,blog.example.com,a.b.example.com…)에 갑니다. 앞에 점을 붙인.example.com도 같은 뜻으로 처리됩니다.
직관과 반대라서 자주 틀립니다. Domain 을 적으면 범위가 좁아지는 것이 아니라 넓어집니다. 가장 좁은 범위는 생략입니다.
Domain 에는 제약이 있습니다. 쿠키를 심는 호스트 자신이거나 그 상위 도메인이어야 하고, com 이나 co.kr 처럼 공개 접미사(1.6절)여서는 안 됩니다. api.example.com 은 Domain=example.com 을 줄 수 있지만 Domain=other.com 이나 Domain=com 은 줄 수 없습니다. 브라우저가 그 Set-Cookie 를 통째로 버립니다.
이 책의 쿠키는 전부 Domain 을 생략합니다. 리프레시 토큰은 API 호스트에만 가면 되고, Domain=example.com 을 붙이면 같은 등록 도메인 아래의 모든 서브도메인이, 예를 들어 누군가 장악한 blog.example.com 이, 리프레시 토큰을 받게 됩니다(14장에서 운영 도메인 구성과 함께 다시 봅니다).
💡 로컬에서는 Domain 의 효과를 실험하기 어렵습니다. localhost 에는 하위 도메인이 없고, Domain=localhost 를 브라우저가 어떻게 다루는지는 브라우저마다 다릅니다. 실제 사이트에서 개발자 도구를 열어 Domain 열이 .example.com 처럼 점으로 시작하는 쿠키와 호스트 이름 그대로인 쿠키를 비교해 보는 것으로 충분합니다.
⚠️ 쿠키는 포트를 구분하지 않습니다. RFC 6265 가 명시적으로 적어 둔 성질입니다. http://localhost:4000 이 심은 쿠키는 http://localhost:5173 과 http://localhost:5174 로 가는 요청에도 (경로와 다른 조건이 맞으면) 실립니다. 출처는 포트를 보는데 쿠키는 포트를 보지 않는다는 이 어긋남이 1.6절에서 다시 등장합니다.
Path — 어느 경로에 보낼까
Path 는 쿠키를 실을 요청 경로의 접두사입니다. Path=/auth 인 쿠키는 /auth, /auth/refresh, /auth/sessions/… 에 실리고, /users/me 나 /authors(경로 구분자 / 경계가 맞지 않으므로)에는 실리지 않습니다. 생략하면 쿠키를 심은 요청 경로의 "디렉터리" 가 기본값이 되는데, 이 기본값은 예측하기 어려우므로 항상 명시하는 것이 좋습니다.
실험 서버에서 admin 쿠키는 Path=/admin 이었습니다. 그래서 /echo 에는 오지 않았습니다. http://localhost:8765/admin/echo 를 열어 보세요.
GET /admin/echo
Cookie: admin=1; plain=1; hidden=1; short=1; secure=1; strict=1; lax=1; none=1
(예시 출력)
이번에는 admin 이 맨 앞에 옵니다. 브라우저는 경로가 더 긴(더 구체적인) 쿠키를 먼저 보냅니다. 같은 이름의 쿠키가 경로만 달리해 두 개 있을 때 서버가 어느 것을 먼저 읽을지가 이 순서로 정해집니다.
이 책의 refresh_token 은 Path=/auth 입니다. 리프레시 토큰은 /auth/refresh 와 /auth/logout 에서만 필요하므로, /users/me 같은 다른 API 요청에 실려 로그나 프록시에 노출되는 일을 줄이려는 것입니다.
⚠️ Path 는 보안 경계가 아닙니다. 같은 출처의 다른 경로에서 도는 스크립트는 그 경로의 페이지를 iframe 으로 열어 쿠키를 읽는 식으로 경계를 넘을 수 있습니다. Path 는 "불필요한 곳에 싣지 않는" 노출 줄이기 수단이지 격리 수단이 아닙니다.
Expires 와 Max-Age — 언제까지 기억할까
두 속성 모두 쿠키의 수명을 정합니다.
| 속성 | 값 | 예 |
|---|---|---|
Expires | 절대 시각 (HTTP 날짜 형식) | Expires=Wed, 07 Oct 2026 03:00:00 GMT |
Max-Age | 지금부터 몇 초 | Max-Age=7200 |
둘 다 있으면 Max-Age 가 이깁니다. 둘 다 없으면 세션 쿠키가 되어 "브라우저를 닫으면" 사라집니다. 다만 최근 브라우저는 "이전 탭 복원" 기능을 켜 두면 세션 쿠키까지 되살리므로, 세션 쿠키가 언제 사라지는지를 보안 판단의 근거로 삼지는 않습니다.
실험 서버의 short 쿠키는 Max-Age=20 이었습니다. /set 을 연 뒤 20초가 지나서 /echo 를 새로고침하면 short=1 이 사라져 있습니다.
쿠키를 지우는 표준적인 방법은 같은 쿠키를 과거의 Expires 나 Max-Age=0 으로 다시 심는 것입니다. 여기서 "같은 쿠키" 가 무엇인지가 중요합니다. 브라우저는 이름 + Domain + Path 가 같으면 같은 쿠키로 보고 덮어씁니다. 이름이 같아도 Path 가 다르면 다른 쿠키입니다. 개발자 도구 콘솔에서 직접 확인해 봅니다. http://localhost:8765/echo 에서 콘솔을 열고 다음을 입력합니다.
document.cookie = 'admin=; Max-Age=0'; // Path 생략 → 이 페이지의 기본 경로
document.cookie = 'plain=; Max-Age=0; Path=/';
그 뒤 /admin/echo 를 열면 plain 은 사라졌지만 admin 은 남아 있습니다. 첫 줄은 Path=/admin 인 쿠키가 아니라 다른 경로의 (존재하지 않는) admin 쿠키를 지우려 했기 때문입니다. 6장에서 clearRefreshCookie 가 설정 때와 똑같은 옵션(refreshOptions)을 다시 쓰는 이유가 이것입니다.
Secure 와 SameSite 는 쿠키의 정체를 가르는 요소는 아니지만, 지우는 Set-Cookie 에서도 빼먹지 않는 편이 안전합니다. 예를 들어 SameSite=None 을 주면서 Secure 를 빠뜨린 삭제 헤더는 브라우저가 통째로 거부하므로 삭제 자체가 일어나지 않습니다. "설정할 때와 똑같이" 를 규칙으로 삼으면 이런 함정을 모두 피합니다.
💡 브라우저는 아주 긴 수명을 그대로 받아 주지 않습니다. RFC 6265bis 초안은 수명의 상한을 400일로 두고, Chrome 은 이를 따릅니다. 다른 브라우저의 상한은 각 브라우저 문서에서 확인하세요. 이 책의 쿠키는 가장 긴 것이 14일이라 영향이 없습니다.
Secure — HTTPS 에서만
Secure 가 붙은 쿠키는 HTTPS 요청에만 실립니다. 평문 HTTP 로 쿠키가 흘러 네트워크에서 엿보이는 것을 막습니다. 또 HTTP 페이지는 Secure 쿠키를 새로 심거나 덮어쓸 수 없습니다.
그런데 실험 서버는 http:// 인데도 secure=1 이 왔습니다. Chrome 은 http://localhost 를 "잠재적으로 신뢰할 수 있는 출처" 로 취급해 개발 편의를 위해 Secure 쿠키를 허용하기 때문입니다. 이 책이 로컬에서도 COOKIE_SECURE=true 로 개발할 수 있는 근거입니다. 다른 브라우저가 localhost 를 같은 방식으로 다루는지는 각 브라우저 문서에서 확인하세요. 이 책의 브라우저 실습을 Chrome 기준으로 설명하는 이유 가운데 하나입니다.
같은 이유로 로컬에서는 Secure 의 "막는" 효과를 직접 보기 어렵습니다. 운영 환경에서 HTTPS 가 아닌 주소로 접속하면 Secure 쿠키가 실리지 않는다는 것을 기억해 두면 충분합니다.
HttpOnly — JS 에서 숨긴다
HttpOnly 가 붙은 쿠키는 document.cookie 로 읽을 수 없습니다. 요청에는 여전히 실립니다. /echo 페이지 콘솔에서 확인합니다.
document.cookie
// 'plain=1; short=1; secure=1; strict=1; lax=1; none=1'
(예시 출력)
서버의 응답에는 hidden=1 이 있었지만 JS 가 보는 목록에는 없습니다. XSS, 곧 공격자의 스크립트가 페이지에서 실행되는 상황에서도 이 쿠키의 값을 훔쳐 밖으로 보낼 수 없습니다.
⚠️ HttpOnly 는 "값을 읽지 못하게" 할 뿐 "쿠키를 쓰지 못하게" 하지 않습니다. XSS 스크립트가 fetch('/auth/refresh', { method: 'POST', credentials: 'include' }) 를 부르면 브라우저는 HttpOnly 쿠키를 실어 보냅니다. 공격자는 값을 모른 채 그 쿠키의 권한을 빌려 쓸 수 있습니다. 그래서 HttpOnly 는 XSS 의 피해를 "토큰 영구 탈취" 에서 "공격 스크립트가 도는 동안의 오용" 으로 줄이는 장치이지 XSS 를 막는 장치가 아닙니다. 2장의 저장 위치 비교에서 이 차이를 다시 씁니다.
SameSite — 다른 사이트에서 시작된 요청에도 보낼까
SameSite 는 요청이 어느 사이트에서 시작되었는가에 따라 쿠키를 실을지 정합니다. "사이트" 의 정확한 뜻은 1.6절에서 정의하니, 지금은 "등록된 도메인이 같으면 같은 사이트" 정도로 읽어 두세요.
| 값 | 다른 사이트에서 시작된 요청에 |
|---|---|
Strict | 절대 싣지 않는다 |
Lax | 최상위(top-level) 이동이면서 안전한 메서드(GET 등)일 때만 싣는다. 링크 클릭은 실리고, 폼 POST·iframe·fetch·이미지는 안 실린다 |
None | 항상 싣는다. Secure 가 반드시 함께 있어야 하고, 없으면 브라우저가 쿠키를 거부한다 |
SameSite 를 생략하면 Chrome 은 Lax 로 취급합니다. 다른 브라우저의 기본값은 각 브라우저 문서에서 확인하세요.
실험으로 봅니다. 핵심은 같은 실험 서버를 127.0.0.1 로 여는 것입니다. localhost 와 127.0.0.1 은 같은 기계를 가리키지만 브라우저에게는 전혀 다른 사이트입니다(1.6절).
http://localhost:8765/set을 엽니다(쿠키는localhost에 심깁니다).- 2분 이상 기다린 뒤
http://127.0.0.1:8765/from을 엽니다. 화면에 "이 페이지의 출처:http://127.0.0.1:8765" 가 보입니다. - "링크로 이동 (GET)" 을 누릅니다.
GET /echo
Cookie: plain=1; hidden=1; secure=1; lax=1; none=1
(예시 출력. short 는 이미 만료되었습니다.)
strict 만 빠졌습니다. 다른 사이트에서 시작된 최상위 GET 이동이므로 Lax 와 None 은 실리고 Strict 는 실리지 않습니다. SameSite 를 적지 않은 plain·hidden·secure 는 Chrome 에서 Lax 로 취급되어 실렸습니다.
- 뒤로 가서 "폼 전송 (POST)" 을 누릅니다.
POST /echo
Cookie: none=1
(예시 출력)
이번에는 None 만 남았습니다. 다른 사이트에서 시작된 POST 에는 Lax 도 실리지 않습니다. 이것이 Lax 가 CSRF 를 상당 부분 막아 주는 이유이자, 8장에서 공격을 성공시키려고 실습용 쿠키를 SameSite=None 으로 바꾸는 이유입니다.
💡 2단계에서 2분을 기다리는 까닭은 Chrome 의 완화 규칙 때문입니다. Chrome 은 SameSite 를 적지 않은 쿠키가 심긴 지 2분이 안 되었으면 다른 사이트의 최상위 POST 에도 예외적으로 실어 보낸다고 알려져 있습니다. 기다리지 않고 해 보면 4단계에서 plain 등이 같이 올 수 있습니다. 이 규칙의 정확한 조건과 유지 여부는 Chrome 문서에서 확인하세요. SameSite 를 명시한 쿠키에는 적용되지 않으므로, 이 책처럼 속성을 항상 명시하면 신경 쓸 필요가 없습니다.
마지막으로 같은 사이트 안에서는 셋 다 실린다는 것도 확인합니다. http://localhost:8765/from 을 열고(이번에는 localhost) 폼을 전송하면 strict=1 까지 모두 옵니다. 페이지와 목적지가 같은 사이트이기 때문입니다.
⚠️ SameSite=Strict 에는 부작용이 하나 있습니다. 다른 사이트의 링크를 눌러 들어온 첫 화면 요청에는 쿠키가 실리지 않습니다. 쿠키 기반 서버 세션을 Strict 로 두면, 메일의 링크를 눌러 들어온 사용자가 로그인이 풀린 것처럼 보이는 이유입니다. 이 책은 이 문제를 겪지 않습니다. 리프레시 토큰은 화면 이동이 아니라 이미 뜬 FE 페이지(localhost:5173)가 부르는 fetch 에 실리고, 그 fetch 는 같은 사이트에서 시작된 요청이기 때문입니다.
브라우저가 쿠키를 고르는 규칙
속성을 모두 보았으니 브라우저가 요청 하나에 어떤 쿠키를 실을지 고르는 과정을 순서대로 정리합니다.
요청: POST http://localhost:4000/auth/refresh (시작한 페이지: http://localhost:5173)
저장된 쿠키마다:
1. 만료되었나? → 만료면 제외 (그리고 삭제)
2. 호스트가 맞나?
host-only 쿠키: 요청 호스트와 정확히 같아야 함
Domain 쿠키 : 요청 호스트가 그 도메인이거나 하위 도메인
※ 포트는 보지 않는다
3. Path 가 요청 경로의 접두사인가?
4. Secure 쿠키인데 안전하지 않은 요청인가? → 제외
5. 요청이 다른 사이트에서 시작되었나?
Strict → 제외
Lax → 최상위 이동 + 안전한 메서드가 아니면 제외
None → 통과
6. (브라우저 정책) 서드파티 쿠키 차단 등 → 제외될 수 있음
살아남은 쿠키를
경로가 긴 것 먼저, 같으면 먼저 만든 것 먼저 정렬해
Cookie: a=1; b=2 로 이어 붙인다
6번은 표준이 아니라 브라우저의 개인정보 보호 정책입니다. iframe 이나 fetch 처럼 다른 사이트 문맥에서 쓰이는 쿠키(서드파티 쿠키)를 사용자 설정이나 시크릿 모드에 따라 막습니다. 브라우저마다, 버전마다 다르므로 해당 브라우저 문서에서 확인하세요. 이 책의 본 설계는 서드파티 쿠키에 기대지 않도록 FE 와 API 를 같은 사이트에 둡니다. 그 전제가 바로 다음 절의 내용입니다.
그리고 한 가지 조건이 더 있습니다. 다른 출처로 가는 fetch 는 credentials: 'include' 를 줘야 쿠키를 싣고, 응답의 Set-Cookie 도 받아들입니다. 위의 1~6을 모두 통과해도 이 옵션이 없으면 쿠키가 실리지 않습니다. 이것은 쿠키 규칙이 아니라 fetch 의 규칙이고, 1.7절의 CORS 와 짝을 이룹니다.
1.6 출처와 사이트
이 책에서 가장 중요한 구분입니다. 두 낱말은 일상에서 비슷하게 쓰이지만 브라우저 보안에서는 뜻이 정확히 다릅니다.
출처(origin) 는 스킴 + 호스트 + 포트 세 가지입니다. 셋이 모두 같아야 같은 출처입니다.
https :// app.example.com : 443 / path?query
└─┬─┘ └──────┬──────┘ └┬┘
스킴 호스트 포트 ← 이 셋이 출처. 경로·쿼리는 보지 않는다
사이트(site) 는 스킴 + 등록 가능한 도메인(registrable domain) 입니다. 포트를 보지 않고, 호스트 전체가 아니라 "등록 가능한 도메인" 까지만 봅니다.
등록 가능한 도메인은 누군가 등록 기관에서 살 수 있는 가장 짧은 이름입니다. app.example.com 의 등록 가능한 도메인은 example.com 입니다. com 은 아무도 소유할 수 없는 공개 접미사이고, 그 바로 아래 한 칸을 붙인 example.com 이 등록 가능한 도메인입니다. shop.example.co.kr 이라면 co.kr 이 공개 접미사이므로 등록 가능한 도메인은 example.co.kr 입니다. 이렇게 "공개 접미사 + 한 칸" 으로 계산하므로 흔히 eTLD+1(effective top-level domain plus one)이라고 부릅니다.
공개 접미사 목록(PSL)
어떤 접미사가 "공개" 인지는 규칙으로 계산할 수 없습니다. com 은 공개 접미사지만 example.com 은 아니고, kr 도 co.kr 도 공개 접미사입니다. 그래서 브라우저들은 공개 접미사 목록(Public Suffix List, PSL) 이라는 목록을 함께 씁니다. Mozilla 가 시작해 지금은 커뮤니티가 관리하며 publicsuffix.org 에서 볼 수 있습니다.
PSL 에는 com, co.kr 같은 국가·일반 도메인 접미사뿐 아니라 여러 사용자에게 하위 도메인을 나눠 주는 서비스의 도메인도 들어 있습니다. github.io 가 대표적입니다. alice.github.io 와 bob.github.io 는 서로 다른 사람이 운영하는 페이지입니다. github.io 가 PSL 에 있으므로 두 주소의 등록 가능한 도메인은 각각 alice.github.io, bob.github.io 가 되고, 둘은 다른 사이트로 취급됩니다. 덕분에 alice.github.io 가 Domain=github.io 로 쿠키를 심어 bob.github.io 의 사용자에게 보내는 일이 막힙니다. 1.5절에서 공개 접미사를 Domain 으로 줄 수 없다고 한 규칙이 여기서 옵니다.
💡 IP 주소와 localhost 에는 등록 가능한 도메인이라는 개념이 맞지 않습니다. 이 경우 호스트 이름 전체가 사이트를 정합니다. localhost 는 localhost 끼리, 127.0.0.1 은 127.0.0.1 끼리만 같은 사이트입니다. 같은 기계를 가리켜도 이름이 다르면 다른 사이트입니다.
판정 연습
이 책에 나오는 주소들을 짝지어 판정합니다. 같은 출처면 반드시 같은 사이트지만, 반대는 성립하지 않습니다.
| A | B | 같은 출처? | 같은 사이트? | 이유 |
|---|---|---|---|---|
http://localhost:5173 | http://localhost:4000 | ❌ | ✅ | 포트만 다르다. 사이트는 포트를 보지 않는다 |
http://localhost:5173 | http://localhost:5174 | ❌ | ✅ | 위와 같다. 8장의 "같은 사이트 공격 페이지" |
http://localhost:5173 | http://127.0.0.1:5174 | ❌ | ❌ | 호스트 이름이 다르다. 8장의 "다른 사이트 공격 페이지" |
http://localhost:4000 | http://127.0.0.1:4000 | ❌ | ❌ | 같은 기계, 같은 포트여도 이름이 다르면 다르다 |
https://app.example.com | https://api.example.com | ❌ | ✅ | 등록 가능한 도메인이 둘 다 example.com |
https://blog.example.com | https://api.example.com | ❌ | ✅ | 위와 같다. 서브도메인 하나가 장악되면 같은 사이트 공격자가 된다 |
https://app.example.com | http://app.example.com | ❌ | ❌ | 스킴이 다르다. 오늘날의 사이트 판정은 스킴도 본다 |
https://example.com | https://example-api.io | ❌ | ❌ | 등록 가능한 도메인이 다르다 |
https://alice.github.io | https://bob.github.io | ❌ | ❌ | github.io 가 PSL 에 있다 |
https://a.example.co.kr | https://b.example.co.kr | ❌ | ✅ | co.kr 이 공개 접미사, 등록 가능한 도메인은 example.co.kr |
https://example.com | https://example.com:443 | ✅ | ✅ | https 의 기본 포트가 443 이다 |
💡 "스킴이 다르면 다른 사이트" 는 비교적 최근에 굳어진 규칙이라 schemeful same-site 라고 따로 부르기도 합니다. 예전 문서에는 스킴을 보지 않는 정의가 남아 있으니, 브라우저별 적용 여부는 해당 문서에서 확인하세요.
왜 둘을 나눠야 하나
출처와 사이트가 다른 결정을 맡기 때문입니다.
| 결정 | 기준 | 이 책에서 |
|---|---|---|
| 다른 페이지의 DOM·응답을 JS 가 읽을 수 있는가 | 출처 (same-origin policy, CORS) | FE(5173)가 API(4000)의 응답을 읽으려면 CORS 허용이 필요 |
SameSite 쿠키를 실을 것인가 | 사이트 | FE(5173)가 부르는 API(4000) 요청에 SameSite=Strict 쿠키가 실린다 |
| 쿠키를 어느 호스트에 보낼 것인가 | 호스트 (Domain, 포트 무시) | localhost:4000 이 심은 쿠키가 localhost:5173 요청에도 실릴 수 있다 |
이 표에서 이 책의 설계 전제가 나옵니다. FE 와 API 가 다른 출처이므로 CORS 설정이 필요하고, 같은 사이트이므로 SameSite=Strict 쿠키를 쓸 수 있습니다. 만약 FE 가 https://example.com, API 가 https://example-api.io 처럼 다른 사이트라면 리프레시 쿠키에 SameSite=None 을 줄 수밖에 없고, 서드파티 쿠키 차단에도 걸립니다. 그래서 이 책은 FE 와 API 를 같은 사이트에 두는 구성을 전제로 하고, 운영에서는 app.example.com 과 api.example.com 을 권합니다(14장).
반대로 이 표는 SameSite 의 한계도 보여 줍니다. SameSite 는 사이트를 보므로, 같은 사이트 안의 다른 출처는 막지 못합니다. 로컬에서는 localhost:5174 에 뜬 공격 페이지가, 운영에서는 장악된 blog.example.com 이 여기에 해당합니다. 9장이 SameSite 위에 Origin 검사와 CSRF 토큰을 한 겹씩 더 얹는 이유가 이것입니다.
⚠️ 개발 중에 FE 를 http://127.0.0.1:5173 으로 열고 API 는 http://localhost:4000 으로 부르면, 코드가 정상이어도 로그인이 유지되지 않습니다. 두 주소가 다른 사이트라 SameSite=Strict 인 리프레시 쿠키가 실리지 않기 때문입니다. 부록 C 의 web/vite.config.ts 가 host: 'localhost' 를 못 박아 두는 이유입니다.
1.7 same-origin policy 와 CORS
same-origin policy(동일 출처 정책) 는 브라우저 보안의 기본 규칙입니다. 한 출처에서 온 스크립트는 다른 출처의 자원을 읽을 수 없습니다. http://localhost:5174 의 스크립트가 http://localhost:4000/users/me 에 fetch 를 보내도, 브라우저는 그 응답을 스크립트에게 넘겨주지 않습니다.
여기서 반드시 짚어야 할 점은 막는 것은 읽기이지 보내기가 아니라는 것입니다. 웹은 원래 다른 출처로 요청을 보내는 것으로 이루어져 있습니다. <img src="다른 출처">, <form action="다른 출처">, <a href="다른 출처"> 는 모두 다른 출처로 요청을 보내고, 브라우저는 이를 막지 않습니다. 정책이 막는 것은 그 응답의 내용을 스크립트가 들여다보는 일입니다.
CORS(Cross-Origin Resource Sharing) 는 이 정책에 서버가 예외를 여는 장치입니다. 서버가 응답에 Access-Control-Allow-Origin: http://localhost:5173 을 붙이면, 브라우저는 "이 서버가 5173 의 스크립트에게 응답을 보여 줘도 된다고 했다" 고 보고 응답을 넘겨줍니다. 쿠키를 싣는 요청이라면 Access-Control-Allow-Credentials: true 도 필요하고, 이때 Access-Control-Allow-Origin 에 * 를 쓸 수 없습니다.
요청이 서버에 부수 효과를 낼 수 있는 모양이면, 예를 들어 PATCH·DELETE 메서드이거나 Authorization·X-CSRF-Token 같은 헤더를 붙이거나 Content-Type: application/json 이면, 브라우저는 본 요청을 보내기 전에 OPTIONS 로 사전 요청(preflight) 을 보내 서버의 허락을 먼저 받습니다. 서버가 허락하지 않으면 본 요청은 아예 나가지 않습니다. 반대로 폼이 보낼 수 있는 모양의 요청(GET·POST 에 application/x-www-form-urlencoded 같은 본문, 특별한 헤더 없음)은 simple request 라 하여 사전 요청 없이 곧장 서버에 도착합니다. 조건 전체는 부록 A의 표에 정리했습니다.
이 성질들을 모으면 이 책의 방어가 무엇에 기대는지 보입니다.
- simple request 는 서버에 도착해 부수 효과를 낸 뒤 응답만 읽히지 않습니다. 쿠키가 자동으로 실린다면, 공격 페이지는 응답을 못 읽어도 피해자의 권한으로 무언가를 바꿀 수 있습니다. 이것이 CSRF 입니다.
- 커스텀 헤더(
X-CSRF-Token)를 붙이려면 사전 요청을 거쳐야 하고, CORS 허용 목록이 공격 페이지의 출처를 거절하면 본 요청이 나가지 않습니다. 9장의 CSRF 토큰이 이 성질을 씁니다. Authorization헤더로만 인증하는 요청은 공격 페이지가 그 헤더를 만들 수 없으므로 CSRF 대상이 아닙니다.
CSRF 의 공격과 방어는 8장과 9장에서 직접 공격 페이지를 만들어 가며 자세히 다룹니다. 이 장에서는 "CORS 는 읽기를 여는 장치이지 요청을 막는 장치가 아니다" 라는 한 문장만 가져가면 됩니다.
1.8 개발자 도구로 쿠키 보기
쿠키 문제는 대부분 "쿠키가 저장은 되었는가" 와 "요청에 실렸는가" 둘 중 하나에서 생깁니다. Chrome 개발자 도구로 둘을 따로 확인하는 방법을 익혀 둡니다. 메뉴 이름과 열 이름은 Chrome 버전에 따라 조금씩 다를 수 있습니다.
저장된 쿠키 보기 — Application 패널
F12(macOS 는⌥⌘I)로 개발자 도구를 엽니다.- Application 패널 → 왼쪽 Storage 아래 Cookies → 페이지 출처(
http://localhost:8765)를 고릅니다. - 표에서
Name,Value,Domain,Path,Expires / Max-Age,HttpOnly,Secure,SameSite열을 봅니다.
실험 서버라면 여덟 개(만료된 short 는 빠질 수 있음)가 보입니다. document.cookie 에서는 안 보이던 hidden 도 이 표에는 HttpOnly 체크와 함께 나옵니다. 개발자 도구는 JS 가 아니라 브라우저 자신의 화면이기 때문입니다. 이 표에서 행을 골라 Delete 키로 지울 수도 있어 실험을 되돌릴 때 편합니다.
⚠️ 이 표의 왼쪽 목록은 지금 보고 있는 페이지의 출처 기준입니다. FE(localhost:5173)를 보면서 API(localhost:4000)가 심은 쿠키를 찾을 때 헷갈리기 쉽습니다. 쿠키는 포트를 가리지 않으므로 localhost 의 쿠키가 함께 보이는 경우가 많지만, 어느 출처 항목 아래에 보이는지는 브라우저 구현에 달려 있습니다. 확실히 하려면 아래의 Network 패널로 확인합니다.
요청에 실렸는지 보기 — Network 패널
- Network 패널을 열고 페이지를 새로고침하거나 요청을 일으킵니다.
- 요청 하나를 누르고 Headers 탭에서 요청의
Cookie헤더와 응답의Set-Cookie헤더를 봅니다. - Cookies 탭에서 이 요청에 실린 쿠키(Request Cookies)와 응답이 심은 쿠키(Response Cookies)를 표로 봅니다.
Cookies 탭에는 걸러진 요청 쿠키도 보여 주는 체크박스가 있습니다. 켜면 저장은 되어 있지만 이 요청에 실리지 않은 쿠키가 노란 표시와 함께 나오고, 마우스를 올리면 SameSite 때문인지, Path 때문인지, Secure 때문인지 이유를 알려 줍니다. 응답의 Set-Cookie 가 거부되었을 때도 비슷하게 경고 아이콘과 이유가 붙습니다. "쿠키가 왜 안 가지?" 의 답은 대부분 여기서 바로 나옵니다.
1.5절의 SameSite 실험을 이 패널을 켠 채로 다시 해 보세요. 4단계의 POST /echo 요청을 고르고 걸러진 쿠키를 보이게 하면, strict·lax·plain 등이 SameSite 이유와 함께 표시됩니다.
확인하기
실험 서버(1.5절)를 띄운 상태에서 Chrome 으로 확인합니다. 아래 기대 결과가 모두 나오면 다음 장으로 넘어갑니다.
| # | 하는 일 | 기대 결과 |
|---|---|---|
| 1 | http://localhost:8765/set 을 연 뒤 /echo | Cookie: 에 admin 을 뺀 쿠키가 모두 보인다 |
| 2 | /admin/echo | admin=1 이 맨 앞에 온다 |
| 3 | /echo 콘솔에서 document.cookie | hidden 이 없다 |
| 4 | /set 뒤 20초 지나 /echo | short 가 없다 |
| 5 | 2분 뒤 http://127.0.0.1:8765/from → 링크 | strict 만 없다 |
| 6 | 같은 페이지 → 폼 전송 | none=1 만 있다 |
| 7 | http://localhost:8765/from → 폼 전송 | strict=1 을 포함해 모두 있다 |
| 8 | 6번 요청을 Network → Cookies 에서 걸러진 쿠키까지 보기 | 빠진 쿠키 옆에 SameSite 관련 이유가 보인다 |
터미널에서는 응답 헤더를 직접 볼 수 있습니다. curl 은 SameSite 를 적용하지 않지만 Set-Cookie 가 어떻게 나가는지 보는 데는 충분합니다.
$ curl -si http://localhost:8765/set | grep -i '^set-cookie'
Set-Cookie: plain=1; Path=/
Set-Cookie: hidden=1; Path=/; HttpOnly
Set-Cookie: admin=1; Path=/admin
Set-Cookie: short=1; Path=/; Max-Age=20
Set-Cookie: secure=1; Path=/; Secure
Set-Cookie: strict=1; Path=/; SameSite=Strict
Set-Cookie: lax=1; Path=/; SameSite=Lax
Set-Cookie: none=1; Path=/; SameSite=None; Secure
(예시 출력)
쿠키 여덟 개가 Set-Cookie 여덟 줄로 나가는 것을 확인하세요. 실험이 끝나면 Ctrl+C 로 서버를 끄고, Application 패널에서 localhost 와 127.0.0.1 의 쿠키를 지워 둡니다.
자주 하는 실수
localhost 와 127.0.0.1 을 섞어 쓴다. 증상은 "로그인은 되는데 새로고침하면 풀린다", "refresh 가 늘 401" 입니다. 두 이름은 다른 사이트라 SameSite=Strict 쿠키가 오가지 않습니다. FE 주소창, VITE_API_URL, WEB_ORIGINS 가 모두 같은 호스트 이름을 쓰는지 확인하세요.
Domain 을 적으면 범위가 좁아진다고 생각한다. 반대입니다. 생략이 가장 좁습니다(호스트 전용). Domain=example.com 은 모든 서브도메인에 쿠키를 뿌립니다.
쿠키를 지웠는데 남아 있다. 삭제하는 Set-Cookie 의 Path 나 Domain 이 설정할 때와 다르면 브라우저는 다른 쿠키로 보고 원래 쿠키를 그대로 둡니다. 삭제 헤더에는 설정 때의 속성을 그대로 다시 줍니다.
SameSite=None 만 주고 Secure 를 빠뜨린다. 브라우저가 쿠키를 거부합니다. 응답에 Set-Cookie 가 분명히 있는데 Application 패널에 쿠키가 없다면 Network 패널의 경고 아이콘부터 보세요.
CORS 에러가 났으니 요청이 서버에 안 갔다고 생각한다. simple request 는 서버에 도착해 처리된 뒤 응답만 가려집니다. 콘솔의 CORS 에러와 서버 로그를 같이 보세요. 이 착각이 CSRF 를 과소평가하게 만듭니다.
credentials: 'include' 를 빠뜨린다. 다른 출처로 가는 fetch 는 이 옵션 없이 쿠키를 싣지도, Set-Cookie 를 받아들이지도 않습니다. 쿠키 속성을 아무리 고쳐도 해결되지 않습니다.
HttpOnly 면 XSS 에 안전하다고 생각한다. 값을 훔치지 못할 뿐, 공격 스크립트는 그 쿠키를 실은 요청을 보낼 수 있습니다.
쿠키가 포트별로 나뉜다고 생각한다. localhost:4000 이 심은 쿠키는 localhost:5173 과 localhost:5174 로 가는 요청에도 경로만 맞으면 실립니다.
정리
- 인증은 누구인지 확인하는 일, 인가는 무엇을 해도 되는지 정하는 일입니다.
401은 인증 실패,403은 인가 실패입니다. - HTTP 는 요청 사이를 기억하지 않습니다. 서버가 장부로 기억하면 세션 방식, 클라이언트가 서명된 증명서를 들고 다니면 토큰 방식입니다. 세션은 즉시 폐기가 쉽고, 토큰은 저장소 없이 검증할 수 있습니다.
- 무엇을 기억하느냐(세션·토큰)와 어떻게 보내느냐(쿠키·헤더)는 다른 축입니다. 쿠키는 자동 전송되어 CSRF 를, 헤더는 JS 가 보관해야 하므로 XSS 를 신경 써야 합니다.
- 쿠키 속성은 "어느 요청에 실을까"(
Domain·Path·Secure·SameSite)와 "누가 읽을까"(HttpOnly), "언제까지"(Expires·Max-Age)를 정합니다. 쿠키의 정체는 이름 +Domain+Path이고, 포트는 보지 않습니다. - 출처 = 스킴 + 호스트 + 포트, 사이트 = 스킴 + 등록 가능한 도메인. 읽기 권한(CORS)은 출처로,
SameSite는 사이트로 판정합니다. 등록 가능한 도메인은 공개 접미사 목록(PSL)으로 계산합니다. - same-origin policy 는 읽기를 막을 뿐 보내기를 막지 않습니다. CORS 는 서버가 읽기를 허락하는 장치입니다.
- 이 책은 FE 와 API 를 다른 출처·같은 사이트에 두고, 매 요청은 헤더의 JWT 로, 10분에 한 번은 쿠키의 리프레시 토큰으로 인증합니다.
다음 장에서는 그 두 토큰을 설계합니다. JWT 를 손으로 서명해 보고, 액세스·리프레시를 나누는 이유와 회전·재사용 탐지의 원리를 정리한 뒤, 이 책 전체의 설계 표를 완성합니다.
연습문제
1. 다음 네 쌍 각각이 같은 출처인지, 같은 사이트인지 판정하세요.
(a) https://shop.example.com 과 https://shop.example.com:8443
(b) https://www.example.com 과 https://example.com
(c) http://localhost:5173 과 https://localhost:5173
(d) https://kim.github.io 와 https://lee.github.io
해답
(a) 다른 출처(포트 443 과 8443), 같은 사이트(포트를 보지 않고 등록 가능한 도메인이 example.com 으로 같음).
(b) 다른 출처(호스트가 다름), 같은 사이트(둘 다 example.com).
(c) 다른 출처(호스트와 포트는 같지만 스킴이 다름), 다른 사이트(스킴을 보는 schemeful same-site 기준).
(d) 다른 출처, 다른 사이트. github.io 가 공개 접미사 목록에 있어서 등록 가능한 도메인이 kim.github.io, lee.github.io 로 서로 다릅니다.
2. API 가 https://api.example.com 에서 다음 헤더로 쿠키를 심었습니다. 이 쿠키가 실리는 요청과 실리지 않는 요청을 각각 고르고 이유를 적으세요.
Set-Cookie: t=abc; Path=/auth; HttpOnly; Secure; SameSite=Strict
(a) https://app.example.com 의 페이지가 fetch('https://api.example.com/auth/refresh', { method: 'POST', credentials: 'include' })
(b) 같은 페이지가 fetch('https://api.example.com/users/me', { credentials: 'include' })
(c) https://evil.test 의 폼이 https://api.example.com/auth/refresh 로 POST
(d) https://app.example.com 의 스크립트가 document.cookie 를 읽음
(e) https://blog.example.com 의 페이지가 fetch('https://api.example.com/auth/refresh', { method: 'POST', credentials: 'include' })
해답
(a) 실립니다. 호스트(api.example.com), 경로(/auth 접두사), HTTPS, 같은 사이트(example.com) 조건이 모두 맞고 credentials: 'include' 도 있습니다.
(b) 실리지 않습니다. /users/me 는 Path=/auth 에 맞지 않습니다.
(c) 실리지 않습니다. evil.test 는 다른 사이트이고 쿠키가 SameSite=Strict 입니다.
(d) 읽을 수 없습니다. HttpOnly 이고, 애초에 Domain 이 없는 호스트 전용 쿠키라 app.example.com 의 쿠키 목록에 있지도 않습니다.
(e) 실립니다. blog.example.com 은 api.example.com 과 같은 사이트이므로 SameSite=Strict 가 막지 않습니다. 응답을 읽을 수 있는지는 CORS 설정에 달렸지만, 요청 자체와 쿠키 전송은 일어납니다. SameSite 만으로는 부족하고 Origin 검사와 CSRF 토큰이 더 필요한 이유입니다(9장).
3. 동료가 "쿠키를 지우는 API 를 만들었는데 쿠키가 안 지워진다" 고 합니다. 설정할 때는 Set-Cookie: refresh_token=…; Path=/auth; HttpOnly; Secure; SameSite=Strict 를 보냈고, 지울 때는 Set-Cookie: refresh_token=; Max-Age=0 을 보냈습니다. 원인과 고칠 방법을 설명하세요.
해답
삭제 헤더에 Path 가 없습니다. Path 를 생략하면 요청 경로에서 계산한 기본 경로가 쓰이므로, 브라우저는 Path=/auth 인 원래 쿠키와 다른 쿠키(이름은 같지만 경로가 다른)를 지우려 한 것으로 봅니다. 쿠키의 정체는 이름 + Domain + Path 입니다. 삭제할 때도 Path=/auth; HttpOnly; Secure; SameSite=Strict 를 설정 때와 똑같이 주어야 합니다. 이 책은 설정과 삭제가 같은 옵션 객체(refreshOptions)를 쓰게 해서 이 실수를 막습니다(6장).
4. "우리 API 는 CORS 에서 http://localhost:5173 만 허용하니까, http://127.0.0.1:5174 의 공격 페이지가 보낸 폼 POST 는 서버에 도달하지 못한다." 이 주장이 틀린 이유를 same-origin policy 와 simple request 로 설명하세요.
해답
same-origin policy 와 CORS 는 다른 출처의 응답을 스크립트가 읽는 것을 막을 뿐, 요청을 보내는 것은 막지 않습니다. <form method="POST"> 가 application/x-www-form-urlencoded 본문으로 보내는 요청은 simple request 라서 사전 요청(preflight) 없이 곧장 서버에 도착하고, 서버는 그것을 처리합니다. 응답은 공격 페이지가 읽지 못하지만, 부수 효과(예: 닉네임 변경)는 이미 일어났습니다. 쿠키가 함께 실린다면 이것이 CSRF 입니다. CORS 허용 목록이 실제로 막는 것은 커스텀 헤더나 PATCH 처럼 사전 요청을 부르는 요청입니다.