aster®

[로그인인증] 운영으로 가져가기 (14)

마무리 · 2026-09

초록으로 채운 큰 열쇠 하나가 가로로 놓여 있고, 그 뒤에 같은 모양의 열쇠 두 개가 남색 윤곽선만으로 겹쳐 있다. 열쇠들 아래로 가는 선이 하나 지나간다.

이 장을 마치면

  • FE 와 API 의 도메인을 어떻게 배치할지 세 가지 구성을 비교해 고르고, 리프레시 쿠키에 Domain 을 붙이지 않는 이유를 설명할 수 있다.
  • 실습 환경의 설정값 가운데 운영에서 반드시 바꿔야 할 것(HTTPS, 비밀, Swagger, WEB_ORIGINS, 프록시)을 체크리스트로 점검한다.
  • 재사용 탐지 경보, 폐기 행 청소, JWT 키 교체처럼 운영하면서 계속 돌려야 할 일을 계획할 수 있다.
  • __Host- 접두사와 액세스 토큰 즉시 폐기처럼 이 설계가 일부러 택하지 않은 선택지의 트레이드오프를 말할 수 있다.
  • 이 책이 다루지 않은 주제(OAuth 2.0/OIDC, MFA, 패스키)가 이 책의 설계와 어떻게 이어지는지 안다.

들어가며

13장에서 22개 시나리오로 이 설계를 증명했습니다. 가입부터 재사용 탐지, CSRF 세 겹, 탭 두 개의 동시 만료까지 모두 기대한 대로 동작합니다. 그런데 그것은 localhost 에서의 이야기입니다.

실습 환경은 운영 환경과 여러모로 다릅니다. FE 와 API 가 같은 컴퓨터의 다른 포트에 있고, HTTPS 가 아니라 HTTP 이고(localhost 라서 브라우저가 봐줬습니다), 비밀 값은 change-me-… 이고, Swagger UI 와 CSRF 실습실이 열려 있고, 서버는 프로세스 하나입니다. 이 장은 이 차이를 하나씩 메웁니다.

이 장의 성격은 앞 장들과 조금 다릅니다. 앞 장들은 "코드를 쓰고, 돌리고, 확인한다" 의 반복이었습니다. 이 장은 대부분 판단과 설정입니다. 새 코드를 쓰는 대신, 이미 만든 코드가 운영 환경에서 어떤 가정 위에 서 있는지 드러내고 그 가정을 지키는 방법을 정리합니다. 몇 군데에서 코드 조각을 보이지만, 그것은 부록 C 에 없는 운영용 변경의 예이고 실습 코드에 넣으라는 뜻이 아닙니다. 그런 조각에는 매번 그렇다고 표시합니다.

장 중간중간의 판단들은 마지막에 하나의 체크리스트(14.15절)로 모읍니다. 그리고 책을 마치며 이 책이 다루지 않은 것들과 다음에 공부할 방향을 적습니다.

14.1 도메인 구성 세 가지

1장에서 출처(origin) 와 사이트(site) 를 구분했습니다. 출처는 스킴·호스트·포트 전부를, 사이트는 스킴과 등록 가능한 도메인만 봅니다. 실습에서는 localhost:5173 과 localhost:4000 이 "다른 출처, 같은 사이트" 였고, 이 관계 위에서 SameSite=Strict 쿠키가 동작했습니다. 운영에서도 이 관계를 유지하는 것이 도메인 설계의 핵심입니다.

선택지는 크게 세 가지입니다.

구성예출처·사이트 관계쿠키판단
A. 서브도메인 둘FE app.example.com, API api.example.com다른 출처, 같은 사이트설계 그대로. Domain 없음 = API 호스트 전용권장
B. 한 도메인 + 리버스 프록시example.com 이 FE 를, example.com/api/* 를 API 로 전달같은 출처쿠키 Path 를 /api/auth 로 바꿔야 한다권장
C. 등록 도메인이 다름FE example.com, API example-api.io다른 사이트SameSite=None; Secure 가 강제된다피한다

A. 서브도메인 둘 — 실습 환경을 그대로 옮긴 모양

실습의 localhost:5173 ↔ localhost:4000 이 운영에서는 app.example.com ↔ api.example.com 이 됩니다. 둘은 출처가 다르니 CORS 가 필요하고(WEB_ORIGINS=https://app.example.com), 사이트가 같으니 SameSite=Strict 인 리프레시 쿠키가 FE 에서 보낸 fetch 에 실립니다. 실습에서 확인한 모든 것이 그대로 성립합니다. 코드를 바꿀 곳이 없고 설정값만 바뀝니다. 이 책이 이 구성을 권장하는 가장 큰 이유입니다.

FE 는 정적 파일 호스팅(CDN)에, API 는 서버에 따로 배포할 수 있어 배포 경로도 단순합니다.

B. 한 도메인 + 리버스 프록시 — CORS 가 사라지는 모양

example.com 앞에 리버스 프록시(nginx, 클라우드 로드 밸런서 등)를 두고, /api/* 로 오는 요청만 API 서버로 넘기는 구성입니다. 브라우저 입장에서는 FE 와 API 가 같은 출처입니다. CORS 가 필요 없어지고, preflight 요청도 사라집니다.

대신 경로가 바뀝니다. 브라우저가 보는 리프레시 엔드포인트는 /auth/refresh 가 아니라 /api/auth/refresh 이니, 리프레시 쿠키의 Path 도 /api/auth 여야 합니다. 부록 C 의 cookies.ts 는 path: '/auth' 를 코드에 박아 두었으므로 이 구성을 택하면 그 값을 바꿔야 합니다. (프록시가 /api 를 떼고 넘기더라도, 쿠키의 Path 는 브라우저가 보는 경로 기준이라는 점을 잊지 마세요.) CSRF 방어는 그대로 둡니다. 같은 출처라도 Origin 검사와 CSRF 토큰은 여전히 제 역할을 합니다.

C. 등록 도메인이 다른 경우 — 피해야 하는 모양

example.com 과 example-api.io 는 다른 사이트입니다. 그러면 FE 에서 API 로 가는 요청은 브라우저 입장에서 "다른 사이트로 가는 요청" 이고, SameSite=Strict 도 Lax 도 쿠키를 싣지 않습니다. 쿠키를 싣게 하려면 SameSite=None; Secure 로 바꿔야 합니다. 그 순간 두 가지를 잃습니다.

첫째, CSRF 방어 한 겹이 사라집니다. 9장에서 세 겹 중 첫 겹이었던 SameSite 가 아무것도 막지 않게 되고, Origin 검사와 CSRF 토큰이 유일한 방어가 됩니다. 둘째, 쿠키가 아예 안 실릴 수 있습니다. 다른 사이트의 쿠키는 브라우저 입장에서 "서드파티 쿠키" 이고, Safari 의 추적 방지 기능을 비롯해 여러 브라우저가 서드파티 쿠키를 막거나 제한합니다. 사용자의 브라우저와 설정에 따라 새로고침 뒤 로그인이 되살아나지 않는 일이 생깁니다. 이 문제는 코드로 풀 방법이 없습니다.

그래서 이 구성이라면 A 나 B 로 바꾸자고 권합니다. 이미 도메인이 정해져 있어 바꿀 수 없다면, 그 결정이 무엇을 포기하는 것인지 팀이 알고 정하게 하세요.

⚠️ "등록 가능한 도메인" 은 눈으로 보는 것과 다를 수 있습니다. 브라우저는 공개 접미사 목록(Public Suffix List) 이라는 표로 등록 가능한 도메인을 판단하는데, 여러 사용자에게 서브도메인을 나눠 주는 호스팅 서비스의 도메인(예: github.io)이 이 목록에 올라 있습니다. 그러면 a.github.io 와 b.github.io 는 서로 다른 사이트입니다. 호스팅 서비스가 기본으로 주는 도메인에 FE 와 API 를 나눠 올리면, 서브도메인 둘처럼 보여도 사실은 구성 C 가 됩니다. 운영에서는 자기 도메인을 연결해 쓰세요.

14.2 쿠키에 Domain 을 붙이지 않는 이유

부록 C 의 cookies.ts 는 쿠키를 심을 때 domain 옵션을 주지 않습니다. 1장에서 보았듯 Domain 속성이 없는 쿠키는 그 쿠키를 심은 호스트에만 돌아갑니다(host-only 쿠키). 구성 A 에서 api.example.com 이 심은 리프레시 쿠키는 api.example.com 으로 가는 요청에만 실립니다.

Domain=example.com 을 붙이면 어떻게 될까요? 그 쿠키는 example.com 과 모든 서브도메인, 즉 app., api., blog., admin., staging. 으로 가는 요청에 실립니다. 리프레시 토큰은 이 설계에서 가장 오래 사는 자격 증명(최대 14일, 세션 상한 30일)입니다. 그런 값이 블로그 서버, 마케팅 페이지 서버, 테스트 서버의 접근 로그와 애플리케이션까지 흘러가게 됩니다. 그 서버들 중 하나라도 로그를 허술하게 다루거나 탈취되면 리프레시 토큰이 새 나갑니다. Path=/auth 가 어느 정도 막아 주지만, 다른 서버에 /auth/… 경로가 있으면 그것도 소용없습니다.

Domain 을 붙이고 싶어지는 가장 흔한 이유는 "FE 서버에서도 쿠키를 읽고 싶다" 입니다(예: 서버 렌더링). 그 요구는 이 책의 설계 범위 밖입니다. 액세스 토큰을 쿠키로 옮기는 순간 CSRF 대상이 모든 요청으로 넓어진다는 것을 9장에서 다뤘습니다. 그 결정을 한다면 설계를 다시 짜야 합니다.

14.3 HTTPS 와 HSTS

운영은 HTTPS 전용입니다. 이유는 두 층입니다.

첫째, 토큰이 네트워크를 지나갑니다. 로그인 응답 본문의 액세스 토큰, 요청 헤더의 Authorization, 쿠키의 리프레시 토큰이 모두 평문 HTTP 에서는 경로 위의 누구에게나 보입니다.

둘째, 쿠키 설정이 HTTPS 를 전제합니다. api/.env 의 COOKIE_SECURE=true 는 두 쿠키에 Secure 속성을 붙입니다. Secure 쿠키는 HTTPS 요청에만 실립니다. 실습에서 HTTP 인데도 동작했던 것은 브라우저가 localhost 를 특별히 신뢰할 수 있는 출처로 봐주었기 때문입니다. 운영에서 HTTP 로 열면 쿠키가 아예 저장되지 않거나 실리지 않습니다. COOKIE_SECURE=false 로 바꿔서 "해결" 하지 마세요. 그것은 증상을 가리고 리프레시 토큰을 평문으로 흘려보내는 선택입니다.

HSTS(Strict-Transport-Security 응답 헤더)는 "이 호스트는 앞으로 일정 기간 HTTPS 로만 접속하라" 고 브라우저에 알리는 헤더입니다. 사용자가 주소창에 http:// 로 치거나 누군가 HTTP 링크를 걸어도, 브라우저가 요청을 보내기 전에 HTTPS 로 바꿉니다. 첫 요청을 가로채 HTTP 로 끌어내리는 공격을 막아 줍니다.

부록 C 의 main.ts 는 app.use(helmet()) 로 helmet 의 기본 보안 헤더를 켭니다. helmet 은 기본값으로 HSTS 헤더를 보내며, 기본값에는 긴 유효 기간과 includeSubDomains 가 들어 있습니다(정확한 기본값은 사용하는 helmet 버전의 공식 문서에서 확인하세요). 두 가지를 알아 두세요.

TLS 는 보통 앞단의 로드 밸런서나 리버스 프록시가 처리하고, 그 뒤의 API 서버는 HTTP 로 받습니다. 그래도 브라우저가 보는 연결이 HTTPS 이니 Secure 쿠키와 HSTS 는 그대로 동작합니다. 이 구성에서 생기는 다른 문제(클라이언트 IP)는 14.7절에서 다룹니다.

14.4 비밀 관리 — 두 비밀을 나눈다

이 설계에는 서버만 알아야 하는 비밀이 둘 있습니다.

비밀무엇을 하나새 나가면
JWT_ACCESS_SECRET액세스 토큰(HS256) 서명·검증공격자가 아무 사용자의 액세스 토큰을 만들 수 있다
CSRF_SECRETCSRF 토큰의 HMAC 서명공격자가 유효한 CSRF 토큰 짝을 만들 수 있다 (쿠키 주입과 결합하면 CSRF 한 겹이 무너진다)

만드는 법과 넣는 법

두 값은 서로 다른 충분히 긴 난수여야 합니다. api/.env 의 주석대로 이렇게 만듭니다.

$ openssl rand -base64 48

48바이트 난수를 base64 로 쓰면 64자가 됩니다. AppConfig 는 두 비밀이 32자 이상인지 검사하는데(3장), 이것은 길이를 보는 최소한의 안전장치일 뿐 난수인지는 알 수 없습니다. password1234password1234password1234 도 통과합니다. 사람이 지은 값을 쓰지 마세요.

운영에서는 이 값을 .env 파일로 두지 않습니다. 배포 플랫폼의 비밀 저장소(클라우드의 비밀 관리 서비스, 오케스트레이터의 시크릿 기능, CI 의 암호화된 변수 등)에 넣고, 실행할 때 환경변수로 주입합니다. AppConfig 는 ConfigModule 을 통해 환경변수를 읽으므로 코드는 바뀌지 않습니다. .env 파일이 저장소에 커밋되지 않았는지(.gitignore), 컨테이너 이미지에 구워지지 않았는지도 확인하세요. DATABASE_URL 에 들어 있는 DB 비밀번호도 같은 방식으로 다룹니다.

왜 둘로 나누나

하나의 비밀로 JWT 도 서명하고 CSRF 토큰도 서명하면 설정이 하나 줄어듭니다. 그래도 나누는 이유는 두 가지입니다.

첫째, 피해 범위가 달라집니다. 어떤 경로로 CSRF 비밀이 새 나가도 액세스 토큰은 위조할 수 없습니다. 반대도 마찬가지입니다. 하나를 잃었을 때 다른 방어가 살아 있습니다.

둘째, 따로 교체할 수 있습니다. 두 비밀을 교체했을 때 사용자에게 일어나는 일이 다릅니다.

14.5 Swagger 와 실습실을 운영에서 닫는다

SWAGGER_ENABLED=false

실습 내내 켜 두었던 Swagger UI 는 운영에서 끄거나 사내망에서만 열리게 합니다. 이유는 세 가지입니다.

첫째, 공격 표면 지도입니다. Swagger UI 와 /docs-json 은 모든 엔드포인트, 모든 요청 모양, 모든 에러 코드를 친절하게 정리해 공개합니다. FE 개발자에게 좋은 것이 공격자에게도 좋습니다.

둘째, CSRF 허용 목록이 넓어집니다. AppConfig.csrfAllowedOrigins 를 다시 보세요. Swagger 가 켜져 있으면 API_ORIGIN 이 CSRF Origin 검사의 허용 목록에 더해집니다. Swagger UI 가 API 자신의 출처에서 login·refresh 를 부를 수 있게 하려는 것이었습니다(10장). 운영에서는 이 출처에서 쿠키 기반 요청이 올 이유가 없으니, 허용 목록을 FE 출처만으로 좁히는 편이 낫습니다. SWAGGER_ENABLED=false 면 이 확장이 자동으로 빠집니다.

셋째, API 출처에 HTML 페이지가 하나 사는 것 자체가 부담입니다. Swagger UI 는 API 와 같은 출처에서 돌고 쿠키도 실립니다. 그 페이지나 그 페이지가 불러오는 스크립트에 문제가 생기면, 그것은 API 출처에서 도는 스크립트가 됩니다.

Swagger 를 꺼도 FE 와의 협업은 멈추지 않습니다. 10장에서 계약을 파일로 내보내 커밋하는 흐름을 만들었고, 내보내기는 SWAGGER_ENABLED 를 보지 않습니다. FE 는 /docs-json 이 아니라 contract/openapi.json 을 봅니다.

LAB_MODE=false

8·9장의 CSRF 실습실(/lab/*)은 일부러 취약하게 만든 엔드포인트입니다. 액세스 토큰을 SameSite=None 쿠키로 주고, 그 쿠키만으로 닉네임을 바꿉니다. 운영에서 이것이 열려 있으면 실제 CSRF 취약점입니다. LAB_MODE=false 면 모든 /lab/* 요청이 404 LAB_DISABLED 가 됩니다. 운영 배포 전에 반드시 끕니다. 더 확실히 하려면 운영 빌드에서 LabModule 을 AppModule 에서 빼는 방법도 있습니다.

14.6 WEB_ORIGINS 정리

WEB_ORIGINS 는 두 곳에서 쓰입니다. CORS 허용 목록(main.ts 의 enableCors)과 CSRF Origin 검사 허용 목록(csrfAllowedOrigins)입니다. 운영 값은 이렇게 됩니다.

# 운영 환경변수 (예)
WEB_ORIGINS=https://app.example.com
API_ORIGIN=https://api.example.com

점검할 것은 네 가지입니다.

14.7 리버스 프록시 뒤에서 — trust proxy 와 req.ip

운영의 API 서버는 대부분 로드 밸런서나 리버스 프록시 뒤에 있습니다. 브라우저의 TCP 연결은 프록시에서 끝나고, API 서버는 프록시가 새로 연 연결로 요청을 받습니다. 그러면 Express 의 req.ip 는 사용자의 IP 가 아니라 프록시의 IP 가 됩니다.

이 설계에서 req.ip 를 쓰는 곳은 두 곳입니다.

프록시는 원래 클라이언트의 IP 를 X-Forwarded-For 헤더에 적어 넘깁니다. Express 에 "이 헤더를 믿어도 된다" 고 알려 주는 설정이 trust proxy 입니다. NestJS 에서는 Express 앱 타입으로 만든 뒤 설정합니다.

// 운영용 변경의 예 — 부록 C 의 api/src/main.ts 에는 없다
import type { NestExpressApplication } from '@nestjs/platform-express';
// …
  const app = await NestFactory.create<NestExpressApplication>(AppModule);
  app.set('trust proxy', 1); // 앞단 프록시 한 단계만 믿는다

값 1 은 "X-Forwarded-For 의 오른쪽 끝에서 한 단계만 믿는다" 는 뜻입니다. 프록시가 두 단계(예: CDN → 로드 밸런서)라면 2 입니다. true 로 두면 모든 단계를 믿는데, X-Forwarded-For 는 클라이언트가 직접 써서 보낼 수 있는 헤더라, 공격자가 이 헤더에 아무 IP 나 적어 로그인 제한을 피하고 기록을 속일 수 있습니다. 프록시 구성에 맞는 정확한 숫자(또는 프록시 주소 목록)를 쓰세요. 설정 방식의 세부는 Express 공식 문서의 "behind proxies" 항목에서 확인하세요.

설정한 뒤에는 로그로 확인합니다. 로그인한 다음 DB 에서 방금 생긴 행의 IP 가 자기 IP 인지 봅니다.

SELECT ip, user_agent, created_at FROM refresh_tokens ORDER BY created_at DESC LIMIT 1;

14.8 로그인 제한을 여러 인스턴스에서

5장의 로그인 제한은 ThrottlerModule.forRoot([{ ttl: 60_000, limit: 5 }]) 입니다. 이 모듈은 기본적으로 횟수를 프로세스 메모리에 셉니다. API 서버가 한 대일 때는 문제가 없습니다.

서버가 세 대로 늘면 어떻게 될까요? 로드 밸런서가 요청을 세 대에 나눠 보내고, 각 서버는 자기가 받은 요청만 셉니다. 공격자는 1분에 5회가 아니라 최대 15회를 시도할 수 있습니다. 서버를 늘릴수록 제한이 느슨해집니다. 반대로 한 서버가 재시작되면 그 서버의 횟수가 0 으로 돌아갑니다.

해결은 횟수를 모든 인스턴스가 공유하는 저장소에 세는 것입니다. 보통 Redis 를 씁니다. @nestjs/throttler 는 저장소를 바꿔 끼울 수 있게 되어 있으니, 사용하는 버전의 공식 문서에서 storage 옵션과 Redis 저장소 구현을 확인하세요. 이 책의 LoginThrottlerGuard 는 에러 응답의 모양만 바꿨을 뿐 저장소에는 관여하지 않으므로, 저장소를 바꿔도 그대로 쓸 수 있습니다.

14.7절의 trust proxy 와 이 절은 한 쌍입니다. 둘 중 하나만 고치면 여전히 틀린 기준으로 세거나, 맞는 기준으로 따로따로 셉니다.

14.9 로그에 남기면 안 되는 것

로그는 대개 애플리케이션보다 허술하게 보관됩니다. 여러 사람이 보고, 외부 서비스로 보내지고, 오래 남습니다. 그래서 로그에 자격 증명이 한 번 찍히면, 그 자격 증명이 새 나간 것과 거의 같습니다.

찍히면 안 되는 것의 목록입니다.

무엇어디에 들어 있나
액세스 토큰요청 헤더 Authorization, 로그인·refresh 응답 본문
리프레시 토큰요청 헤더 Cookie, 응답 헤더 Set-Cookie
CSRF 토큰요청 헤더 X-CSRF-Token, Cookie, /auth/csrf 응답 본문
비밀번호가입·로그인 요청 본문의 password
비밀 값환경변수 전체를 덤프하는 시작 로그, 설정 오류 메시지

부록 C 의 코드는 요청을 로그로 남기지 않습니다. NestJS 의 기본 로거는 앱 시작과 라우트 등록 같은 것만 찍습니다. 문제는 운영하면서 나중에 붙이는 것들입니다. HTTP 요청 로깅 미들웨어, APM(성능 모니터링) 도구, 에러 추적 서비스, 리버스 프록시의 접근 로그는 설정에 따라 헤더와 본문을 통째로 기록합니다. 그런 도구를 붙일 때는 위의 헤더와 필드를 가리는(redact) 설정을 먼저 하고, 실제로 로그를 한 번 찍어 가려졌는지 눈으로 확인하세요.

두 가지를 더 적어 둡니다.

14.10 REFRESH_TOKEN_REUSED 를 경보로 받는다

7장의 재사용 탐지는 서버가 "이미 회전된 리프레시 토큰이 grace 밖에서 다시 왔다" 를 감지하고 family 를 끊는 장치입니다. 운영에서 이 사건은 경보로 받아야 합니다.

12장을 거치며 이 신호의 품질이 달라졌습니다. 탭 간 잠금이 없던 시절에는 "탭 두 개를 열어 둔 평범한 사용자" 가 이 신호를 만들 수 있었습니다. 잠금을 넣은 뒤로는 정상적인 브라우저 사용에서 이 신호가 날 일이 거의 없습니다. 남는 원인은 다음과 같습니다.

두 번째와 세 번째는 드물고, 첫 번째는 반드시 알아야 합니다. 그래서 경보로 받습니다.

어떻게 받나

부록 C 의 코드는 재사용 탐지 시점에 로그를 남기지 않습니다(AuthService.refresh 가 reused 결과를 에러로 바꿔 던질 뿐입니다). 운영에서는 두 가지 중 하나를 씁니다.

방법 1 — 탐지 지점에서 구조화된 로그를 남긴다. reused 를 처리하는 곳에서 사용자 id, family id, 요청 IP, User-Agent 를 한 줄의 구조화된 로그(JSON 등)로 남기고, 로그 수집 도구에서 그 이벤트에 경보를 겁니다. 토큰 원문은 남기지 않습니다(14.9절).

방법 2 — DB 를 주기적으로 조회한다. 코드를 바꾸지 않고도 됩니다. 재사용 탐지는 family 의 살아 있던 행에 revoke_reason = 'reuse_detected' 와 그 시각을 남기므로, 최근 몇 분 동안 끊긴 family 수를 셀 수 있습니다.

-- 최근 5분 동안 재사용 탐지로 끊긴 세션 수
SELECT count(DISTINCT family_id) AS reused_sessions
  FROM refresh_tokens
 WHERE revoke_reason = 'reuse_detected'
   AND revoked_at > now() - interval '5 minutes';

이 값이 0 보다 크면 경보를 보냅니다. 경보를 받은 뒤 조사할 때는 해당 family 의 행들을 시간순으로 봅니다. 7장의 확인 쿼리에 ip, user_agent 를 더하면, 회전 사슬 중 어디서 IP 나 브라우저가 바뀌었는지 보입니다. 14.7절의 trust proxy 가 제대로 설정돼 있어야 이 조사가 의미를 가집니다.

14.11 폐기 행 청소

refresh_tokens 테이블은 가만두면 계속 커집니다. refresh 한 번마다 행이 하나 생기고, 옛 행은 지워지지 않고 rotated 로 남습니다. 액세스 토큰이 10분이니, 하루 종일 앱을 쓰는 사용자 한 명이 하루에 행을 수십 개 만듭니다.

그렇다고 폐기된 행을 바로 지우면 안 됩니다. 폐기된 행이 재사용 탐지의 근거이기 때문입니다. rotated 행을 지워 버리면, 그 옛 토큰이 다시 왔을 때 서버는 "모르는 토큰" 으로 보고 401 REFRESH_TOKEN_INVALID 를 줄 뿐, 탈취를 탐지하고 family 를 끊지 못합니다.

언제 지워도 되는지는 세션 상한이 알려 줍니다. 7장에서 family 전체의 절대 만료 시각을 session_expires_at 에 두었고, 회전해도 이 값은 늘지 않습니다. 이 시각이 지나면 그 family 의 어떤 토큰도 다시 유효해질 수 없으니, 탐지할 필요도 없습니다. 여기에 사후 조사를 위한 여유를 며칠 둡니다.

-- 세션 상한이 7일 넘게 지난 family 의 행을 지운다
DELETE FROM refresh_tokens
 WHERE session_expires_at < now() - interval '7 days';

이 쿼리가 안전한 이유를 짚어 둡니다.

운영에서는 이 쿼리를 주기적으로 돌립니다. 하루 한 번이면 충분합니다. 돌리는 방법은 환경에 따라 고릅니다. 배포 플랫폼의 예약 작업, 서버의 cron, PostgreSQL 확장(pg_cron 등) 모두 됩니다. 두 가지를 더 고려하세요.

14.12 JWT 키 교체

비밀은 언젠가 바꿔야 합니다. 정기 교체 정책 때문일 수도 있고, 새 나갔다는 의심 때문일 수도 있습니다. 14.4절에서 JWT_ACCESS_SECRET 을 바꾸면 모든 액세스 토큰이 401 이 되고 FE 가 refresh 로 수습한다고 했습니다. 서버가 한 대라면 이것으로 충분합니다. 교체는 재시작 한 번이고, 사용자는 요청 하나가 조금 늦어지는 것 말고는 느끼지 못합니다. 10분짜리 짧은 토큰과 401 자동 재시도(12장)가 만든 여유입니다.

서버가 여러 대일 때의 문제

서버가 여러 대이고 새 설정이 하나씩 퍼져 나가는(롤링 배포) 몇 분 동안은 사정이 다릅니다.

 브라우저                      서버 1 (새 비밀)                서버 2 (옛 비밀)
 ────────                      ────────────────                ────────────────
 GET /users/me (옛 토큰) ────▶ 401 (서명 불일치)
 POST /auth/refresh ─────────▶ 200, 새 비밀로 서명한 토큰
 GET /users/me (새 토큰) ─────────────────────────────────────▶ 401 (서명 불일치)
                                 authFetch 는 재시도를 한 번만 하므로 이 401 이 화면까지 간다

요청이 어느 서버로 가느냐에 따라 토큰이 맞았다 틀렸다 합니다. 사용자는 로그아웃되지는 않지만(리프레시는 멀쩡하니까요) 요청이 간헐적으로 실패하는 것을 봅니다.

kid 와 여러 키 동시 검증

이 문제를 없애려면 검증하는 쪽이 옛 키와 새 키를 동시에 받게 해야 합니다. 그러려면 토큰이 "나는 어느 키로 서명됐다" 를 말해야 합니다. JWT 헤더의 kid(key id) 필드가 그 자리입니다. 교체 절차는 세 단계입니다.

// 설명용 절차 — 부록 C 에 없는 운영용 변경
1단계: 모든 서버에 새 키 k2 를 "검증용" 으로 배포한다.  서명은 여전히 k1.  검증: { k1, k2 }
2단계: 모든 서버가 1단계를 마친 뒤, 서명 키를 k2 로 바꾼다.        서명: k2.  검증: { k1, k2 }
3단계: 액세스 토큰 수명(10분) 이상 기다린 뒤 k1 을 뺀다.            서명: k2.  검증: { k2 }

1단계가 끝날 때까지 새 키로 서명하지 않으니, 어떤 서버도 모르는 키로 서명된 토큰이 돌아다니지 않습니다. 2단계 뒤에는 옛 토큰과 새 토큰이 섞여 있지만 모든 서버가 둘 다 검증합니다. 3단계에서 옛 키를 빼는 시점에는 옛 키로 서명된 토큰이 모두 만료됐습니다. 사용자는 아무것도 느끼지 못합니다.

코드로는 서명할 때 헤더에 kid 를 넣고, 검증할 때 토큰 헤더의 kid 로 키 목록에서 비밀을 고르는 모양이 됩니다. @nestjs/jwt 와 그 밑의 라이브러리가 이를 지원하는 방식은 버전에 따라 다르니 공식 문서에서 확인하세요. 한 가지는 지켜야 합니다. kid 는 키를 고르는 데만 쓰고, 알고리즘은 5장에서 했듯 서버가 고정합니다(algorithms: ['HS256']). 토큰 헤더가 말하는 alg 를 믿지 않는 원칙은 키가 여러 개가 되어도 그대로입니다.

RS256/EdDSA 와 JWKS 로 옮기는 시점

지금의 HS256 은 대칭 키입니다. 서명하는 쪽과 검증하는 쪽이 같은 비밀을 씁니다. 검증하는 서버가 API 하나뿐일 때는 이것이 가장 단순하고 빠릅니다.

검증하는 주체가 늘어나면 이야기가 달라집니다. 두 번째 API 서비스가 생기거나, API 게이트웨이가 토큰을 먼저 검증하게 되거나, 다른 팀의 서비스가 우리 토큰을 받게 되면, 그 모두에게 JWT_ACCESS_SECRET 을 나눠 줘야 합니다. 대칭 키를 가진 쪽은 검증만이 아니라 서명도 할 수 있습니다. 검증하려고 비밀을 받은 서비스 하나가 뚫리면, 공격자는 아무 사용자의 토큰이나 만들 수 있습니다.

이때가 비대칭 서명(RS256, EdDSA 등) 으로 옮길 시점입니다. 토큰을 발급하는 인증 서버만 개인 키를 갖고, 검증하는 쪽은 공개 키만 받습니다. 공개 키로는 서명을 확인할 수만 있고 만들 수는 없습니다. 공개 키는 보통 JWKS(JSON Web Key Set)라는 형식으로 잘 알려진 URL 에 공개하고, 검증하는 쪽은 토큰의 kid 로 그 집합에서 알맞은 키를 골라 씁니다. 위의 kid 교체 절차가 JWKS 에 키를 더하고 빼는 일로 자연스럽게 옮겨집니다.

정리하면, 검증 주체가 하나면 HS256, 둘 이상이 되면 비대칭 서명 + JWKS 입니다. 옮길 때도 5장의 원칙, 서버가 허용할 알고리즘을 고정한다는 것은 그대로 지킵니다.

14.13 __Host- 접두사 — 이 설계가 택하지 않은 것

쿠키 이름이 __Host- 로 시작하면 브라우저가 그 쿠키에 세 가지를 강제합니다. Secure 가 있어야 하고, Path=/ 여야 하고, Domain 이 없어야 합니다. 셋 중 하나라도 어기면 브라우저가 쿠키를 저장하지 않습니다.

이 접두사가 막아 주는 것은 쿠키 주입입니다. 9장에서 서명된 CSRF 토큰을 설명하며, 같은 사이트의 다른 서브도메인이 상위 도메인용 쿠키를 심어 우리 쿠키를 덮어쓰거나 끼워 넣을 수 있다고 했습니다. __Host- 쿠키는 Domain 을 가질 수 없으니, 다른 서브도메인이 이 이름의 쿠키를 우리 호스트용으로 심을 방법이 없습니다.

그러면 리프레시 쿠키도 __Host-refresh_token 으로 바꾸면 좋지 않을까요? 여기에 트레이드오프가 있습니다. __Host- 는 Path=/ 를 강제하는데, 이 설계는 리프레시 쿠키를 Path=/auth 로 좁혀 두었습니다(6장). 이 경로 제한 덕분에 리프레시 토큰은 /auth/* 요청에만 실리고, /users/me 같은 수많은 일반 API 요청에는 실리지 않습니다. 리프레시 토큰이 지나가는 요청이 적을수록, 그 요청을 로그로 남기거나 가로챌 기회도 줄어듭니다.

refresh_token + Path=/auth (이 설계)__Host-refresh_token + Path=/
쿠키 주입다른 서브도메인이 이론상 끼워 넣을 수 있다막힌다
리프레시 토큰이 실리는 요청/auth/* 뿐API 의 모든 요청
로그·프록시 노출적다많다

이 설계는 경로 제한을 택했습니다. 리프레시 토큰이 매 요청마다 오가지 않는 이점이 더 크다고 봤습니다. 구성 A 에서 Domain 을 붙이지 않았으니 주입의 위험은 같은 사이트의 서브도메인이 뚫린 경우로 좁혀지고, 설령 가짜 리프레시 쿠키가 끼워져도 서버는 DB 에 없는 토큰을 401 로 거절할 뿐입니다.

반면 CSRF 쿠키는 __Host-csrf_token 으로 바꿔도 잃을 것이 없습니다. 이미 Path=/ 이고, Domain 이 없고, Secure 입니다. 이름만 바꾸면 쿠키 주입 방어가 한 겹 더해집니다. 바꾼다면 cookies.ts 의 CSRF_COOKIE 상수 하나를 고치면 되지만, 이것은 부록 C 에 반영하지 않은 선택입니다. __Host- 쿠키는 Secure 가 필수이므로 COOKIE_SECURE=true 가 전제입니다.

14.14 액세스 토큰을 즉시 끊어야 할 때 — sid 조회

7장과 12장에서 여러 번 짚었듯, 이 설계에서 폐기는 리프레시 토큰에만 즉시 반영됩니다. 로그아웃, 모든 기기에서 로그아웃, 세션 끊기, 재사용 탐지, 무엇으로 family 를 끊든 이미 발급된 액세스 토큰은 exp 까지(최대 10분) 계속 통과합니다. JwtAuthGuard 는 서명·만료·iss·aud 만 보고 DB 를 조회하지 않기 때문입니다. 이것이 JWT 를 쓰는 이유이기도 합니다. 요청마다 DB 를 보지 않아도 되니까요.

대부분의 서비스에서 10분의 시차는 받아들일 만합니다. 하지만 받아들일 수 없는 경우가 있습니다. 계정을 정지했는데 10분 동안 계속 활동할 수 있다면 곤란합니다. 재사용 탐지로 탈취를 감지해 family 를 끊었는데, 공격자가 이미 받은 액세스 토큰으로 10분간 데이터를 가져갈 수 있다면 그것도 곤란합니다.

그럴 때는 가드에서 토큰의 sid 가 아직 살아 있는지 DB 를 조회합니다. 액세스 토큰의 sid 는 family id 이므로(5장), 그 family 에 폐기되지 않은 유효한 행이 있는지 보면 됩니다.

-- 운영용 변경의 예 — 부록 C 의 JwtAuthGuard 는 이 조회를 하지 않는다
SELECT 1
  FROM refresh_tokens
 WHERE family_id = $1
   AND revoked_at IS NULL
   AND expires_at > now()
 LIMIT 1;

행이 없으면 서명이 맞아도 401 ACCESS_TOKEN_INVALID 로 거절합니다. family_id 에는 7장에서 만든 인덱스가 있어 조회 자체는 빠릅니다.

비용은 분명합니다. 보호된 모든 요청마다 쿼리가 하나 늘어납니다. 요청이 많은 서비스라면 DB 부하가 눈에 띄게 늘고, DB 가 느려지면 모든 API 가 느려집니다. JWT 의 "상태 없는 검증" 이라는 장점을 일부 포기하는 셈입니다. 조회 결과를 몇 초 캐시하면 부하는 줄지만, 그만큼 시차가 다시 생깁니다. 그래서 요구가 있을 때만 넣습니다. 계정 정지처럼 사용자 단위로 끊어야 한다면 users 테이블에 정지 여부를 두고 같은 방식으로 조회해야 하는데, 이 책의 스키마에는 그 열이 없습니다.

14.15 운영 체크리스트

이 장의 판단들을 모았습니다. 배포 전에 한 줄씩 확인하고 표시하세요.

도메인과 쿠키

전송

비밀

열려 있으면 안 되는 것

출처

프록시와 여러 인스턴스

로그와 경보

주기 작업과 계획

계약

14.16 이 책이 다루지 않은 것과 다음 공부

머리말에서 이 책이 다루지 않는다고 적은 것들이 있습니다. 그 주제들이 이 책의 설계와 어떻게 이어지는지 적어 두고 책을 마칩니다.

OAuth 2.0 과 OpenID Connect

"구글로 로그인" 같은 소셜 로그인, 그리고 여러 서비스가 하나의 인증 서버를 쓰는 구성은 OAuth 2.0 과 그 위에 신원 정보를 얹은 OpenID Connect(OIDC) 의 영역입니다. 처음 보면 용어가 많아 어렵게 느껴지지만, 이 책을 끝낸 독자에게는 익숙한 것이 많습니다.

다음 공부로는 OAuth 2.0 의 Authorization Code 흐름과 PKCE, 그리고 브라우저 앱을 위한 보안 권고를 권합니다. 요즘 권고에는 토큰을 브라우저에 두지 않고 서버(BFF, Backend for Frontend)가 대신 들고 있는 구성이 자주 나옵니다. 그 구성은 인증 수단이 쿠키로 옮겨 가는 것이므로, 9장의 CSRF 대상이 모든 요청으로 넓어진다는 점을 기억하고 읽으세요.

다중 인증(MFA)

비밀번호 하나로는 부족하다고 판단되면 두 번째 요소를 더합니다. 인증 앱의 6자리 숫자(TOTP), 보안 키, 문자 메시지 등입니다. 이 책의 설계에 MFA 를 더한다면 바뀌는 곳은 login 한 곳입니다. 비밀번호가 맞아도 바로 토큰을 발급하지 않고, 두 번째 요소를 확인한 뒤에야 5·6장의 발급(리프레시 family 생성과 액세스 토큰 서명)으로 넘어갑니다. 발급 이후의 회전, 재사용 탐지, CSRF, FE 의 세션 관리는 그대로입니다. 공부할 것은 TOTP 의 원리, 복구 코드, 그리고 "두 번째 단계를 기다리는 중" 이라는 중간 상태를 어떻게 안전하게 들고 있을지입니다.

패스키

패스키(WebAuthn)는 비밀번호 대신 기기에 저장된 키 쌍으로 로그인하는 방식입니다. 서버는 공개 키만 갖고, 개인 키는 사용자의 기기를 떠나지 않습니다. 비밀번호가 없으니 비밀번호 유출이 없고, 로그인 요청이 출처에 묶여 있어서 가짜 사이트로 유도하는 피싱에 강합니다. 4장의 argon2 해싱과 5장의 로그인 제한이 막으려던 위협의 상당 부분이 구조적으로 사라집니다.

패스키도 MFA 와 마찬가지로 "누구인지 확인하는" 단계를 바꿉니다. 확인이 끝난 뒤 토큰을 발급하고 유지하는 일은 여전히 이 책의 설계가 맡습니다. 그래서 이 책에서 배운 것은 로그인 방식이 바뀌어도 쓸모가 줄지 않습니다.

확인하기

운영 환경(또는 운영과 같은 설정의 스테이징)에 배포한 뒤, 외부에서 다음을 확인합니다. 아래의 api.example.com, app.example.com 은 여러분의 주소로 바꿉니다.

  1. HSTS·캐시 헤더

    $ curl -sI https://api.example.com/auth/csrf | grep -i 'strict-transport-security\|cache-control'
    

    예시 출력:

    strict-transport-security: max-age=31536000; includeSubDomains
    cache-control: no-store
    
  2. 쿠키 속성 — 같은 요청의 set-cookie 에 Secure, HttpOnly, SameSite=Strict 가 있고 Domain= 이 없습니다.

    $ curl -sI https://api.example.com/auth/csrf | grep -i set-cookie
    
  3. 닫혀 있어야 할 것 — 세 요청 모두 404 입니다.

    $ curl -s -o /dev/null -w '%{http_code}\n' https://api.example.com/docs
    $ curl -s -o /dev/null -w '%{http_code}\n' https://api.example.com/docs-json
    $ curl -s -o /dev/null -w '%{http_code}\n' https://api.example.com/lab/settings
    
  4. CORS — 허용되지 않은 출처의 사전 요청에 Access-Control-Allow-Origin 이 없습니다. localhost 도 마찬가지입니다.

    $ curl -si -X OPTIONS https://api.example.com/auth/refresh \
        -H 'Origin: http://localhost:5173' \
        -H 'Access-Control-Request-Method: POST' | grep -i access-control-allow-origin
    

    기대: 출력 없음. -H 'Origin: https://app.example.com' 으로 바꾸면 access-control-allow-origin: https://app.example.com 이 나옵니다.

  5. 클라이언트 IP — 운영 FE 에서 로그인한 뒤 SELECT ip FROM refresh_tokens ORDER BY created_at DESC LIMIT 1; 이 여러분의 공인 IP 입니다.

  6. 경보와 청소 — 14.10절의 조회와 14.11절의 청소가 예약돼 있고, 마지막 실행 기록이 있습니다.

자주 하는 실수

운영에서 로그인은 되는데 새로고침하면 로그아웃된다 FE 와 API 가 다른 사이트입니다(14.1절 구성 C). 공개 접미사 목록에 오른 호스팅 도메인을 나눠 쓰고 있지 않은지도 확인하세요. 브라우저 개발자 도구에서 refresh 요청에 쿠키가 실렸는지 봅니다.

운영에서 쿠키가 아예 저장되지 않는다 HTTP 로 서비스하고 있거나, TLS 를 끝낸 프록시 뒤에서 브라우저에 HTTPS 가 아닌 주소가 노출됐습니다. COOKIE_SECURE=false 로 바꾸지 말고 HTTPS 를 고칩니다.

모든 요청이 CORS 오류다 / 로그인이 CSRF_ORIGIN_REJECTED 다 WEB_ORIGINS 가 FE 의 실제 출처와 글자 하나까지 같은지 봅니다. 끝의 /, http/https, www 유무가 흔한 원인입니다.

어느 날부터 모든 사용자의 로그인이 가끔 429 다 프록시 뒤에서 trust proxy 가 없어 모든 사용자가 같은 IP 로 보입니다. 한 사람의 틀린 시도가 모두를 막습니다.

trust proxy 를 true 로 두었더니 로그인 제한이 듣지 않는다 공격자가 X-Forwarded-For 에 매번 다른 IP 를 적어 보내고 있습니다. 프록시 단계 수에 맞는 숫자로 설정합니다.

서버를 늘렸더니 로그인 제한이 느슨해졌다 로그인 제한의 횟수를 프로세스 메모리에 세고 있습니다. 공유 저장소로 옮깁니다.

청소 작업을 넣은 뒤 재사용 탐지가 안 된다 revoked_at IS NOT NULL 인 행을 바로 지우고 있습니다. 폐기된 행은 탐지의 근거입니다. session_expires_at 기준으로 지웁니다.

키를 바꾼 직후 몇 분간 요청이 간헐적으로 401 이다 여러 서버에 새 비밀이 퍼지는 동안 서버마다 다른 키로 검증하고 있습니다. kid 와 여러 키 동시 검증으로 3단계 교체를 합니다.

정리

연습문제

1. 팀이 FE 를 shop.example.com 에, API 를 shop-api.example.com 에 두려고 합니다. 이 구성은 14.1절의 A·B·C 중 무엇이고, 이 책의 쿠키 설정을 바꿔야 할까요?

해답

구성 A 입니다. 두 호스트 모두 등록 가능한 도메인이 example.com 이라 같은 사이트이고, 호스트가 다르니 출처는 다릅니다. 서브도메인 이름이 api. 가 아니어도 상관없습니다. 쿠키 설정은 바꿀 필요가 없습니다. SameSite=Strict, Domain 없음, Path=/auth 그대로 동작합니다. 바꿀 것은 설정값뿐입니다. WEB_ORIGINS=https://shop.example.com, API_ORIGIN=https://shop-api.example.com, FE 의 VITE_API_URL=https://shop-api.example.com.

2. 동료가 "청소 작업을 가볍게 하려고 DELETE FROM refresh_tokens WHERE revoked_at < now() - interval '1 day' 로 하자" 고 제안합니다. 이 쿼리가 재사용 탐지를 어떻게 약하게 만드는지, 그리고 세션 목록 화면에는 어떤 영향을 주는지 설명해 보세요.

해답

이 쿼리는 폐기된 지 하루가 지난 rotated 행을 지웁니다. 그러면 탈취된 리프레시 토큰이 하루 넘게 지난 뒤 쓰였을 때, 서버는 그 토큰을 DB 에서 찾지 못해 REFRESH_TOKEN_INVALID 만 돌려줍니다. 재사용 탐지가 일어나지 않으니 family 가 끊기지 않고 경보도 울리지 않습니다. 공격자가 훔친 토큰을 며칠 묵혀 쓰는 경우를 탐지하지 못하게 됩니다.

세션 목록 쪽에서는, 이 조건이 살아 있는 family 의 일부 행만 지운다는 것이 문제입니다. 회전 사슬의 앞쪽(폐기된 행)은 지워지고 끝의 살아 있는 행은 남습니다. 7장의 세션 목록 쿼리는 family 의 min(created_at) 을 세션 시작 시각으로 쓰므로, 앞쪽 행이 지워지면 "로그인한 시각" 이 하루 전쯤으로 계속 밀려납니다. 한 달 전에 로그인한 기기가 어제 로그인한 것처럼 보입니다. (외래 키는 문제가 되지 않습니다. replaced_by 는 옛 행이 새 행을 가리키고, 사슬을 따라갈수록 revoked_at 이 늦어지므로, 어떤 행이 조건에 걸리면 그 행을 가리키는 더 옛 행도 함께 걸립니다.) session_expires_at 기준으로 family 째 지우면 탐지 근거도, 세션 목록의 시각도 온전히 남습니다.

3. 서버가 한 대인 서비스에서 JWT_ACCESS_SECRET 이 새 나갔다는 의심이 생겼습니다. 지금 당장 해야 할 일을 순서대로 적고, 그 순간 사용자에게 어떤 일이 일어나는지 설명해 보세요.

해답
  1. 새 난수를 만들어 비밀 저장소의 JWT_ACCESS_SECRET 을 바꾸고 API 를 재시작합니다. 서버가 한 대이니 kid 절차 없이 바로 바꿔도 됩니다. 2) 옛 비밀로 서명된 토큰(공격자가 만든 것 포함)은 재시작 즉시 모두 401 이 됩니다. 3) 공격자가 위조 토큰으로 무엇을 했는지 조사합니다. 위조 토큰은 sid 가 실제 family 와 맞지 않을 수 있고, 접근 기록이 도움이 됩니다.

사용자 입장에서는, 다음 API 요청이 401 을 받고 12장의 authFetch 가 refresh 로 새 토큰을 받아 재시도하므로 대부분 아무것도 느끼지 못합니다. 리프레시 토큰은 JWT 가 아니라 DB 의 난수라 이 비밀과 무관하기 때문입니다. 짧은 순간 refresh 요청이 몰리는 것이 전부입니다. 만약 리프레시 토큰까지 탈취가 의심되면, 그것은 별도로 사용자별 logout-all 과 같은 폐기가 필요합니다.

4. 두 번째 백엔드 서비스(예: 결제 API)가 생겨서 우리 액세스 토큰을 검증해야 합니다. HS256 을 유지한 채 그 서비스에 JWT_ACCESS_SECRET 을 나눠 주는 방법의 위험은 무엇이고, 어떤 구성으로 옮겨야 할까요?

해답

HS256 은 대칭 키라서 비밀을 가진 쪽은 검증뿐 아니라 서명도 할 수 있습니다. 결제 서비스가 뚫리거나 그 서비스의 설정이 새 나가면, 공격자는 아무 사용자의 액세스 토큰을 만들어 원래 API 까지 마음대로 부를 수 있습니다. 비밀을 가진 곳이 늘수록 새 나갈 경로도 늘어납니다.

RS256 이나 EdDSA 같은 비대칭 서명으로 옮깁니다. 토큰을 발급하는 인증 API 만 개인 키를 갖고, 결제 서비스는 JWKS 로 공개된 공개 키로 검증만 합니다. 토큰 헤더에 kid 를 넣어 키를 고르게 하면, 키 교체도 JWKS 에 새 키를 더하고 옛 키를 빼는 일로 무중단으로 할 수 있습니다. 두 서비스 모두 허용 알고리즘을 고정(algorithms: ['RS256'] 등)하는 원칙은 그대로 지킵니다.

5. 14.15절 체크리스트의 항목 중 하나를 골라, "이 항목을 빠뜨렸을 때 13장의 시나리오 중 어느 것이 운영에서 다른 결과를 낼까" 를 설명해 보세요. (예시 답을 하나 적어 둡니다. 다른 항목으로도 생각해 보세요.)

해답

예시 — trust proxy 를 빠뜨린 경우와 S19(로그인 제한). 실습 환경에서 S19 는 "틀린 로그인 6회 → 6번째 429" 였고, 요청하는 사람이 한 명이니 그 사람만 막혔습니다. 운영에서 프록시 뒤에 trust proxy 없이 배포하면 모든 요청이 프록시 IP 로 보이므로, 서로 다른 사용자들의 틀린 로그인이 하나의 횟수로 합쳐집니다. S19 를 운영에서 돌리면 시나리오를 돌린 사람뿐 아니라 그 1분 동안 로그인하려던 모든 사용자가 429 를 받습니다. 시나리오의 기대 결과("6번째 429")는 똑같이 나오지만 그 의미가 전혀 달라진다는 점이 핵심입니다.

다른 예로, LAB_MODE=false 를 빠뜨리면 9장의 공격 페이지가 운영 사용자에게도 통하는 실제 취약점이 되고, WEB_ORIGINS 에 localhost 를 남기면 S18(CORS)을 Origin: http://localhost:5173 으로 돌렸을 때 허용 헤더가 나와 버립니다.

맺음말

이 책은 머리말의 질문 네 개로 시작했습니다. 액세스 토큰은 어디에 두어야 하는가, 리프레시 토큰을 탈취당하면 어떻게 되는가, 탭 두 개에서 왜 로그아웃되는가, SameSite 만 켜면 되지 않는가.

이제 각각에 우리가 만든 코드로 답할 수 있습니다. 액세스 토큰은 모듈 변수에 두고 새로고침은 조용한 갱신으로 되살립니다(11장). 탈취된 리프레시 토큰은 회전 사슬에서 한 번 더 쓰이는 순간 드러나고, 그 세션 전체가 끊깁니다(7장). 탭 두 개의 로그아웃은 쿠키는 공유되는데 메모리는 공유되지 않아서 생기고, 탭 간 잠금이 그것을 줄 세웁니다(12장). SameSite 는 같은 사이트의 다른 출처를 막지 못하고, 그래서 Origin 검사와 서명된 CSRF 토큰이 함께 있어야 합니다(8·9장).

이 답들에는 공통점이 있습니다. 어느 것도 한 줄짜리 설정이 아니었습니다. 브라우저가 쿠키를 언제 싣는지, 서버가 두 요청을 어떤 순서로 처리하는지, 공격자가 무엇을 볼 수 있고 무엇을 볼 수 없는지를 하나씩 따져서 나온 답이었습니다. 그리고 그 따짐이 맞았는지를 매번 직접 공격해 보고, 시간순으로 그려 보고, 시나리오로 돌려 확인했습니다.

인증 라이브러리나 매니지드 서비스를 쓰게 되더라도 이 과정은 그대로 쓸모가 있습니다. 문서에서 "리프레시 토큰을 회전합니다" 라는 한 줄을 읽으면, 그 뒤에 FOR UPDATE 와 grace 와 탭 간 잠금이 있어야 한다는 것을 압니다. "CSRF 를 방어합니다" 라는 한 줄을 읽으면, 어느 요청이 대상이고 어느 겹이 무엇을 막는지 물을 수 있습니다. 이 책이 드리고 싶었던 것은 코드 자체보다 그 질문들입니다.

끝까지 따라와 주셔서 고맙습니다.