
이 부록은 책을 따라가다 막혔을 때 증상에서 출발해 원인을 찾도록 정리한 것입니다. 각 항목은 같은 순서로 적었습니다.
- 증상 — 화면이나 터미널에서 보이는 것
- 원인 — 그 증상을 만드는 흔한 이유. 여러 개면 자주 겪는 것부터
- 확인 — 원인을 가려내는 명령이나 화면
- 해결 — 고치는 방법과, 관련된 장
먼저 아래 표에서 증상을 찾고 해당 절로 가세요. 표에 없는 증상이라도 B.3(쿠키)과 B.4(CORS)를 먼저 읽어 보면 대개 실마리가 나옵니다. 인증 관련 문제의 대부분은 "쿠키가 저장되지 않았다", "쿠키가 요청에 실리지 않았다", "브라우저가 응답을 막았다" 셋 중 하나이기 때문입니다.
증상으로 찾기
| 증상 | 절 |
|---|---|
ECONNREFUSED 127.0.0.1:55432, DB 에 연결되지 않음 | B.1.1 |
pnpm install 중 argon2 에서 에러 | B.1.2 |
| Nest 가 뜨지 않고 Node 버전 관련 에러 | B.1.3 |
cookieParser is not a function, helmet is not a function | B.1.4 |
req.cookies 가 undefined, refresh 가 늘 401 | B.2.1 |
응답에 Set-Cookie 가 있는데 브라우저에 쿠키가 없다 | B.3.1 |
| 쿠키는 저장돼 있는데 요청에 실리지 않는다 | B.3.2 |
| 로그아웃했는데 쿠키가 남아 있다 | B.3.3 |
콘솔에 … has been blocked by CORS policy | B.4 |
POST /auth/refresh 가 계속 401 | B.5.1 |
409 REFRESH_TOKEN_ROTATED 가 반복된다 | B.5.2 |
개발 중에만 REFRESH_TOKEN_REUSED 로 로그아웃된다 | B.5.3 |
| 액세스 토큰이 발급 직후부터 만료됐다고 나온다 | B.5.4 |
403 CSRF_ORIGIN_REJECTED (특히 Swagger 에서) | B.6.1 |
403 CSRF_TOKEN_INVALID | B.6.2 |
| 8·9장 공격 실험 결과가 책과 다르다 | B.6.3 |
Swagger UI(/docs)가 빈 화면이거나 일부만 보인다 | B.7.1 |
import.meta.env.VITE_API_URL 이 undefined | B.8.1 |
| FE 타입이 API 변경을 반영하지 않는다 | B.8.2 |
429 TOO_MANY_REQUESTS 로 로그인이 안 된다 | B.8.3 |
B.1 설치와 실행
B.1.1 DB 연결이 거부된다
증상. API 를 띄우면 첫 요청에서 Error: connect ECONNREFUSED 127.0.0.1:55432(또는 ::1:55432)가 나옵니다.
pnpm db:migrate 가 service "db" is not running 으로 실패하기도 합니다.
원인.
- Docker Desktop 이 꺼져 있거나,
docker compose up을 하지 않았다. - 포트를 헷갈렸다. 이 책의 DB 는 컨테이너 안에서 5432 로 돌고, 바깥에는 55432 로 열려 있다. 기본 포트 5432 를 피한 것은 이미 설치된 로컬 PostgreSQL 과 부딪히지 않게 하려는 것이다.
DATABASE_URL의 사용자·비밀번호가docker-compose.yml과 다르다. 특히 볼륨(pgdata)이 먼저 만들어진 뒤POSTGRES_PASSWORD를 바꾸면 바뀐 값이 적용되지 않는다. PostgreSQL 이미지는 빈 데이터 디렉터리일 때만 초기 사용자를 만든다.
확인.
$ docker compose ps
$ docker compose logs db | tail -20
$ lsof -iTCP:55432 -sTCP:LISTEN
$ grep DATABASE_URL api/.env
docker compose ps 에 db 가 running 이고 포트에 0.0.0.0:55432->5432/tcp 가 보여야 합니다.
해결. 저장소 루트에서 docker compose up -d 로 띄웁니다. 비밀번호를 바꿨다면 볼륨을 지우고 다시 만들어야 합니다.
볼륨 삭제는 DB 의 모든 데이터가 사라지는 작업이니, 실습 데이터라는 것을 확인한 뒤에 docker compose down -v → docker compose up -d → pnpm db:migrate 순서로 합니다(3장).
B.1.2 argon2 설치가 실패한다
증상. pnpm add argon2 나 pnpm install 중에 node-gyp, gyp ERR!, prebuild, make 같은 단어가 들어간 에러가 나거나,
설치는 됐는데 실행할 때 Cannot find module '…/argon2.node' 류의 에러가 납니다.
원인. argon2 는 C 로 된 네이티브 모듈입니다. 보통은 운영체제·CPU·Node 버전에 맞는 미리 빌드된 바이너리가 패키지에 들어 있어 그대로 쓰지만,
맞는 바이너리가 없으면 설치 중에 직접 컴파일을 시도하고, 이때 C/C++ 빌드 도구와 Python 이 필요합니다.
아주 새로운 Node 버전, 흔하지 않은 CPU 아키텍처, Alpine 같은 musl 기반 리눅스에서 이런 일이 생기기 쉽습니다.
또 pnpm 은 버전에 따라 의존성의 설치 스크립트를 기본으로 실행하지 않고 승인을 요구하기도 하므로, 빌드 단계가 조용히 건너뛰어졌을 수도 있습니다.
(정확한 동작은 argon2·pnpm 버전마다 다를 수 있습니다. 설치 로그의 경고를 먼저 읽으세요.)
확인.
$ node -v && node -p "process.platform + ' ' + process.arch"
$ pnpm why argon2
$ node -e "require('argon2').hash('x').then(h => console.log(h.slice(0, 30)))"
마지막 명령이 $argon2id$v=19$m=… 로 시작하는 문자열을 찍으면 정상입니다(api/ 안에서 실행).
해결.
- 설치 로그에 pnpm 이 빌드 스크립트를 건너뛰었다는 경고가 있으면, 안내된 명령(예:
pnpm approve-builds)으로argon2의 빌드를 허용하고 다시 설치합니다. - 컴파일이 필요한데 도구가 없다면 macOS 는
xcode-select --install, Debian/Ubuntu 는build-essential과python3을 설치합니다. 설치 도구를 들이는 작업이니 팀의 개발 환경 규칙을 먼저 확인하세요. - Node 를 LTS 버전(B.1.3)으로 맞추면 미리 빌드된 바이너리가 있을 가능성이 높아집니다.
B.1.3 Node 버전이 맞지 않는다
증상. pnpm start:dev 에서 SyntaxError, ERR_REQUIRE_ESM, Unsupported engine 경고, 또는 Nest CLI 가 버전 요구를 알리는 메시지를 내고 멈춥니다.
원인. NestJS 12 로 만든 앱을 실행하려면 Node 20.19 이상 또는 22.12 이상이 필요하고, 프로젝트를 만들고 코드를 생성하는 Nest CLI 는 22.22.3 이상, 24.15 이상, 또는 26 이상을 요구합니다(NestJS 공식 마이그레이션 가이드, 이 책이 쓴 버전 기준). 머리말의 준비물 표가 CLI 기준을 적은 이유입니다.
Nest CLI(@nestjs/cli)나 다른 개발 도구는 이보다 높은 버전을 요구할 수 있습니다. 정확한 값은 설치된 패키지의 package.json 에 있는 engines 필드가 기준입니다.
확인.
$ node -v
$ node -p "require('@nestjs/core/package.json').engines"
$ node -p "require('@nestjs/cli/package.json').engines"
(api/ 안에서 실행합니다. engines 가 undefined 면 그 패키지는 요구 버전을 적지 않은 것입니다.)
해결. 표시된 요구 범위에 맞는 Node 로 바꿉니다. 여러 버전을 오가야 한다면 버전 관리 도구(nvm, fnm, Volta 등)를 쓰고,
api/.nvmrc 같은 파일에 버전을 적어 두면 팀 전체가 같은 버전을 씁니다.
B.1.4 cookieParser is not a function
증상. API 가 뜨자마자 TypeError: (0 , cookie_parser_1.default) is not a function 이나 helmet_1.default is not a function 으로 죽습니다.
원인. cookie-parser 와 helmet(의 CommonJS 빌드)은 module.exports = 함수 모양으로 내보냅니다.
main.ts 의 import cookieParser from 'cookie-parser'; 같은 default import 가 이것을 제대로 받으려면 TypeScript 의 esModuleInterop 이 켜져 있어야 합니다.
꺼져 있으면 TypeScript 는 require('cookie-parser').default 를 부르는 코드로 컴파일하고, 그 값은 undefined 입니다.
allowSyntheticDefaultImports 만 켜져 있으면 타입 검사는 통과하고 실행할 때만 터지므로 더 헷갈립니다.
(최근 TypeScript 는 이 옵션들의 기본값과 허용 범위를 바꾸는 중이라, 스캐폴드가 만든 tsconfig.json 에 따라 증상이 없을 수도 있습니다.)
확인.
$ grep -n -E 'esModuleInterop|allowSyntheticDefaultImports|"module"' api/tsconfig.json
$ grep -n -A2 'cookie_parser' api/dist/main.js
두 번째 명령에서 .default)( 로 부르고 있다면 이 원인입니다.
해결. api/tsconfig.json 의 compilerOptions 에 "esModuleInterop": true 를 넣고 다시 빌드합니다. 부록 C 의 코드는 이 설정을 전제로 합니다.
설정을 바꿀 수 없는 사정이라면 import * as cookieParser from 'cookie-parser'; 처럼 namespace import 로 바꾸는 방법도 있지만,
이 형태로 함수를 부르는 것은 ES 모듈 규칙상 올바르지 않아 도구에 따라 경고가 나옵니다.
B.2 요청 처리
B.2.1 req.cookies 가 undefined 다
증상. 브라우저 개발자 도구에서는 요청에 Cookie: refresh_token=… 이 분명히 실려 있는데, POST /auth/refresh 가 401 REFRESH_TOKEN_INVALID 입니다.
핸들러에서 console.log(req.cookies) 를 찍으면 undefined 입니다.
원인. Express 는 Cookie 헤더를 스스로 파싱하지 않습니다. cookie-parser 미들웨어가 헤더를 읽어 req.cookies 객체를 만들어 줍니다.
main.ts 에서 app.use(cookieParser()) 를 빠뜨렸거나, 파일은 고쳤는데 서버가 다시 뜨지 않았습니다.
확인.
$ grep -n 'cookieParser' api/src/main.ts
$ curl -s -i -H 'Origin: http://localhost:5173' -H 'Cookie: refresh_token=dummy' \
-X POST http://localhost:4000/auth/refresh | head -1
(9장 이후라면 두 번째 요청은 CSRF 검사 때문에 403 이 먼저 나옵니다. 그 경우 CsrfGuard 의 토큰 검사도 req.cookies 가 없어서 항상 실패하므로,
정상 준비 함수(9장)를 써도 CSRF_TOKEN_INVALID 가 계속 난다면 이 원인을 의심합니다.)
해결. main.ts 에 app.use(cookieParser()); 를 넣습니다. 쿠키를 읽는 모든 코드(가드·핸들러)보다 먼저 등록되기만 하면 되므로 helmet() 바로 다음이 자연스럽습니다(3장).
B.3 쿠키
쿠키 문제는 먼저 어느 단계에서 문제가 생겼는지를 가르면 빨리 풀립니다.
- 응답에
Set-Cookie가 있는가? → 개발자 도구 Network → 해당 요청 → Headers 의 응답 헤더 - 브라우저가 그 쿠키를 받아들였는가? → 같은 요청의 Cookies 하위 탭(거부된 쿠키에는 노란 경고 아이콘과 이유가 붙습니다), 그리고 Application → Cookies
- 다음 요청에 쿠키가 실렸는가? → 그 요청의 Cookies 하위 탭에서 "필터링된 쿠키 표시" 를 켜면 빠진 쿠키와 이유가 보입니다
(위치와 문구는 Chrome 버전에 따라 조금씩 다릅니다.)
B.3.1 Set-Cookie 는 왔는데 쿠키가 저장되지 않는다
증상. 로그인 응답 헤더에 Set-Cookie: refresh_token=… 이 있는데 Application 탭의 쿠키 목록에 없습니다. 새로고침하면 로그인이 풀립니다.
원인.
Secure쿠키를 HTTP 로, 그리고localhost가 아닌 주소로 받았다.COOKIE_SECURE=true인 쿠키는 원래 HTTPS 에서만 저장됩니다. 브라우저가 예외로 봐주는 것은localhost(와127.0.0.1같은 루프백 주소)뿐입니다.http://192.168.0.10:4000이나/etc/hosts에 등록한http://api.test:4000으로 열면 저장되지 않습니다.SameSite=None인데Secure가 없다. 최근 브라우저는SameSite=None쿠키에Secure가 없으면 거부합니다. 실습 쿠키lab_session이secure: true로 고정된 이유입니다.- 다른 사이트에서 시작한 요청의 응답이
SameSite=Strict·Lax쿠키를 설정하려 했다. 예를 들어 FE 를127.0.0.1:5173으로 열고 API 는localhost:4000으로 부르면, 로그인 응답의refresh_token(Strict)은 다른 사이트 응답이라 저장되지 않을 수 있습니다. - 서드파티 쿠키 차단.
SameSite=None쿠키가 다른 사이트 문맥에서 설정되는 경우, 브라우저 설정(시크릿 창 기본값 포함)에 따라 거부됩니다.
확인. Network 탭 → 로그인 요청 → Cookies 하위 탭의 "응답 쿠키" 에 경고 아이콘이 있는지, 마우스를 올려 이유를 읽습니다. curl 로는 속성을 확인할 수 있습니다.
$ curl -s -i http://localhost:4000/auth/csrf | grep -i '^set-cookie'
해결. FE·API·Swagger 를 모두 localhost 로 엽니다(부록 C 의 vite.config.ts 가 host: 'localhost' 인 이유). 사내 IP 나 다른 호스트 이름으로 시험해야 한다면
그 환경은 HTTPS 로 띄우거나, 로컬 실험에 한해 COOKIE_SECURE=false 로 둡니다. COOKIE_SECURE=false 는 운영에서 절대 쓰지 않습니다(14장).
B.3.2 쿠키가 요청에 실리지 않는다
증상. Application 탭에는 refresh_token 이 있는데, POST /auth/refresh 요청의 헤더에 Cookie 가 없습니다. 결과는 401 REFRESH_TOKEN_INVALID.
원인.
credentials: 'include'누락.fetch의 기본값은 같은 출처일 때만 쿠키를 싣습니다. FE(localhost:5173)와 API(localhost:4000)는 다른 출처이므로credentials: 'include'가 있어야 합니다. 부록 C 의api·authApi클라이언트는 둘 다 이 옵션을 줍니다. 직접fetch를 부르는 코드(예:LabPage.tsx)에서 빠뜨리기 쉽습니다.- FE 는
127.0.0.1로, API 는localhost로. 둘이 다른 사이트가 되어SameSite=Strict쿠키가 실리지 않습니다. 반대로 FE 는localhost,VITE_API_URL은http://127.0.0.1:4000인 경우도 같습니다. Path불일치.refresh_token은Path=/auth라/auth/*로 가는 요청에만 실립니다./users/me에 이 쿠키가 없는 것은 정상입니다. 반대로 API 경로 앞에 접두사(/api/auth/refresh)를 붙였다면 쿠키Path도 함께 바꿔야 합니다.- 쿠키가 만료됐다. Application 탭의 Expires 열을 봅니다. 세션 상한(
SESSION_MAX_AGE_SEC)을 테스트용으로 짧게 둔 채 잊은 경우도 있습니다.
확인.
$ grep -rn "credentials" web/src
$ grep VITE_API_URL web/.env
브라우저 주소창의 호스트와 VITE_API_URL 의 호스트가 같은 문자열(localhost)인지 눈으로 확인합니다.
해결. 요청에 credentials: 'include' 를 주고, FE 와 API 를 같은 호스트 이름으로 엽니다(11장).
B.3.3 로그아웃했는데 쿠키가 지워지지 않는다
증상. POST /auth/logout 이 204 를 줬는데 Application 탭에 refresh_token 이 그대로 있습니다. 새로고침하면 refresh 가 401 을 받고서야 로그아웃 상태가 됩니다.
원인. 브라우저는 쿠키를 이름 + Domain + Path 로 구분합니다. 쿠키를 지우는 응답(Set-Cookie: refresh_token=; Expires=Thu, 01 Jan 1970 …)의 Path 가
설정할 때와 다르면 브라우저는 다른 쿠키로 보고 원래 쿠키를 지우지 않습니다. res.clearCookie(name) 을 옵션 없이 부르면 Path=/ 로 지우려 하므로 Path=/auth 쿠키는 남습니다.
일부 브라우저는 Secure·SameSite 가 다른 삭제 요청도 무시할 수 있습니다.
확인.
$ curl -s -i -H 'Origin: http://localhost:5173' -H "X-CSRF-Token: $T" -b "$JAR" \
-X POST http://localhost:4000/auth/logout | grep -i '^set-cookie'
(9장의 준비 함수로 $JAR·$T 를 만든 뒤 실행합니다.) 출력의 Path=/auth 가 있는지, HttpOnly; Secure; SameSite=Strict 가 설정 때와 같은지 봅니다.
해결. 부록 C 의 cookies.ts 처럼 설정과 삭제가 같은 옵션 함수(refreshOptions(config))를 쓰게 합니다. 옵션을 한곳에서 만들면 한쪽만 고치는 실수가 사라집니다(6장).
B.4 CORS 에러
증상. FE 콘솔에 다음과 같은 메시지가 나오고, 요청이 실패합니다.
Access to fetch at 'http://localhost:4000/auth/login' from origin 'http://localhost:5173'
has been blocked by CORS policy: …
원인. 메시지의 뒷부분이 원인을 알려 줍니다. 흔한 것은 다음 넷입니다.
| 메시지의 뒷부분 | 원인 |
|---|---|
No 'Access-Control-Allow-Origin' header is present | FE 의 출처가 WEB_ORIGINS 에 없다. 끝의 /, 127.0.0.1 과 localhost, 포트 번호 차이를 확인 |
Request header field x-csrf-token is not allowed | allowedHeaders 에 그 헤더가 없다 |
The value of the 'Access-Control-Allow-Origin' header … must not be the wildcard '*' when the request's credentials mode is 'include' | origin: '*' 와 credentials: 'include' 를 함께 썼다. 쿠키를 쓰는 요청에는 구체적인 출처가 필요하다 |
… 'Access-Control-Allow-Credentials' header … is '' | 서버의 credentials: true 가 빠졌다 |
CORS 에러처럼 보이지만 실제로는 서버가 500 을 냈거나 서버가 꺼져 있는 경우도 있습니다. 에러 응답에 CORS 헤더가 붙지 않는 구성이면 브라우저는 CORS 에러로 보고합니다.
확인. preflight 를 curl 로 흉내 냅니다.
$ curl -s -i -X OPTIONS http://localhost:4000/auth/login \
-H 'Origin: http://localhost:5173' \
-H 'Access-Control-Request-Method: POST' \
-H 'Access-Control-Request-Headers: content-type,x-csrf-token' | grep -i '^access-control'
예상 결과(예시):
Access-Control-Allow-Origin: http://localhost:5173
Access-Control-Allow-Credentials: true
Access-Control-Allow-Methods: GET,POST,PATCH,DELETE
Access-Control-Allow-Headers: Content-Type,Authorization,X-CSRF-Token
Origin 을 http://evil.test 로 바꾸면 Access-Control-Allow-Origin 이 없어야 정상입니다(13장 S18).
해결. .env 의 WEB_ORIGINS 를 브라우저 주소창의 출처와 글자 하나까지 같게 적고, main.ts 의 enableCors 가 부록 C 와 같은지 확인합니다.
origin: true 나 '*' 로 바꿔 에러를 없애지 않습니다. 9장에서 본 것처럼 CSRF 토큰 방어가 통째로 무너집니다.
B.5 토큰과 refresh
B.5.1 refresh 가 계속 401 이다
증상. 로그인은 되는데 POST /auth/refresh 가 매번 401 입니다. 새로고침하면 로그인이 풀립니다.
원인. 응답의 code 로 먼저 가릅니다.
REFRESH_TOKEN_INVALID— 서버가 유효한 쿠키를 받지 못했습니다. 쿠키가 안 실림(B.3.2:credentials, 호스트 이름,Path),req.cookies가 없음(B.2.1), 쿠키가 폐기·만료됨(로그아웃, 세션 상한, DB 초기화)이 흔한 원인입니다.docker compose down -v로 DB 를 지웠다면 브라우저의 쿠키는 남아 있어도 DB 에 그 해시가 없으니 401 이 정상입니다.REFRESH_TOKEN_REUSED— 이미 회전된 토큰이 grace(10초) 밖에서 다시 왔습니다. B.5.3 을 보세요.- 401 이 아니라 403 이면 CSRF 문제입니다(B.6).
확인.
$ pnpm --dir api db:psql
auth_lab=# SELECT family_id, left(token_hash, 8) AS hash, revoke_reason, expires_at, session_expires_at
auth_lab-# FROM refresh_tokens ORDER BY created_at DESC LIMIT 5;
가장 최근 행의 revoke_reason 이 비어 있고 expires_at 이 미래여야 refresh 가 가능합니다.
브라우저 쪽에서는 refresh 요청의 Cookies 하위 탭에 refresh_token 이 실렸는지 봅니다.
해결. 원인에 맞게 고칩니다. 쿠키가 실리지 않는 문제가 가장 흔하므로 B.3.2 를 먼저 확인하세요.
B.5.2 409 가 반복된다
증상. 409 REFRESH_TOKEN_ROTATED 를 받고 FE 가 한 번 더 시도했는데 또 409 입니다.
원인. 409 는 "방금(grace 10초 안에) 다른 요청이 이 토큰을 회전시켰으니, 그 요청이 내려 준 새 쿠키로 다시 해라" 는 뜻입니다.
재시도에서도 409 가 나온다면 새 쿠키가 브라우저에 저장되지 않아 옛 쿠키를 다시 보낸 것입니다. 앞선 refresh 응답의 Set-Cookie 가 거부됐거나(B.3.1),
그 응답이 도착하기 전에 재시도가 나갔거나(탭 간 잠금이 없음), FE 가 아닌 곳(curl 스크립트 등)에서 쿠키 저장소를 갱신하지 않고 옛 값을 계속 보내는 경우입니다.
확인. Network 탭에서 연속된 refresh 요청들의 Cookie 값 앞부분을 비교합니다. 같은 값이 두 번 이상 나가고 있다면 이 원인입니다.
curl 이라면 -c "$JAR" 로 응답 쿠키를 저장소에 다시 쓰고 있는지 봅니다.
해결. refresh 는 탭 간 잠금(navigator.locks) 안에서 single-flight 로 부르고(12장), 쿠키가 저장되지 않는 원인(B.3.1)을 없앱니다.
curl 에서는 모든 요청에 -c "$JAR" -b "$JAR" 를 함께 줍니다.
B.5.3 StrictMode 에서 REFRESH_TOKEN_REUSED
증상. 개발 서버에서만, 새로고침 직후 가끔 "다른 곳에서 이 세션의 토큰이 다시 쓰였습니다" 가 뜨며 로그아웃됩니다. 운영 빌드에서는 재현되지 않습니다.
원인. React 개발 모드의 StrictMode 는 effect 를 두 번 실행합니다. 부팅 refresh 를 single-flight 없이 부르면 두 번째 요청이 옛 쿠키를 들고 나가고,
grace 안이면 409, 밖이면 재사용 탐지가 걸립니다. 네트워크가 느리거나 디버거로 멈춘 사이 10초가 지나면 후자가 됩니다.
Vite 의 HMR 로 모듈이 다시 실행되며 refreshing 변수가 초기화되는 경우도 비슷한 결과를 낳을 수 있습니다.
확인. Network 탭에서 새로고침 직후 refresh 요청이 두 개 나가는지, 두 요청의 Cookie 값이 같은지 봅니다.
해결. useBootSession 은 refreshSession() 을 부르고, refreshSession() 은 진행 중인 promise 를 재사용해야 합니다(부록 C session.ts 의 refreshing ??=, 12장).
StrictMode 를 끄는 것은 해결이 아닙니다. 두 탭이 동시에 refresh 하는 실제 상황도 같은 문제를 일으키기 때문입니다.
B.5.4 JWT 의 exp 가 이상하다 — 시계 차이
증상. 방금 받은 액세스 토큰으로 요청했는데 401 ACCESS_TOKEN_INVALID 가 나오거나, MePage 의 "남은 시간" 이 600초가 아니라 음수나 수천 초로 표시됩니다.
원인. JWT 의 iat·exp 는 발급한 서버의 시계로 적힙니다. 검증하는 쪽(같은 API 라면 문제없음)이나 화면에 남은 시간을 계산하는 쪽(FE 는 사용자 컴퓨터의 시계)이 다른 시계를 쓰면 차이가 생깁니다.
가상 머신·컨테이너가 절전 후 시계를 맞추지 못했거나, 사용자 컴퓨터의 시계가 틀린 경우입니다. 이 책의 검증 옵션에는 시계 오차 허용(clockTolerance)을 두지 않았습니다.
확인.
$ date -u
$ docker compose exec db date -u
$ node -e "console.log(new Date().toISOString())"
그리고 토큰의 본문을 풀어 iat 를 지금 시각(date +%s)과 비교합니다. 몇 초 이상 차이 나면 시계 문제입니다.
해결. 운영체제의 시간 동기화(NTP)를 켭니다. FE 의 남은 시간 표시는 교육용이라 사용자 시계에 기대지만, 실제 만료 판단은 서버가 하고 FE 는 401 을 받으면 refresh 하므로 동작에는 지장이 없습니다. 서버가 여러 대라면 서버 간 시계 차이도 확인합니다.
B.6 CSRF
B.6.1 403 CSRF_ORIGIN_REJECTED
증상. login·refresh·logout 이 403 과 "code": "CSRF_ORIGIN_REJECTED" 를 줍니다.
원인. 요청의 Origin(없으면 Referer 의 출처)이 허용 목록(csrfAllowedOrigins)에 없습니다.
- curl 에서 — curl 은
Origin을 붙이지 않습니다.-H 'Origin: http://localhost:5173'을 붙입니다(9장의 준비 함수). - FE 에서 — 브라우저 주소창의 출처가
WEB_ORIGINS와 다릅니다. 끝의/,127.0.0.1, 포트 번호를 봅니다. - Swagger 에서 — Swagger UI 의 요청은 API 자신의 출처(
http://localhost:4000)에서 나갑니다. 이 값이 허용 목록에 들어가려면SWAGGER_ENABLED=true이고API_ORIGIN이 주소창의 출처와 같아야 합니다.http://127.0.0.1:4000/docs로 열었거나API_ORIGIN=http://localhost:4000/처럼 끝에/를 붙이면 거부됩니다. - Swagger 에서, 위 설정이 전부 맞는데도 — 요청의
Origin헤더 값을 직접 봅니다. helmet 의 기본Referrer-Policy: no-referrer는 Fetch 표준상cors모드가 아닌 요청(폼 제출,no-corsfetch)에서만Origin을null로 바꾸므로,fetch를 쓰는 Swagger UI 의 요청에는 실제 출처가 실리는 것이 정상입니다.null이나 다른 값이 보이면 브라우저 확장 프로그램·프록시가 헤더를 바꾸고 있지 않은지 확인하세요.
확인.
$ grep -E '^(WEB_ORIGINS|API_ORIGIN|SWAGGER_ENABLED)=' api/.env
$ curl -s -I http://localhost:4000/docs | grep -i referrer-policy
브라우저에서는 실패한 요청의 Headers → Request Headers → Origin 값을 직접 봅니다. 그 값이 허용 목록의 어느 원소와도 글자 하나까지 같지 않으면 거부됩니다.
해결. 설정 값을 주소창의 출처와 맞춥니다. 원인을 가리기 어려우면 9장의 curl 준비 함수로 같은 요청을 보내 서버 설정부터 확인합니다.
어느 경우든 허용 목록에 'null' 을 넣어서는 안 됩니다(9장).
B.6.2 403 CSRF_TOKEN_INVALID
증상. Origin 은 맞는데 403 과 "code": "CSRF_TOKEN_INVALID" 가 나옵니다.
원인. X-CSRF-Token 헤더와 csrf_token 쿠키가 한 쌍이 아닙니다.
- 헤더를 빠뜨렸다. Swagger 에서 Authorize 의
csrf칸을 채우지 않았거나, FE 에서ensureCsrf()를 거치지 않고 요청했다. GET /auth/csrf를 다시 불러 쿠키가 새 값으로 바뀌었는데 헤더에는 옛 값을 싣고 있다. 다른 탭이나 Swagger 에서 발급을 새로 받아도 같은 브라우저의 쿠키가 바뀐다.- 쿠키가 없다. 2시간(
Max-Age=7200)이 지나 만료됐거나, B.3.1 의 이유로 저장되지 않았거나, B.2.1 처럼 서버가 쿠키를 읽지 못한다. - curl 이
Secure쿠키를http://localhost로 보내지 않는다. 최근 curl 은localhost를 예외로 보내 주지만, 오래된 버전은Secure쿠키를 HTTP 요청에 싣지 않을 수 있다. CSRF_SECRET을 바꾸고 API 를 다시 띄웠다. 이전에 발급한 토큰은 서명이 맞지 않는다.
확인.
$ curl --version | head -1
$ grep csrf_token "$JAR"
$ curl -s -b "$JAR" -H 'Origin: http://localhost:5173' -H "X-CSRF-Token: $T" \
-X POST http://localhost:4000/auth/logout -o /dev/null -w '%{http_code}\n'
저장소 파일에 csrf_token 줄이 있고, 마지막 명령이 204 면 짝이 맞는 것입니다. grep 결과의 값과 echo "$T" 가 같은지도 봅니다.
해결. 새 저장소에서 T=$(csrf) 부터 다시 합니다. Swagger 에서는 GET /auth/csrf 를 다시 실행하고 Authorize 값을 새 값으로 교체합니다.
FE 는 이 에러를 받으면 토큰을 버리고 다시 발급받아 한 번 재시도합니다(12장, 부록 C 의 runRefresh).
B.6.3 8·9장 공격 실험 결과가 책과 다르다
증상. 방어를 켰는데 공격이 성공하거나, 방어가 없는데 공격이 실패합니다.
원인과 확인.
| 증상 | 원인 | 확인 |
|---|---|---|
| 설정을 바꿨는데 이전 결과 그대로 | API 를 다시 띄우지 않았거나, POST /lab/login 을 다시 하지 않아 옛 속성의 쿠키가 남음 | curl -s localhost:4000/lab/settings, Application 탭의 lab_session SameSite 열 |
none 인데 127.0.0.1:5174 공격이 401 | 서드파티 쿠키 차단(시크릿 창은 기본 차단) | 요청의 Cookies 하위 탭 → 필터링된 쿠키와 그 이유 |
모든 실습 요청이 404 LAB_DISABLED | LAB_MODE 가 true 가 아님 | grep LAB_ api/.env |
닉네임 변경이 400 VALIDATION_FAILED | 본문이 파싱되지 않음(text/plain), 또는 닉네임이 2~20자 밖 | 요청의 Content-Type 과 본문 |
| 공격 탭에 아무것도 안 보임 | 정상. 응답은 숨긴 iframe 으로 가고, helmet 의 frame 관련 헤더가 표시를 막음 | Network 탭에서 nickname 요청의 상태 코드 |
localhost:5174 가 디렉터리 목록을 보여 줌 | evil/ 밖에서 python3 -m http.server 를 실행함 | 서버를 띄운 터미널의 현재 폴더 |
lab_session 이 저장되지 않음 | Swagger 를 localhost 가 아닌 주소로 엶 | 주소창 |
해결. 9장 9.1 의 실험 순서를 처음부터 다시 따라갑니다. 한 번에 한 설정만 바꿉니다.
B.7 Swagger UI
B.7.1 /docs 가 빈 화면이다
증상. http://localhost:4000/docs 가 흰 화면이거나, 틀만 보이고 엔드포인트 목록이 뜨지 않습니다.
원인.
SWAGGER_ENABLED가true가 아니다. 이 경우/docs자체가 404 입니다.- helmet 의 CSP 가 Swagger UI 의 스크립트·스타일을 막는다. helmet 은 기본으로
Content-Security-Policy를 붙여 같은 출처의 스크립트만 허용합니다.@nestjs/swagger는 Swagger UI 파일을 API 자신이 내보내므로 대개 문제가 없지만, 버전이나 옵션(외부 CDN 의 CSS·JS 를 쓰는 설정, 인라인 스크립트)에 따라 CSP 위반으로 일부가 막힐 수 있습니다. 이 부분은 라이브러리 버전에 따라 달라지므로 콘솔 메시지로 확인하는 것이 확실합니다. - API 가 기동 중 에러로 죽었거나, 문서 생성(
buildOpenApi) 단계에서 DTO 데코레이터 문제로 예외가 났다.
확인. 개발자 도구 콘솔에 Refused to load the script … 나 Refused to apply inline style … Content Security Policy 같은 메시지가 있는지 봅니다.
$ curl -s -o /dev/null -w '%{http_code}\n' http://localhost:4000/docs
$ curl -s -I http://localhost:4000/docs | grep -i content-security-policy
$ curl -s http://localhost:4000/docs-json | jq '.paths | keys | length'
마지막 명령이 숫자를 찍으면 문서 자체는 정상으로 만들어지고 있는 것입니다.
해결. CSP 위반이라면 콘솔이 알려 주는 막힌 출처를 보고 판단합니다. helmet 에 /docs 경로만 다른 CSP 를 주거나, 개발 환경에서만 CSP 를 완화하는 방법이 있지만
부록 C 의 코드를 바꾸는 일이고 운영의 보안 헤더와 직결되므로 신중하게 결정합니다. 문서만 필요하다면 /docs-json 과 계약 파일(contract/openapi.json)로도 작업할 수 있습니다(10장).
B.8 프론트엔드
B.8.1 VITE_API_URL 이 undefined 다
증상. FE 의 요청이 http://localhost:5173/undefined/auth/csrf 같은 이상한 주소로 나가거나, import.meta.env.VITE_API_URL 을 찍으면 undefined 입니다.
원인.
- 변수 이름에
VITE_접두사가 없다. Vite 는 비밀이 번들에 섞이지 않도록VITE_로 시작하는 변수만 클라이언트 코드에 노출합니다.API_URL=…로 적으면 보이지 않습니다. .env를web/이 아닌 곳(저장소 루트,api/)에 두었다. Vite 는 기본으로 프로젝트 루트(web/)의.env를 읽습니다..env를 고친 뒤 개발 서버를 다시 띄우지 않았다. 환경변수는 개발 서버가 시작할 때 읽습니다.
확인.
$ cat web/.env
$ grep -rn "import.meta.env" web/src
해결. web/.env 에 VITE_API_URL=http://localhost:4000 을 적고 pnpm dev 를 다시 실행합니다(10장).
TypeScript 가 import.meta.env.VITE_API_URL 의 타입을 모른다고 하면 Vite 의 vite/client 타입 선언이 포함돼 있는지 확인합니다.
⚠️ VITE_ 변수는 번들에 그대로 들어가 누구나 볼 수 있습니다. API 주소 같은 공개 값만 넣고, 비밀은 절대 넣지 않습니다.
B.8.2 openapi-typescript 가 옛 스키마를 본다
증상. 백엔드에 엔드포인트나 필드를 추가했는데, FE 에서 api.GET('/새경로') 가 타입 에러이거나 새 필드가 타입에 없습니다.
원인. src/api/schema.d.ts 는 생성된 시점의 계약을 담은 파일입니다. 백엔드를 고친 뒤 다시 만들지 않으면 옛 타입이 남습니다.
gen:api 는 contract/openapi.json 을 입력으로 쓰므로, 그 파일도 백엔드에서 다시 내보내야 합니다. 두 단계 중 하나만 하면 여전히 옛 타입입니다.
gen:api:live 를 쓴다면 실행 중인 API 가 새 코드로 다시 떴는지도 확인합니다.
확인.
$ ls -l contract/openapi.json web/src/api/schema.d.ts
$ jq '.paths | keys' contract/openapi.json
$ git diff --stat contract/openapi.json
해결. 두 단계를 순서대로 다시 합니다.
$ pnpm --dir api openapi:export
$ pnpm --dir web gen:api
그다음 FE 에서 타입 검사를 돌려 깨지는 곳을 고칩니다. 깨지는 곳이 곧 계약이 바뀐 곳입니다(10장). schema.d.ts 는 손으로 고치지 않습니다.
B.8.3 429 TOO_MANY_REQUESTS 로 로그인이 안 된다
증상. 비밀번호가 맞는데도 로그인 응답이 429 입니다.
원인. 로그인 시도 제한은 IP 당 1분 5회이고(5장), 성공·실패를 가리지 않고 셉니다. 13장의 시나리오 스크립트나 curl 실험을 연달아 돌리면 쉽게 넘습니다.
CsrfGuard 보다 제한 가드가 먼저 돌기 때문에 CSRF 로 거절된 로그인 요청도 횟수에 들어갑니다(9장). 제한 기록은 메모리에 있어 API 를 다시 띄우면 초기화됩니다.
확인. 응답의 code 가 TOO_MANY_REQUESTS 인지 봅니다. 직전 1분 동안 보낸 로그인 요청 수를 셉니다.
해결. 1분 기다립니다. 실습 중이라면 API 를 다시 띄워도 됩니다. 제한 값을 늘려 문제를 피하지는 않습니다. 운영과 다른 값으로 테스트하게 됩니다.