
로그인 기능은 거의 모든 서비스에 들어가지만, 막상 처음부터 직접 만들어 본 개발자는 드뭅니다. 보통은 프레임워크나 인증 서비스가 대신 해 주고, 우리는 "로그인하면 토큰이 온다" 정도만 압니다. 그러다 어느 날 이런 질문을 받습니다.
- 액세스 토큰은 어디에 저장해야 하나요?
localStorage는 왜 안 되죠? - 리프레시 토큰을 탈취당하면 어떻게 되나요?
- 탭을 두 개 열어 두면 가끔 로그아웃되는 버그는 왜 생기나요?
- 쿠키를 쓰면 CSRF 를 막아야 한다는데,
SameSite만 켜면 되는 거 아닌가요?
이 책은 이 질문들에 직접 만들어 보고 직접 공격해 보는 방식으로 답합니다. 실제로 토큰을 발급하고, 회전시키고, 탈취된 토큰을 탐지하고, 공격 페이지를 만들어 CSRF 를 성공시킨 다음 방어를 한 겹씩 입혀 공격이 실패하는 것을 눈으로 확인합니다.
무엇을 만드는가
auth-lab — 프론트엔드와 백엔드가 분리된 로그인 앱입니다.
| 부분 | 기술 | 주소 |
|---|---|---|
| 백엔드 API | NestJS 12, PostgreSQL 17, pg | http://localhost:4000 |
| API 문서 | Swagger UI (@nestjs/swagger) | http://localhost:4000/docs |
| 프론트엔드 | React 19 + Vite 8, openapi-fetch | http://localhost:5173 |
| 공격 실습 페이지 | 정적 HTML | http://localhost:5174, http://127.0.0.1:5174 |
완성된 앱은 다음을 합니다.
- 가입·로그인. 비밀번호는 argon2id 로 해싱합니다.
- 로그인하면 액세스 토큰(JWT, 10분) 을 응답 본문으로, 리프레시 토큰(14일) 을
HttpOnly쿠키로 줍니다. - 액세스 토큰이 만료되면 FE 가 알아서 리프레시하고, 그때마다 리프레시 토큰을 새것으로 바꿉니다(회전).
- 이미 쓴 리프레시 토큰이 다시 오면 탈취로 보고 그 세션 전체를 끊습니다(재사용 탐지).
- 로그인된 기기 목록을 보고 하나씩 끊거나 전부 끊을 수 있습니다.
- 쿠키를 쓰는 요청은 SameSite · Origin 검사 · CSRF 토큰 세 겹으로 지킵니다.
- 프론트엔드 개발자는 Swagger 문서만 보고 작업합니다. API 타입은 문서에서 자동으로 만들어집니다.
누구를 위한 책인가
- JavaScript/TypeScript 로 웹 앱을 만들어 본 개발자
- HTTP 요청과 응답, 상태 코드, 헤더가 무엇인지 아는 개발자
- NestJS 와 React 는 몰라도 됩니다. 필요한 만큼 그때그때 설명합니다.
쿠키와 CORS 는 몰라도 됩니다. 1장에서 처음부터 다룹니다.
이 책이 다루지 않는 것
- 소셜 로그인(OAuth 2.0 / OpenID Connect). 이 책의 토큰 설계를 이해하면 OAuth 의 토큰 흐름이 훨씬 쉽게 읽힙니다.
- 다중 인증(MFA), 비밀번호 재설정 메일, 이메일 인증
- 모바일 앱 클라이언트. 쿠키가 아닌 저장소를 쓰므로 설계가 달라집니다.
차례
1부. 원리
| 장 | 제목 | 이 장을 마치면 |
|---|---|---|
| 1 | 인증은 무엇을 기억하는 일인가 | 세션과 토큰 방식의 차이, 쿠키의 모든 속성, 출처와 사이트를 구분해 설명할 수 있다 |
| 2 | 토큰을 설계하다 | JWT 를 손으로 해부하고, 액세스·리프레시를 나누는 이유와 이 책의 설계 표를 이해한다 |
2부. 백엔드 — 토큰을 발급한다
| 장 | 제목 | 이 장을 마치면 |
|---|---|---|
| 3 | 실습 환경을 세우다 | DB·NestJS·Swagger 가 뜨고, 환경변수를 검증하는 설정 계층이 생긴다 |
| 4 | 가입 — 비밀번호를 저장하는 법 | argon2id 로 해싱하고, 에러 응답 형식과 Swagger 문서화 규칙을 정한다 |
| 5 | 로그인과 액세스 토큰 | JWT 를 발급·검증하고, 가드로 API 를 보호하고, 로그인 시도를 제한한다 |
| 6 | 리프레시 토큰을 발급하다 | 불투명 토큰을 해시로 저장하고, 쿠키 속성을 하나하나 정해 내려보낸다 |
| 7 | 회전과 재사용 탐지 | 트랜잭션으로 토큰을 회전하고, 탈취를 탐지하고, 세션을 관리한다 |
3부. CSRF — 공격하고 막는다
| 장 | 제목 | 이 장을 마치면 |
|---|---|---|
| 8 | CSRF 공격을 직접 해 보기 | 공격 페이지를 만들어 다른 사람의 닉네임을 바꾸는 데 성공한다 |
| 9 | 방어를 한 겹씩 | SameSite·Origin 검사·서명된 CSRF 토큰을 하나씩 켜며 공격이 실패하는 것을 확인한다 |
4부. 프론트엔드 — Swagger 만 보고 붙인다
| 장 | 제목 | 이 장을 마치면 |
|---|---|---|
| 10 | Swagger 로 협업하기 | 계약 파일을 내보내고, 그 파일에서 FE 타입과 클라이언트를 만든다 |
| 11 | 로그인 화면과 세션 상태 | 액세스 토큰을 메모리에 두고, 새로고침 뒤에도 로그인을 되살린다 |
| 12 | 끊기지 않는 세션 | 401 자동 재시도, single-flight, 탭 간 잠금, 로그아웃 동기화를 만든다 |
5부. 마무리
| 장 | 제목 | 이 장을 마치면 |
|---|---|---|
| 13 | 시나리오로 증명하기 | 22개 시나리오를 e2e 테스트와 curl 스크립트로 돌려 설계를 증명한다 |
| 14 | 운영으로 가져가기 | 도메인 구성, 비밀 관리, 키 교체, 모니터링 체크리스트를 채운다 |
부록
한 장의 그림으로 보는 전체 흐름
브라우저 (localhost:5173) API (localhost:4000) PostgreSQL
───────────────────────── ───────────────────── ──────────
① GET /auth/csrf ─────────────────────────────▶ CSRF 토큰 생성
◀──── { csrfToken } + Set-Cookie: csrf_token
(csrfToken 은 메모리에)
② POST /auth/login ───────────────────────────▶ CsrfGuard: Origin 확인, 헤더=쿠키 확인
X-CSRF-Token: … 비밀번호 argon2 검증
{ email, password } 리프레시 토큰 생성 ─────────────────▶ INSERT (해시만)
◀──── { accessToken, user } 액세스 토큰(JWT) 서명
+ Set-Cookie: refresh_token (HttpOnly)
(accessToken 은 메모리에)
③ GET /users/me ──────────────────────────────▶ JwtAuthGuard: 서명·만료·iss·aud 확인
Authorization: Bearer <accessToken>
◀──── { id, email, nickname }
… 10분 뒤 액세스 토큰 만료 …
④ GET /users/me ─────────────────────────────▶ 401 ACCESS_TOKEN_INVALID
⑤ POST /auth/refresh ────────────────────────▶ CsrfGuard
Cookie: refresh_token (브라우저가 자동으로) family 잠금, SELECT … FOR UPDATE ──▶ 옛 토큰 잠금
새 토큰 INSERT, 옛 토큰 rotated ───▶ 회전
◀──── { accessToken } + Set-Cookie: 새 refresh_token
⑥ GET /users/me (재시도) ────────────────────▶ 200
이 그림의 번호 하나하나가 책의 한 장씩입니다. ①은 9장, ②는 5·6장, ③은 5장, ④~⑥은 7장과 12장에서 만듭니다.
실습 환경
준비물
| 도구 | 버전 | 확인 명령 |
|---|---|---|
| Node.js | 22.22.3 이상 또는 24.15 이상 (Nest CLI 요구사항. 앱 실행만은 20.19+/22.12+ 로도 된다) | node -v |
| pnpm | 11 이상 (이 책은 11.20) | pnpm -v |
| Docker Desktop | 최신 | docker compose version |
| Chrome | 최신 | 8·9장 실습은 Chrome 기준으로 설명합니다 |
| curl, jq | 아무 버전 | curl --version, jq --version |
| Python 3 | 아무 버전 | 공격 페이지를 띄우는 데만 씁니다 |
이 책이 쓴 패키지 버전
2026년 9월에 npm 레지스트리에서 확인한 최신 버전입니다. 버전이 올라가면 API 가 바뀔 수 있으니, 책과 다르게 동작하면 먼저 버전을 확인하고 해당 패키지의 공식 문서를 보세요.
| 패키지 | 버전 | 쓰는 곳 |
|---|---|---|
@nestjs/core · @nestjs/common · @nestjs/platform-express | 12.1 | API |
@nestjs/swagger | 12.0 | API 문서 |
@nestjs/jwt | 12.0 | JWT 서명·검증 |
@nestjs/config | 12.0 | .env 읽기 |
@nestjs/throttler | 6.7 | 로그인 시도 제한 |
pg | 8.23 | PostgreSQL 드라이버 |
argon2 | 0.45 | 비밀번호 해싱 |
cookie-parser | 1.4 | 요청 쿠키 파싱 |
helmet | 8.3 | 보안 헤더 |
class-validator · class-transformer | 0.15 · 0.5 | DTO 검증 |
typescript | 6.x | NestJS 12 는 TypeScript 6 을 요구합니다. 스캐폴드가 설치하는 버전을 그대로 씁니다 |
react · react-dom | 19.3 | FE |
vite | 8.3 | FE 개발 서버 |
react-router | 8.4 | FE 라우팅 (선언형 BrowserRouter·Routes 사용) |
openapi-typescript | 7.13 | Swagger → TS 타입 |
openapi-fetch | 0.17 | 타입 안전 API 클라이언트 |
vitest | 4.1 | e2e 테스트 러너 (Nest 스캐폴드 기본값) |
ORM 을 쓰지 않는 이유. 이 책은 SQL 을 pg 로 직접 씁니다. 리프레시 토큰 회전의 핵심은
트랜잭션과 행 잠금(SELECT … FOR UPDATE)인데, ORM 을 쓰면 이 부분이 추상화 뒤로 숨어 버립니다.
인증 로직의 모든 줄이 눈에 보이는 것이 이 책의 목표라 SQL 을 그대로 드러냈습니다.
실무에서 ORM 을 쓴다면 7장의 트랜잭션만 그 ORM 의 방식으로 옮기면 됩니다.
폴더 구조
~/Workspace/auth-lab/
├── curriculum/ ← 지금 읽고 있는 책
├── docker-compose.yml
├── contract/ ← BE 가 내보낸 openapi.json (10장)
├── api/ ← NestJS
├── web/ ← React
└── evil/ ← 공격 페이지 (8장)
각 장의 구성
모든 장은 같은 순서를 따릅니다.
- 이 장을 마치면 — 장이 끝났을 때 할 수 있어야 하는 일
- 들어가며 — 왜 이 장이 필요한가, 앞 장과 어떻게 이어지는가
- 본문 — 이론 → 코드 → 실행 → 결과 확인을 한 묶음으로 반복합니다
- 확인하기 — 명령과 기대 결과. 여기가 통과해야 다음 장으로 넘어갑니다
- 자주 하는 실수 — 실제로 막히는 곳과 증상
- 정리
- 연습문제 — 해답은 문제 바로 아래 접힌 칸에 있습니다. 먼저 풀고 여세요
표기
$로 시작하는 줄은 터미널 명령, 그 아래는 출력입니다.- 코드 블록 위의
// api/src/auth/auth.service.ts같은 주석은 파일 경로입니다. - 이미 있는 파일을 고칠 때는 바뀌는 부분만 보여 주고
// …로 나머지를 줄입니다. 전체 모습이 필요하면 부록 C 를 보세요. - 💡 는 알아 두면 좋은 배경, ⚠️ 는 틀리기 쉬운 곳입니다.
먼저 알아 둘 용어
이 책에서 반복해서 쓰는 말입니다. 1장과 2장에서 자세히 다룹니다.
| 용어 | 뜻 |
|---|---|
| 출처(origin) | 스킴 + 호스트 + 포트. http://localhost:5173 과 http://localhost:4000 은 다른 출처 |
| 사이트(site) | 스킴 + 등록 가능한 도메인. 포트를 보지 않는다. 위 둘은 같은 사이트 |
| 액세스 토큰 | API 를 부를 때 Authorization 헤더에 싣는 짧은 수명의 JWT |
| 리프레시 토큰 | 새 액세스 토큰을 받을 때만 쓰는 긴 수명의 난수. 쿠키로만 오간다 |
| family | 로그인 한 번에서 시작해 회전으로 이어진 리프레시 토큰들의 묶음. 이 책에서는 곧 세션 |
| 회전(rotation) | 리프레시할 때마다 리프레시 토큰을 새것으로 바꾸고 옛것을 폐기하는 것 |
| 재사용 탐지 | 폐기된 리프레시 토큰이 다시 오면 탈취로 보고 family 전체를 끊는 것 |
| grace | 동시 요청 때문에 옛 토큰이 늦게 도착하는 것을 봐주는 짧은 시간(10초) |
| single-flight | 동시에 여러 곳에서 refresh 를 요청해도 실제 요청은 하나만 보내고 결과를 나눠 갖는 것 |