aster®

[로그인인증] 토큰을 설계하다 (02)

마무리 · 2026-09

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

이 장을 마치면

  • 불투명 토큰과 자기 완결 토큰을 구분하고, 이 책이 액세스 토큰과 리프레시 토큰에 각각 어느 쪽을 쓰는지 이유와 함께 말할 수 있습니다.
  • Node.js 내장 crypto 만으로 HS256 JWT 를 손으로 서명하고, 디코드하고, 검증하고, 변조와 alg: none 공격이 실패하는 것을 확인할 수 있습니다.
  • 등록 클레임(iss·sub·aud·exp·iat·jti)과 이 책의 sid 가 각각 무엇을 막거나 가능하게 하는지 설명할 수 있습니다.
  • 액세스·리프레시를 나누는 이유와 토큰 저장 위치의 장단점을 표로 비교할 수 있습니다.
  • 회전·재사용 탐지·grace·세션 절대 상한이 함께 어떻게 동작하는지 시간순으로 그릴 수 있습니다.

들어가며

1장에서 인증이란 결국 "요청 사이에서 신원을 기억하는 일" 이고, 그 기억을 서버 장부에 두면 세션 방식, 서명된 증명서로 들고 다니면 토큰 방식이라는 것을 보았습니다. 그리고 이 책은 둘을 섞는다고 했습니다. 매 요청에는 짧은 수명의 자기 완결 토큰(액세스 토큰)을 쓰고, 그 토큰을 새로 받을 때만 서버 장부에 기록된 불투명 토큰(리프레시 토큰)을 씁니다.

이 장은 그 설계를 하나씩 정당화합니다. 먼저 JWT 를 라이브러리 없이 손으로 만들어 봅니다. JWT 는 겉보기에 암호문처럼 생겼지만 실제로는 누구나 읽을 수 있는 JSON 에 서명 하나를 붙인 것입니다. 이것을 직접 확인해야 "JWT 에 무엇을 넣으면 안 되는지", "검증할 때 무엇을 고정해야 하는지" 를 외우지 않고 이해할 수 있습니다.

그다음 토큰을 둘로 나누는 이유, 브라우저 어디에 보관할지, 리프레시 토큰을 탈취당했을 때 어떻게 알아챌지를 차례로 다룹니다. 이 장의 끝에는 책 전체의 설계 표가 있습니다. 3장부터 14장까지의 코드는 모두 이 표의 한 줄을 구현하는 일입니다.

이 장도 원리만 다룹니다. auth-lab 에 JWT 발급 코드를 넣는 것은 5장, 리프레시 토큰은 6장과 7장입니다.

2.1 토큰의 두 종류

"토큰" 은 넓은 말입니다. 이 책에서 토큰은 요청에 실어 보내 신원이나 권한을 증명하는 문자열을 뜻하고, 크게 두 종류가 있습니다.

불투명(opaque) 토큰은 그 자체로는 아무 의미가 없는 난수입니다. Q0y1ECht0PXyQdyZO-UVWmu-S9fLgz8oRw8BD6eHypg 같은 문자열을 받은 서버는 자기 저장소에서 이 값을 찾아봐야 누구의 토큰인지, 아직 유효한지 알 수 있습니다. 1장의 세션 ID 가 불투명 토큰입니다.

자기 완결(self-contained) 토큰은 필요한 정보를 스스로 담고 있고, 그 정보가 위조되지 않았다는 증거(서명)도 함께 갖고 있습니다. 서버는 저장소를 보지 않고 서명만 확인하면 됩니다. JWT 가 대표적입니다.

관점불투명 토큰자기 완결 토큰 (JWT)
내용없음 (난수)사용자·만료 시각 등이 들어 있음
검증 방법저장소 조회서명 검증 (계산만)
폐기저장소에서 지우면 즉시exp 까지는 유효
크기작다 (이 책: 43자)크다 (이 책: 약 360자)
유출되었을 때 드러나는 것없음내용 전체 (서명은 암호화가 아니므로)
서버 저장필요불필요
이 책에서리프레시 토큰액세스 토큰

두 성질이 정확히 뒤집혀 있다는 점이 중요합니다. 불투명 토큰은 폐기가 쉽고 대신 매번 저장소를 봐야 하고, 자기 완결 토큰은 저장소가 필요 없는 대신 폐기가 어렵습니다. 그래서 "자주 쓰는 쪽" 에 자기 완결을, "드물게 쓰지만 반드시 끊을 수 있어야 하는 쪽" 에 불투명을 배정합니다. 2.8절에서 이 배정을 수명·노출 빈도와 함께 다시 따집니다.

이 책의 리프레시 토큰은 32바이트 난수를 base64url 로 인코딩한 43자 문자열입니다. 부록 C 의 TokenService.newRefreshToken() 이 randomBytes(32).toString('base64url') 로 만듭니다. 32바이트는 256비트이므로 무작위로 맞힐 확률은 사실상 0 입니다.

2.2 JWT 해부

JWT(JSON Web Token, RFC 7519)는 점(.)으로 나뉜 세 부분입니다.

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9 . eyJzdWIiOiJ1c2VyLTEiLCJzaWQiOi… . wdWNl2JuZa0D8Y-jTv1YR9Ngtkf_46GfrllytYMqkDw
└──────────── header ─────────────┘   └─────────── payload ──────────┘   └──────────────── signature ─────────────────┘
   {"alg":"HS256","typ":"JWT"}           {"sub":"user-1","sid":…}            HMAC-SHA256(key, header + "." + payload)

세 부분 모두 base64url 로 인코딩되어 있습니다. base64url 은 이진 데이터를 글자로 바꾸는 base64 의 변형으로, URL 과 헤더에 그대로 넣을 수 있게 두 가지를 바꿨습니다.

base64base64url
62번째 문자+-
63번째 문자/_
끝의 채움 문자 =붙인다뗀다

+, /, = 는 URL 이나 쿠키에서 특별한 뜻을 갖기 때문입니다. 이 책의 리프레시 토큰, CSRF 토큰, JWT 가 모두 base64url 을 쓰는 이유도 같습니다. 부록 C 의 web/src/auth/jwt.ts 가 atob 앞에서 - 를 + 로, _ 를 / 로 되돌리는 것도 브라우저의 atob 가 표준 base64 만 받기 때문입니다.

⚠️ base64url 은 암호화가 아닙니다. 누구든 되돌릴 수 있는 인코딩일 뿐입니다. JWT 의 payload 는 토큰을 손에 넣은 모든 사람이 읽을 수 있습니다. 2.7절에서 다시 강조합니다.

서명이 하는 일은 하나입니다. header 나 payload 가 한 글자라도 바뀌면 서명이 맞지 않게 만드는 것입니다. 서명을 새로 계산하려면 비밀 키가 필요하므로, 키가 없는 사람은 내용을 바꾼 뒤 맞는 서명을 붙일 수 없습니다. 서버는 받은 header 와 payload 로 서명을 다시 계산해 토큰의 서명과 같은지만 보면 됩니다.

2.3 실습: node 로 JWT 를 손으로 만들기

라이브러리 없이 Node.js 내장 crypto 만으로 JWT 를 만들고, 읽고, 검증합니다. 패키지는 설치하지 않습니다. 아래 명령과 출력은 Node.js v24.19.0 에서 실제로 실행한 결과입니다. 머리말의 준비물 표에 적은 버전이면 같은 결과가 나옵니다.

⚠️ 여기서 만드는 코드는 JWT 의 구조를 이해하기 위한 실험입니다. 실제 앱에서는 검증된 라이브러리(이 책은 @nestjs/jwt)를 씁니다. 5장에서 그렇게 합니다.

1단계: header 를 base64url 로

$ node -e 'console.log(Buffer.from(JSON.stringify({ alg: "HS256", typ: "JWT" })).toString("base64url"))'
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9

JWT 를 많이 본 사람이라면 익숙한 eyJhbGciOi… 가 나왔습니다. 모든 HS256 JWT 가 이 문자열로 시작하는 까닭은 {"alg":"HS256","typ":"JWT"} 라는 같은 JSON 을 인코딩했기 때문입니다. eyJ 는 {" 의 base64 입니다.

2단계: 서명해서 토큰 만들기

비밀 키는 부록 C 의 api/.env 에 있는 개발용 JWT_ACCESS_SECRET 값을 그대로 씁니다. 셸 변수에 담아 두고, 만든 토큰도 변수 TOKEN 에 받습니다.

$ export SECRET='change-me-change-me-change-me-change-me-0001'
$ TOKEN=$(node -e '
const { createHmac } = require("node:crypto");
const b64u = (obj) => Buffer.from(JSON.stringify(obj)).toString("base64url");
const header  = { alg: "HS256", typ: "JWT" };
const payload = { sub: "user-1", sid: "session-1", iss: "auth-lab-api", aud: "auth-lab-web",
                  iat: 1790000000, exp: 1790000600, jti: "jwt-1" };
const input = b64u(header) + "." + b64u(payload);
const signature = createHmac("sha256", process.env.SECRET).update(input).digest("base64url");
console.log(input + "." + signature);
')
$ echo "$TOKEN"
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyLTEiLCJzaWQiOiJzZXNzaW9uLTEiLCJpc3MiOiJhdXRoLWxhYi1hcGkiLCJhdWQiOiJhdXRoLWxhYi13ZWIiLCJpYXQiOjE3OTAwMDAwMDAsImV4cCI6MTc5MDAwMDYwMCwianRpIjoiand0LTEifQ.wdWNl2JuZa0D8Y-jTv1YR9Ngtkf_46GfrllytYMqkDw

HS256 의 전부가 이 몇 줄입니다. header.payload 문자열에 HMAC-SHA256 을 계산하고, 그 결과를 base64url 로 붙이면 끝입니다. 입력(키·header·payload)이 같으면 서명도 늘 같으므로, 이 명령을 그대로 실행하면 여러분의 화면에도 글자 하나 다르지 않은 토큰이 나옵니다.

payload 의 iat: 1790000000 은 Unix 시각(초)으로 2026-09-21T14:13:20Z 이고, exp 는 그보다 600초(10분) 뒤입니다. 이 책의 ACCESS_TOKEN_TTL_SEC=600 과 같은 간격입니다. sub·sid 값은 읽기 쉽게 짧은 문자열을 넣었습니다. 실제 앱에서는 둘 다 UUID 입니다.

3단계: 키 없이 읽기

$ node -e '
const [h, p, s] = process.argv[1].split(".");
console.log(JSON.parse(Buffer.from(h, "base64url").toString()));
console.log(JSON.parse(Buffer.from(p, "base64url").toString()));
console.log("서명 길이(바이트):", Buffer.from(s, "base64url").length);
' "$TOKEN"
{ alg: 'HS256', typ: 'JWT' }
{
  sub: 'user-1',
  sid: 'session-1',
  iss: 'auth-lab-api',
  aud: 'auth-lab-web',
  iat: 1790000000,
  exp: 1790000600,
  jti: 'jwt-1'
}
서명 길이(바이트): 32

비밀 키를 전혀 쓰지 않았는데 내용이 다 보입니다. 이것이 "JWT 는 암호화가 아니다" 의 실물입니다. 서명은 SHA-256 의 출력 크기인 32바이트입니다.

💡 node -e '코드' 인자 로 실행하면 첫 인자가 process.argv[1] 에 들어갑니다. 파일로 실행할 때(node file.js 인자 → process.argv[2])와 한 칸 다릅니다.

4단계: 검증하기

검증 코드는 여러 번 쓸 것이므로 셸 변수 VERIFY 에 담아 둡니다. 두 가지를 확인합니다. header 의 alg 가 우리가 기대하는 HS256 인지, 그리고 서명을 다시 계산한 값이 토큰의 서명과 같은지입니다.

$ VERIFY='
const { createHmac, timingSafeEqual } = require("node:crypto");
const [token, secret] = [process.argv[1], process.argv[2]];
const [h, p, s] = token.split(".");
const header = JSON.parse(Buffer.from(h, "base64url").toString());
if (header.alg !== "HS256") { console.log("거부: alg =", header.alg); process.exit(1); }
const expected = createHmac("sha256", secret).update(h + "." + p).digest();
const actual = Buffer.from(s, "base64url");
const ok = actual.length === expected.length && timingSafeEqual(actual, expected);
console.log(ok ? "서명 일치" : "서명 불일치");
'
$ node -e "$VERIFY" "$TOKEN" "$SECRET"
서명 일치
$ node -e "$VERIFY" "$TOKEN" 'wrong-secret-wrong-secret-wrong-secret-00'
서명 불일치

키가 맞으면 일치, 다르면 불일치입니다. 비교에 === 대신 timingSafeEqual 을 쓴 것은 비교에 걸리는 시간이 "몇 번째 바이트에서 달라졌는가" 에 따라 달라지지 않게 하려는 것입니다. 시간 차이로 서명을 한 바이트씩 맞혀 가는 공격을 막는 습관입니다. timingSafeEqual 은 길이가 다르면 예외를 던지므로 길이를 먼저 비교합니다. 9장의 CSRF 토큰 비교(safeEqual)도 같은 방식입니다.

실제 검증은 여기서 끝나지 않습니다. exp 가 지났는지, iss·aud 가 기대한 값인지도 봐야 합니다. 이 실험에서는 서명만 봤고, 5장의 verifyAccessToken 은 라이브러리에 issuer·audience 를 넘겨 전부 확인합니다.

5단계: 내용을 바꿔 보기

공격자가 토큰을 손에 넣어 sub 를 admin 으로 바꾸고 서명은 그대로 둔다면 어떻게 될까요.

$ FORGED=$(node -e '
const [h, p, s] = process.argv[1].split(".");
const payload = JSON.parse(Buffer.from(p, "base64url").toString());
payload.sub = "admin";
console.log(h + "." + Buffer.from(JSON.stringify(payload)).toString("base64url") + "." + s);
' "$TOKEN")
$ echo "$FORGED"
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJhZG1pbiIsInNpZCI6InNlc3Npb24tMSIsImlzcyI6ImF1dGgtbGFiLWFwaSIsImF1ZCI6ImF1dGgtbGFiLXdlYiIsImlhdCI6MTc5MDAwMDAwMCwiZXhwIjoxNzkwMDAwNjAwLCJqdGkiOiJqd3QtMSJ9.wdWNl2JuZa0D8Y-jTv1YR9Ngtkf_46GfrllytYMqkDw
$ node -e "$VERIFY" "$FORGED" "$SECRET"
서명 불일치

payload 부분만 바뀌었고 서명은 원래 토큰과 똑같습니다. 서버가 새 payload 로 서명을 다시 계산하면 다른 값이 나오므로 거부됩니다. 키를 모르는 공격자는 새 payload 에 맞는 서명을 만들 수 없습니다.

6단계: alg: none 으로 바꿔 보기

JWT 표준에는 서명하지 않는 토큰을 뜻하는 alg: "none" 이 정의되어 있습니다. 공격자가 header 를 none 으로 바꾸고 서명을 비워 보냅니다.

$ NONE=$(node -e '
const [, p] = process.argv[1].split(".");
const h = Buffer.from(JSON.stringify({ alg: "none", typ: "JWT" })).toString("base64url");
console.log(h + "." + p + ".");
' "$TOKEN")
$ echo "$NONE"
eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJzdWIiOiJ1c2VyLTEiLCJzaWQiOiJzZXNzaW9uLTEiLCJpc3MiOiJhdXRoLWxhYi1hcGkiLCJhdWQiOiJhdXRoLWxhYi13ZWIiLCJpYXQiOjE3OTAwMDAwMDAsImV4cCI6MTc5MDAwMDYwMCwianRpIjoiand0LTEifQ.
$ node -e "$VERIFY" "$NONE" "$SECRET"
거부: alg = none

토큰이 점으로 끝납니다. 세 번째 부분이 비어 있다는 뜻입니다. 우리 검증 코드는 alg 가 HS256 이 아니면 서명을 보기도 전에 거부했습니다. 만약 검증 코드가 "header 의 alg 를 읽어 그 알고리즘으로 검증" 하도록 짜여 있었다면 어땠을까요. none 이니 서명을 확인하지 않고 통과시켰을 것입니다. 2.6절에서 이 공격을 자세히 봅니다.

2.4 클레임 — 토큰에 무엇을 적는가

RFC 7519 는 자주 쓰는 클레임 몇 개에 이름과 뜻을 정해 두었습니다. 이를 등록 클레임(registered claims) 이라 합니다. 이름이 세 글자인 것은 토큰을 작게 하려는 것입니다.

클레임이름뜻이 책의 값검증 측이 하는 일
ississuer누가 발급했나JWT_ISSUER = auth-lab-api기대한 발급자인지 확인
subsubject누구에 대한 토큰인가사용자 id (UUID)이 값으로 사용자를 식별
audaudience누구에게 쓰라고 발급했나JWT_AUDIENCE = auth-lab-web자기가 대상인지 확인
expexpiration time언제 만료되나 (Unix 초)발급 + ACCESS_TOKEN_TTL_SEC(600)지났으면 거부
iatissued at언제 발급했나 (Unix 초)발급 시각기록·디버깅용
jtiJWT ID이 토큰의 고유 번호매번 새 UUID로그 추적, 필요하면 차단 목록
nbfnot before이 시각 전에는 무효쓰지 않음—

그리고 이 책만의 클레임이 하나 있습니다.

클레임뜻이 책의 값
sid세션 id. 이 액세스 토큰을 발급하게 한 리프레시 토큰 family 의 idrefresh_tokens.family_id (UUID)

family 는 머리말의 용어 표에서 정의한 대로, 로그인 한 번에서 시작해 회전으로 이어진 리프레시 토큰들의 묶음입니다. 이 책에서는 family 가 곧 세션입니다. 액세스 토큰에 sid 를 넣어 두면 두 가지가 가능해집니다.

각 클레임을 검증 측이 확인하지 않으면 아무 의미가 없다는 점을 기억하세요. iss 와 aud 는 특히 그렇습니다. 같은 비밀 키를 쓰는 다른 서비스가 있다고 해 봅시다. 그 서비스용으로 발급된 토큰이 이 API 에서도 통과하면 곤란합니다. aud 를 확인하면 "다른 대상에게 발급된 토큰" 을 거부할 수 있습니다. 13장의 시나리오 S5 가 다른 aud 로 서명한 토큰이 401 이 되는지 확인합니다.

부록 C 의 TokenService 는 이 클레임들을 라이브러리 옵션으로 넘깁니다. 옵션 이름과 클레임의 대응은 이렇습니다.

signAccessToken 옵션만드는 클레임
payload { sid: sessionId }sid
subject: userIdsub
issuer: this.config.jwtIssueriss
audience: this.config.jwtAudienceaud
expiresIn: this.config.accessTokenTtlSecexp (그리고 라이브러리가 iat 를 자동으로 넣음)
jwtid: randomUUID()jti

5장에서 이 코드를 한 줄씩 씁니다.

⚠️ exp·iat 는 초 단위입니다. JavaScript 의 Date.now() 는 밀리초라서 그대로 넣으면 약 5만 년 뒤에 만료되는 토큰이 됩니다. 손으로 JWT 를 만들 때 가장 흔한 실수입니다.

2.5 서명 알고리즘 — HS256, RS256, EdDSA

JWT 의 서명 방식은 크게 두 계열입니다.

대칭 키(HMAC) 방식은 서명과 검증에 같은 비밀 키를 씁니다. HS256 은 HMAC-SHA256 입니다. 2.3절에서 한 것이 이것입니다.

비대칭 키(공개키 서명) 방식은 개인 키로 서명하고 공개 키로 검증합니다. RS256 은 RSA 서명 + SHA-256, EdDSA 는 보통 Ed25519 곡선을 쓰는 서명입니다. 공개 키는 이름 그대로 공개해도 됩니다. 공개 키로는 검증만 할 수 있고 서명은 할 수 없습니다.

관점HS256RS256EdDSA (Ed25519)
키비밀 키 1개RSA 개인 키 + 공개 키Ed25519 개인 키 + 공개 키
서명할 수 있는 쪽키를 가진 모두개인 키를 가진 쪽만개인 키를 가진 쪽만
검증할 수 있는 쪽키를 가진 모두공개 키를 가진 누구나공개 키를 가진 누구나
서명 크기32바이트키 크기와 같음 (2048비트 키 → 256바이트)64바이트
키 배포검증하는 모든 곳에 비밀을 나눠야 함공개 키만 나누면 됨 (JWKS 로 게시 가능)RS256 과 같음
알맞은 구성발급자와 검증자가 같은 서비스발급자 하나, 검증자 여럿RS256 과 같음, 더 작고 빠름

이 책은 HS256 을 씁니다. API 서버 하나가 액세스 토큰을 발급하고 검증도 합니다. 비밀을 나눌 상대가 없으므로 대칭 키의 약점("검증하는 쪽도 서명할 수 있다")이 문제가 되지 않고, 가장 단순합니다.

검증하는 서비스가 여럿으로 늘어나면 사정이 바뀝니다. HS256 이면 모든 서비스에 비밀 키를 줘야 하고, 그중 하나만 뚫려도 공격자가 아무 사용자의 토큰이나 발급할 수 있습니다. 이때는 RS256 이나 EdDSA 로 옮겨 발급 서버만 개인 키를 갖고, 나머지는 공개 키로 검증만 하게 합니다. 키를 교체할 때 헤더에 키 id(kid)를 넣어 여러 키를 동시에 받게 하는 방법과 함께 14장의 운영 체크리스트에서 다룹니다.

💡 HS256 의 비밀 키는 충분히 길고 무작위여야 합니다. HMAC 은 키가 짧거나 추측 가능하면 토큰 하나를 들고 오프라인에서 키를 추측해 볼 수 있습니다. 부록 C 의 AppConfig 는 JWT_ACCESS_SECRET 이 32자보다 짧으면 앱이 뜨지 않게 막고, .env 주석은 openssl rand -base64 48 로 만든 값을 쓰라고 안내합니다. 이 장의 실습에서 쓴 change-me-… 는 개발용 자리 표시 값이니 운영에 그대로 두면 안 됩니다.

2.6 alg: none 과 알고리즘 혼동 공격

JWT 는 header 에 알고리즘을 적습니다. 문제는 이 header 도 공격자가 마음대로 바꿀 수 있는 입력이라는 점입니다. 검증 코드가 "토큰이 자기 알고리즘을 알려 주니 그대로 따르자" 고 짜여 있으면 두 가지 공격이 열립니다.

alg: none 공격. 2.3절 6단계에서 해 본 것입니다. header 를 {"alg":"none"} 으로 바꾸고 서명을 비웁니다. header 를 믿는 검증기는 "서명 없음" 방식으로 검증해, 즉 아무것도 확인하지 않고 통과시킵니다. 공격자는 payload 를 마음대로 쓸 수 있게 됩니다.

알고리즘 혼동 공격. 서버가 RS256 을 쓴다고 합시다. 검증 코드는 verify(token, key) 모양이고, key 자리에 RSA 공개 키를 넘깁니다. 공격자는 header 를 {"alg":"HS256"} 으로 바꾸고, 그 공개 키 문자열을 HMAC 비밀 키로 삼아 서명합니다. 공개 키는 누구나 구할 수 있습니다. header 를 믿는 검증기는 "HS256 이구나" 하고 넘겨받은 key, 즉 공개 키로 HMAC 을 계산합니다. 공격자가 쓴 것과 같은 키이므로 서명이 일치합니다.

서버 기대: RS256, 공개 키 PUB 로 검증
공격자   : header {"alg":"HS256"}, HMAC(PUB, header.payload) 로 서명
header 를 믿는 검증기:
  alg = HS256 이네 → HMAC(PUB, header.payload) 계산 → 일치 → 통과 ✗

두 공격의 뿌리는 같습니다. 공격자가 고른 알고리즘으로 검증했다는 것입니다. 해결도 하나입니다. 검증 측이 허용할 알고리즘을 미리 정해 두고, header 의 alg 가 그 목록에 없으면 거부합니다. 부록 C 의 verifyAccessToken 에 있는 한 줄이 이것입니다.

// api/src/auth/token.service.ts (부록 C 에서 발췌)
const payload = await this.jwt.verifyAsync<{ sub: string; sid: string }>(token, {
  secret: this.config.jwtAccessSecret,
  algorithms: ['HS256'], // 토큰 헤더의 alg 를 믿지 않는다
  issuer: this.config.jwtIssuer,
  audience: this.config.jwtAudience,
});

라이브러리 대부분은 이제 기본값으로도 none 을 거부하거나 키 종류와 알고리즘이 맞지 않으면 거부합니다. 그래도 이 책은 기본값에 기대지 않고 명시합니다. 라이브러리 버전이 바뀌어 기본값이 달라져도, 다른 개발자가 옵션을 건드려도 이 한 줄이 남아 있는 한 공격 경로가 닫혀 있습니다. 사용하는 라이브러리의 정확한 기본 동작은 해당 라이브러리 문서에서 확인하세요.

13장의 시나리오 S6 은 header 를 {"alg":"none"} 으로 바꾼 토큰을 실제 API 에 보내 401 이 나오는지 확인합니다. 2.3절 6단계에서 만든 것과 같은 모양의 토큰입니다.

2.7 서명이지 암호화가 아니다

2.3절 3단계에서 키 없이 payload 를 읽었습니다. 다시 강조합니다. JWT(정확히는 JWS 형식의 JWT)는 내용을 숨기지 않습니다. 서명은 "누가 썼고 바뀌지 않았다" 를 보증할 뿐 "남이 읽지 못한다" 를 보증하지 않습니다.

그래서 payload 에 넣어도 되는 것과 안 되는 것이 갈립니다.

넣어도 되는 것넣으면 안 되는 것
사용자 id(sub), 세션 id(sid)비밀번호, 비밀번호 해시
만료 시각, 발급자, 대상주민등록번호·전화번호 같은 개인정보
토큰 고유 번호(jti)다른 시스템의 API 키나 비밀
(필요하면) 역할 이름사용자에게 보여서는 안 되는 내부 상태

이 책의 액세스 토큰에는 id 와 시각만 들어 있습니다. 이메일이나 닉네임도 넣지 않습니다. 사용자 정보는 로그인 응답 본문(TokenResponse.user)과 GET /users/me 로 따로 받습니다. 닉네임을 토큰에 넣으면 닉네임을 바꿔도 토큰이 만료될 때까지 옛 값이 남는다는 문제도 있습니다.

FE 도 JWT 를 읽을 수 있습니다. 부록 C 의 web/src/auth/jwt.ts 에 있는 readJwtPayload 는 화면에 "액세스 토큰 남은 시간" 을 보여 주려고 exp 를 읽습니다. 이 함수의 주석처럼 읽기만 하고 검증은 하지 않습니다. 브라우저에는 비밀 키가 없고, 있어서도 안 됩니다. 토큰의 진위는 언제나 서버가 판정합니다.

💡 내용까지 숨겨야 하는 경우를 위해 JWE(JSON Web Encryption)라는 암호화 형식이 따로 있습니다. 이 책은 쓰지 않습니다. 숨길 것을 애초에 토큰에 넣지 않는 편이 단순합니다.

2.8 액세스와 리프레시를 나누는 이유

토큰 하나로 끝낼 수는 없을까요. 액세스 토큰의 수명을 14일로 늘리면 리프레시 토큰도, 회전도, 재사용 탐지도 필요 없습니다. 이 방법이 왜 나쁜지를 세 가지 축으로 따져 봅니다.

수명. 자기 완결 토큰은 exp 전에 취소할 수 없습니다(1.4절). 14일짜리 JWT 를 탈취당하면 14일 동안 막을 수단이 없습니다. 로그아웃 버튼을 눌러도, 비밀번호를 바꿔도 공격자의 토큰은 유효합니다. 수명을 10분으로 줄이면 최악의 피해가 10분으로 묶입니다.

노출 빈도. 액세스 토큰은 모든 API 요청에 실립니다. 요청이 많을수록 로그, 프록시, 브라우저 확장, 개발자 도구, 오류 보고 도구 등 토큰이 스쳐 가는 곳이 많아집니다. 자주 노출되는 값일수록 수명이 짧아야 합니다. 반대로 리프레시 토큰은 10분에 한 번, 그것도 /auth/refresh 로만 오갑니다. 쿠키의 Path=/auth 가 다른 경로로 새지 않게 막습니다.

폐기 가능성. 수명이 긴 쪽은 반드시 끊을 수 있어야 합니다. 리프레시 토큰은 DB 에 기록된 불투명 토큰이므로 행을 폐기하는 순간 무효가 됩니다. 로그아웃, 전체 로그아웃, 세션 하나 끊기, 재사용 탐지가 모두 이 성질을 씁니다.

관점액세스 토큰리프레시 토큰
목적API 호출새 액세스 토큰 받기
형식JWT (자기 완결)32바이트 난수 (불투명)
수명10분 (ACCESS_TOKEN_TTL_SEC=600)14일 (REFRESH_TOKEN_TTL_SEC=1209600), 세션 상한 30일 안에서
노출 빈도모든 API 요청약 10분에 한 번, /auth/* 에서만
전달Authorization: Bearer 헤더refresh_token 쿠키 (HttpOnly)
검증서명 계산 (저장소 없음)DB 조회 (SHA-256 해시로)
폐기불가 — exp 를 기다린다즉시 — 행을 폐기
CSRF 대상아니다 (헤더는 자동 전송되지 않음)그렇다 → 세 겹 방어 (8·9장)
XSS 에 값 노출메모리에 있으므로 가능성 있음JS 가 읽을 수 없음

표를 가로로 읽으면 두 토큰이 서로의 약점을 덮는 구조가 보입니다. 액세스 토큰은 자주 노출되는 대신 짧고, 리프레시 토큰은 오래 사는 대신 드물게 노출되고 언제든 끊을 수 있습니다.

그 대가로 "폐기가 즉시 반영되지 않는 시간" 이 생깁니다. 사용자가 로그아웃해 리프레시 family 를 폐기해도, 이미 나간 액세스 토큰은 exp 까지 최대 10분간 유효합니다. 이 책은 그 10분을 받아들입니다. 계정 정지처럼 즉시 끊어야 하는 요구가 생기면 2.4절에서 말한 대로 가드에서 sid 를 조회하는 방법이 있습니다.

💡 리프레시 토큰을 DB 에 저장할 때는 평문이 아니라 SHA-256 해시를 저장합니다. DB 가 유출되어도 해시로는 쿠키를 만들 수 없습니다. 비밀번호에는 느린 argon2id 를 쓰는데 왜 여기서는 빠른 SHA-256 으로 충분할까요. 비밀번호는 사람이 고른 값이라 사전에서 추측할 수 있으므로 한 번 추측하는 비용을 크게 해야 합니다. 반면 리프레시 토큰은 256비트 난수라 추측할 사전이 없습니다. 추측이 불가능한 값에는 느린 해시가 필요 없습니다. 6장에서 hashRefreshToken 으로 구현합니다.

2.9 토큰을 어디에 둘까

브라우저에서 토큰을 둘 수 있는 곳은 크게 넷입니다. 네 가지 상황에서 각각이 어떻게 되는지 비교합니다.

저장 위치XSS 로 값 탈취CSRF새로고침탭 공유
localStorage가능 — localStorage.getItem 한 줄대상 아님유지공유
sessionStorage가능 — 위와 같음대상 아님유지 (같은 탭 안)탭마다 따로
메모리 (JS 변수)어렵지만 불가능하지는 않음대상 아님사라짐탭마다 따로
HttpOnly 쿠키불가 — JS 가 읽지 못함대상 — 자동 전송유지공유

sessionStorage 가 탭을 복제하거나 새 창으로 열 때 어떻게 복사되는지는 브라우저마다 세부가 다르므로 브라우저 문서에서 확인하세요.

표의 첫 열을 조금 더 정확히 봅니다. "메모리" 가 "어렵지만 불가능하지는 않음" 인 이유는, 모듈 안의 변수는 공격 스크립트가 이름으로 찾아 읽을 수 없지만, 공격 스크립트가 전역 fetch 를 바꿔치기해 다음 요청의 Authorization 헤더를 엿볼 수는 있기 때문입니다. 그리고 네 가지 모두 XSS 스크립트가 도는 동안 그 토큰으로 요청을 보내는 것은 막지 못합니다(1.5절의 HttpOnly 경고). 저장 위치가 줄여 주는 것은 "토큰이 공격자의 서버로 넘어가 공격 스크립트가 멈춘 뒤에도 계속 쓰이는 것" 입니다. XSS 자체는 CSP 와 출력 이스케이프로 따로 막아야 합니다.

어느 한 칸도 모든 열에서 이기지 못합니다. 그래서 이 책은 두 토큰에 서로 다른 칸을 씁니다.

두 약점이 서로를 메웁니다. 새로고침으로 메모리의 액세스 토큰이 사라지면, 앱이 뜰 때 쿠키의 리프레시 토큰으로 /auth/refresh 를 한 번 불러 되살립니다(조용한 갱신, 11장). 쿠키의 CSRF 약점은 리프레시 토큰을 쓰는 세 엔드포인트(login·refresh·logout)에만 해당하므로, 그 셋만 세 겹으로 막으면 됩니다(9장).

그리고 "탭 공유" 열이 새 문제를 하나 만듭니다. 리프레시 쿠키는 모든 탭이 공유하는데 액세스 토큰은 탭마다 따로입니다. 탭 두 개가 동시에 액세스 토큰 만료를 겪으면 둘 다 같은 리프레시 쿠키로 refresh 를 부릅니다. 이것이 다음 절의 회전과 부딪힙니다.

2.10 회전과 재사용 탐지

리프레시 토큰은 수명이 길고 쿠키로 오갑니다. 쿠키가 탈취되는 경로는 여럿입니다. 감염된 기기, 악성 브라우저 확장, 잘못 설정된 로그 등입니다. 탈취된 리프레시 토큰은 14일 동안 새 액세스 토큰을 계속 찍어 낼 수 있습니다. 이것을 어떻게 알아챌 수 있을까요.

회전(rotation) 은 refresh 할 때마다 리프레시 토큰을 새것으로 바꾸고 옛것을 폐기하는 것입니다. 한 번 쓴 리프레시 토큰은 다시 쓸 수 없습니다. 그러면 폐기된 토큰이 다시 오는 것 자체가 이상 신호가 됩니다. 정상 사용자는 늘 가장 최근 토큰만 갖고 있기 때문입니다. 옛 토큰이 다시 온다는 것은 누군가 그 토큰의 사본을 갖고 있다는 뜻입니다. 이때 그 family 전체를 폐기하는 것이 재사용 탐지입니다.

누가 먼저 쓰느냐에 따라 두 경우가 있습니다. 어느 쪽이든 결과는 같다는 것이 이 설계의 핵심입니다.

경우 1: 공격자가 먼저 쓴다

 시각   정상 사용자                      서버 (family F)                         공격자
 ────   ──────────                      ─────────────────                        ──────
 t0     로그인 ─────────────────────▶   R1 발급 (F 시작)
        ◀──────────────── 쿠키 R1
 t1                                                                              R1 사본 탈취
 t2                                     R1 → rotated, R2 발급  ◀──────────────── refresh(R1)
                                                               ──── R2 + 액세스 ▶ (공격자가 R2 보유)
 t3     refresh(R1) ────────────────▶   R1 은 이미 rotated, grace 지남
        (사용자는 아직 R1 을 갖고 있음)    → 재사용 탐지!
                                          F 의 살아 있는 토큰(R2) 전부 reuse_detected
        ◀──── 401 REFRESH_TOKEN_REUSED
        (로그인 화면 + "다른 곳에서
         세션이 사용됐다" 안내)
 t4                                     R2 는 폐기됨           ◀──────────────── refresh(R2)
                                                               ──── 401 ────────▶ (더 못 씀)

경우 2: 정상 사용자가 먼저 쓴다

 시각   정상 사용자                      서버 (family F)                         공격자
 ────   ──────────                      ─────────────────                        ──────
 t0     로그인 ─────────────────────▶   R1 발급
 t1                                                                              R1 사본 탈취
 t2     refresh(R1) ────────────────▶   R1 → rotated, R2 발급
        ◀──────────────── 쿠키 R2
 t3                                     R1 은 이미 rotated, grace 지남 ◀──────── refresh(R1)
                                          → 재사용 탐지!
                                          F 의 살아 있는 토큰(R2) 전부 reuse_detected
                                                               ──── 401 ────────▶
 t4     refresh(R2) ────────────────▶   R2 는 폐기됨
        ◀──── 401 REFRESH_TOKEN_INVALID
        (다시 로그인)

두 그림 모두 정상 사용자와 공격자 중 늦게 온 쪽이 옛 토큰을 내밀게 되고, 그 순간 family 가 통째로 끊깁니다. 서버는 누가 공격자인지 모릅니다. 알 필요도 없습니다. "같은 토큰의 사본이 둘 있다" 는 사실만으로 이 세션을 믿을 수 없다고 판단하고, 양쪽 모두를 로그아웃시킵니다. 정상 사용자는 다시 로그인하면 새 family 를 받고, 공격자는 비밀번호가 없으므로 돌아올 수 없습니다.

한계도 있습니다.

구현에서 놓치기 쉬운 점이 하나 있습니다. 재사용을 탐지하면 family 를 폐기하는 UPDATE 를 한 뒤 401 을 돌려줘야 하는데, "에러를 던지면 트랜잭션을 롤백" 하는 흔한 패턴을 쓰면 폐기까지 롤백되어 사라집니다. 부록 C 의 RefreshTokenRepository.rotate 가 결과를 예외가 아닌 값(RotateOutcome)으로 돌려주고, COMMIT 한 뒤에야 AuthService 가 에러로 바꾸는 이유입니다. 7장에서 자세히 다룹니다.

⚠️ 재사용 탐지가 제대로 동작하려면 폐기된 행을 바로 지우면 안 됩니다. 행이 없으면 "처음 보는 토큰" 과 "이미 쓴 토큰" 을 구분할 수 없어 REFRESH_TOKEN_INVALID 로만 응답하게 되고, family 폐기가 일어나지 않습니다. 그래서 이 책은 revoked_at 과 revoke_reason 으로 표시만 해 두고 오래된 행은 나중에 따로 청소합니다.

2.11 grace 가 필요한 이유

2.9절 끝에서 예고한 문제입니다. 공격자가 없어도 같은 리프레시 토큰이 두 번 오는 정상적인 상황이 있습니다.

 시각     탭 A                            서버                              탭 B
 ──────   ────                            ────                              ────
 t0       (액세스 토큰 만료)                                                (액세스 토큰 만료)
 t0+1ms   refresh(R1) ──────────────▶                          ◀──────────── refresh(R1)
                                         R1 잠금 (FOR UPDATE)
                                         R1 → rotated, R2 발급
          ◀──── 200 + 쿠키 R2
                                         (A 의 트랜잭션이 끝나 B 차례)
                                         R1 은 방금 rotated
                                           grace 가 없다면 → 재사용 탐지 → family 폐기 ✗
                                           grace(10초) 안이면 → 409 REFRESH_TOKEN_ROTATED
                                                                ──── 409 ────▶
                                                                              쿠키 저장소엔 이미 R2
                                                               ◀──────────── refresh(R2) 재시도
                                         R2 → rotated, R3 발급 ──── 200 ────▶

쿠키는 탭끼리 공유되므로, 두 탭이 거의 동시에 refresh 를 보내면 둘 다 R1 을 싣고 떠납니다. 서버는 하나를 먼저 처리해 R1 을 폐기하고, 뒤따라온 요청은 폐기된 R1 을 내밉니다. 공격이 아닌데 겉모양은 재사용과 똑같습니다. 여기서 family 를 끊으면 사용자는 탭을 두 개 열었다는 이유로 로그아웃됩니다. 머리말의 "탭을 두 개 열어 두면 가끔 로그아웃되는 버그" 가 바로 이것입니다.

grace 는 이 경우를 봐주는 짧은 시간입니다. 방금(REFRESH_REUSE_GRACE_SEC, 10초 안에) rotated 로 폐기된 토큰이 다시 오면 재사용으로 보지 않고 409 REFRESH_TOKEN_ROTATED 를 돌려줍니다. 이 응답에서는 쿠키를 건드리지 않습니다. 먼저 끝난 요청이 이미 새 쿠키(R2)를 내려보냈기 때문입니다. FE 는 409 를 받으면 한 번 더 refresh 하고, 이번에는 쿠키 저장소의 R2 가 실려 정상적으로 회전됩니다.

grace 는 안전망입니다. 1차 방어는 FE 에 있습니다. 한 탭 안에서는 동시에 여러 요청이 401 을 받아도 refresh 를 한 번만 보내고(single-flight), 탭 사이에서는 브라우저의 잠금 API(navigator.locks)로 refresh 를 한 줄로 세웁니다(12장). grace 는 이 잠금을 쓸 수 없는 환경이나 예상하지 못한 경쟁에 대비한 것입니다.

grace 에는 대가가 있습니다. grace 안에서는 옛 토큰이 와도 탐지하지 않으므로, 공격자가 정상 사용자의 refresh 직후 10초 안에 같은 토큰을 내밀면 탐지되지 않습니다. 다만 409 는 새 토큰을 주지 않으므로 공격자가 얻는 것도 없습니다. 그래도 grace 를 늘릴수록 "탐지되지 않는 틈" 이 넓어지므로 10초보다 키우지 않습니다.

2.12 세션 절대 상한

2.10절의 첫 번째 한계를 다시 봅니다. 공격자가 리프레시 토큰을 훔친 뒤 정상 사용자가 다시 오지 않으면 재사용 탐지는 일어나지 않습니다. 공격자가 14일 안에 꾸준히 refresh 하면 회전할 때마다 새 14일을 받으므로, 이론상 영원히 세션을 이어 갈 수 있습니다.

이것을 막는 것이 세션 절대 상한입니다. family 가 처음 만들어질 때(로그인할 때) "이 세션은 무슨 일이 있어도 이 시각에 끝난다" 를 정해 두고, 회전해도 늘리지 않습니다. 이 책에서는 30일(SESSION_MAX_AGE_SEC=2592000)이고, DB 의 session_expires_at 열에 저장됩니다.

각 리프레시 토큰의 만료는 두 값 중 이른 쪽입니다.

토큰 만료 = min(발급 시각 + 14일, session_expires_at)
 로그인                                                                  session_expires_at
 day 0                                                                   day 30
   │── R1 (≤14일) ──│                                                      │
          │ refresh day 5                                                  │
          │── R2 (day 19 까지) ──│                                          │
                     │ refresh day 12                                      │
                     │── R3 (day 26 까지) ──│                               │
                                │ refresh day 20                           │
                                │── R4 (day 34 → day 30 으로 잘림) ────────│
                                                                           │ 이후 refresh → 401

두 수명이 서로 다른 것을 지킵니다.

부록 C 의 SQL 은 이 규칙을 그대로 옮깁니다. 로그인(createFamily)에서 session_expires_at = now() + 30일, expires_at = LEAST(now() + 14일, now() + 30일) 로 넣고, 회전(rotateInTx)에서는 옛 행의 session_expires_at 을 그대로 물려받으며 expires_at = LEAST(now() + 14일, session_expires_at) 으로 넣습니다. 13장의 시나리오 S17 은 SESSION_MAX_AGE_SEC=5 로 띄워 6초 뒤 refresh 가 401 이 되는지 확인합니다.

2.13 이 책의 설계 표

지금까지의 결정을 한 표로 모읍니다. 각 줄의 "구현" 열이 그 결정을 코드로 옮기는 장입니다. 3장부터는 이 표의 줄을 하나씩 채워 나가는 과정입니다.

항목결정왜구현
FE·API 배치다른 출처, 같은 사이트 (로컬 localhost:5173 ↔ localhost:4000, 운영 app.example.com ↔ api.example.com)SameSite=Strict 쿠키를 쓸 수 있고 서드파티 쿠키 차단에 걸리지 않는다 (1.6절)3장, 14장
비밀번호argon2id (m=19456, t=2, p=1)사람이 고른 값이라 추측 비용을 키워야 한다4장
액세스 토큰JWT HS256, 10분. 클레임 sub·sid·jti·iss·aud (+exp·iat)매 요청을 저장소 없이 검증. 폐기 불가를 짧은 수명으로 묶는다 (2.4·2.5·2.8절)5장
액세스 토큰 검증algorithms: ['HS256'] 고정, issuer·audience 확인alg: none·알고리즘 혼동 차단, 다른 대상의 토큰 거부 (2.6절)5장
액세스 토큰 전달응답 본문으로 주고, 요청에는 Authorization: Bearer헤더는 자동 전송되지 않아 CSRF 대상이 아니다 (1.4절)5장
액세스 토큰 보관FE 메모리(모듈 변수). localStorage 금지XSS 한 번에 영구 탈취되는 것을 막는다 (2.9절)11장
리프레시 토큰불투명 난수 32바이트(base64url). DB 에는 SHA-256 해시만즉시 폐기 가능. 추측 불가한 값이라 빠른 해시로 충분 (2.1·2.8절)6장
리프레시 전달쿠키 refresh_token — HttpOnly; Secure; SameSite=Strict; Path=/auth, 만료 14일(세션 상한 안)JS 가 못 읽고, 다른 사이트 요청에 안 실리고, /auth 밖으로 새지 않는다 (1.5절)6장
회전refresh 마다 새 토큰, 옛 토큰은 rotated. 같은 family 유지. 한 트랜잭션 + FOR UPDATE한 번 쓴 토큰이 다시 오면 이상 신호가 된다 (2.10절)7장
재사용 탐지rotated 토큰이 grace 뒤에 오면 family 전체 폐기, 401 REFRESH_TOKEN_REUSED누가 공격자인지 몰라도 사본의 존재로 세션을 끊는다 (2.10절)7장
grace10초 안의 재사용은 409 REFRESH_TOKEN_ROTATED탭 동시 refresh 의 오탐 방지. 더 키우지 않는다 (2.11절)7장, 12장
세션 상한family 첫 발급부터 30일(session_expires_at). 회전해도 늘지 않는다탐지되지 않은 탈취 세션의 최대 기간 (2.12절)6장, 7장
세션 관리세션 목록·하나 끊기·전체 로그아웃. sid 로 현재 세션 표시사용자가 모르는 기기를 직접 끊을 수 있게 (2.4절)7장
CSRF쿠키를 쓰는 요청(login·refresh·logout)에 세 겹: SameSite · Origin 검사 · 서명된 double-submit 토큰(X-CSRF-Token)SameSite 는 같은 사이트의 다른 출처를 못 막는다 (1.6절)8장, 9장
계약BE 가 OpenAPI 를 낸다. FE 는 openapi-typescript 로 타입을 만들어 openapi-fetch 로 부른다FE 가 문서만 보고 작업한다10장
FE 갱신부팅 시 조용한 갱신, single-flight, 탭 간 잠금, 401 시 1회 재시도, BroadcastChannel 로 로그아웃 동기화새로고침·다중 탭에서도 세션이 끊기지 않고 재사용 탐지가 오탐하지 않게 (2.9·2.11절)11장, 12장
검증22개 시나리오를 e2e 와 curl 로설계가 실제로 동작함을 증명13장

이 표의 수치는 모두 api/.env 의 환경변수로 조정할 수 있습니다(ACCESS_TOKEN_TTL_SEC, REFRESH_TOKEN_TTL_SEC, SESSION_MAX_AGE_SEC, REFRESH_REUSE_GRACE_SEC). 3장에서 이 값들을 읽고 검증하는 설정 계층을 만듭니다.

확인하기

2.3절의 실습을 다시 돌려 다음 결과가 나오는지 확인합니다. 모두 여러분의 컴퓨터에서 이 장과 글자 하나까지 같은 결과가 나와야 합니다. HMAC 은 입력이 같으면 출력도 같기 때문입니다.

#명령기대 결과
11단계 (header 인코딩)eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
22단계 후 echo "$TOKEN"끝이 .wdWNl2JuZa0D8Y-jTv1YR9Ngtkf_46GfrllytYMqkDw
33단계 (디코드)sub: 'user-1', exp: 1790000600, 서명 길이 32
4node -e "$VERIFY" "$TOKEN" "$SECRET"서명 일치
5틀린 키로 검증서명 불일치
6$FORGED 검증서명 불일치
7$NONE 검증거부: alg = none

2번의 서명이 다르다면 SECRET 값이나 payload 의 글자(따옴표, 순서, 숫자)가 다른 것입니다. JSON 의 키 순서가 바뀌어도 payload 문자열이 달라지므로 서명이 바뀝니다.

리프레시 토큰의 모양도 확인해 둡니다. 아래 값은 실행할 때마다 다릅니다.

$ node -e 'const b = require("node:crypto").randomBytes(32); console.log(b.toString("base64url"), b.toString("base64url").length)'
Q0y1ECht0PXyQdyZO-UVWmu-S9fLgz8oRw8BD6eHypg 43
$ node -e 'console.log(require("node:crypto").createHash("sha256").update("abc").digest("hex").length)'
64

32바이트 난수는 43자 base64url 이 되고, 그 SHA-256 해시를 hex 로 쓰면 64자입니다. 6장에서 DB 의 token_hash 열에 들어가는 것이 이 64자입니다.

자주 하는 실수

JWT 를 암호화된 것으로 생각한다. payload 는 base64url 인코딩일 뿐이라 누구나 읽습니다. 개인정보나 비밀을 넣지 않습니다.

검증할 때 header 의 alg 를 따른다. alg: none 과 알고리즘 혼동 공격이 열립니다. 허용 알고리즘을 검증 측에서 고정합니다(algorithms: ['HS256']).

iss·aud 를 넣기만 하고 확인하지 않는다. 확인하지 않는 클레임은 없는 것과 같습니다. 같은 키로 서명된 다른 용도의 토큰이 통과합니다.

exp 에 밀리초를 넣는다. JWT 의 시각은 초 단위입니다. Date.now() 를 그대로 넣으면 사실상 만료되지 않는 토큰이 됩니다.

액세스 토큰 수명을 늘려 리프레시를 없앤다. 폐기할 수 없는 토큰의 수명을 늘리는 것이라 탈취 피해가 그만큼 길어집니다.

액세스 토큰을 localStorage 에 둔다. XSS 한 번에 값이 밖으로 나가고, 공격 스크립트가 멈춘 뒤에도 공격자가 계속 씁니다.

재사용을 탐지하고 에러를 던져 트랜잭션을 롤백한다. family 폐기까지 롤백됩니다. 결과를 값으로 돌려받아 COMMIT 한 뒤 에러로 바꿉니다(7장).

grace 없이 회전만 넣는다. 탭 두 개가 동시에 refresh 하면 재사용으로 오탐해 사용자가 로그아웃됩니다. 반대로 grace 를 길게 잡으면 탐지되지 않는 틈이 넓어집니다.

회전할 때 세션 상한을 새로 계산한다. session_expires_at 을 now() + 30일 로 다시 잡으면 활성 세션이 영원히 이어집니다. 옛 행의 값을 물려받습니다.

리프레시 토큰을 평문으로 저장하거나 argon2 로 해시한다. 평문은 DB 유출이 곧 세션 탈취이고, argon2 는 256비트 난수에는 불필요하게 느립니다. SHA-256 으로 충분합니다.

정리

1부의 원리는 여기까지입니다. 다음 장에서는 DB 와 NestJS 를 띄우고, 이 장의 설계 표에 나온 수치들을 환경변수로 읽어 검증하는 설정 계층부터 만듭니다.

연습문제

1. 다음 JWT 의 payload 를 키 없이 디코드해, sub 와 exp 값을 적고 exp 가 몇 년 몇 월 며칠(UTC)인지 구하세요. 명령도 함께 적으세요.

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyLTEiLCJzaWQiOiJzZXNzaW9uLTEiLCJpc3MiOiJhdXRoLWxhYi1hcGkiLCJhdWQiOiJhdXRoLWxhYi13ZWIiLCJpYXQiOjE3OTAwMDAwMDAsImV4cCI6MTc5MDAwMDYwMCwianRpIjoiand0LTEifQ.wdWNl2JuZa0D8Y-jTv1YR9Ngtkf_46GfrllytYMqkDw
해답

2.3절에서 만든 토큰입니다. 두 번째 부분을 base64url 로 디코드하면 됩니다.

$ node -e '
const p = JSON.parse(Buffer.from(process.argv[1].split(".")[1], "base64url").toString());
console.log(p.sub, p.exp, new Date(p.exp * 1000).toISOString());
' 'eyJhbGciOi…(위 토큰 전체)'

sub 는 user-1, exp 는 1790000600 이고, 이는 2026-09-21T14:23:20.000Z 입니다. exp 는 초 단위이므로 Date 에 넘길 때 1000 을 곱해야 합니다. 키를 쓰지 않았다는 점이 요점입니다. 토큰을 가진 누구나 이 정보를 읽을 수 있습니다.

2. 동료가 검증 코드를 이렇게 짰습니다. 어떤 공격에 열려 있는지 설명하고 고치세요.

const header = JSON.parse(Buffer.from(token.split('.')[0], 'base64url').toString());
const payload = jwt.verify(token, key, { algorithms: [header.alg] });
해답

허용 알고리즘을 토큰의 header 에서 가져오고 있습니다. algorithms 옵션을 쓰긴 했지만 공격자가 고른 값을 그대로 넣었으므로 고정하지 않은 것과 같습니다. 공격자가 alg 를 none 으로 바꾸면(라이브러리가 none 을 받아들이는 설정이라면) 서명 없이 통과하고, 서버가 RS256 공개 키를 key 로 넘기는 구성이라면 alg 를 HS256 으로 바꿔 공개 키로 HMAC 서명한 토큰이 통과합니다(알고리즘 혼동). 고치는 방법은 허용 목록을 코드에 상수로 두는 것입니다.

const payload = jwt.verify(token, key, { algorithms: ['HS256'] });

3. 탭 A 와 탭 B 가 같은 리프레시 쿠키 R1 을 갖고 있습니다. 탭 A 가 refresh 를 보내 R2 를 받았고, 15초 뒤 탭 B 가 (잠금 없이) R1 으로 refresh 를 보냈습니다. 서버는 어떤 응답을 주고, 그 뒤 두 탭은 어떤 상태가 되나요? grace 가 10초라는 것과 쿠키가 탭끼리 공유된다는 것을 모두 고려하세요.

해답

먼저 짚을 점이 있습니다. 쿠키는 탭끼리 공유되므로, 탭 A 가 R2 를 받은 순간 브라우저의 쿠키 저장소에는 R2 가 들어가고, 15초 뒤 탭 B 가 보내는 요청에도 R2 가 실립니다. 따라서 이 상황은 보통 정상적인 회전(R2 → R3)으로 끝나고 문제가 없습니다.

문제가 되는 것은 탭 B 의 요청이 A 의 응답이 도착하기 전에 이미 R1 을 싣고 떠난 경우(동시 요청)입니다. 이때 B 의 요청이 서버에서 처리되는 시점이 R1 폐기 후 10초 안이면 409 REFRESH_TOKEN_ROTATED 로 끝나고, B 는 재시도해 쿠키 저장소의 최신 토큰으로 회전합니다. 만약 어떤 이유로(예: 네트워크 지연, 잠금 없는 클라이언트) R1 이 폐기 후 10초가 지나 서버에 도착하면 서버는 재사용으로 판단해 family 전체를 폐기하고 401 REFRESH_TOKEN_REUSED 를 줍니다. 그러면 R2 도 폐기되므로 두 탭 모두 로그아웃 상태가 됩니다. 이 오탐을 막는 1차 장치가 FE 의 탭 간 잠금(12장)이고, grace 는 그 안전망입니다.

4. 한 사용자가 1일에 로그인했고 이후 매일 한 번씩 앱을 써서 refresh 가 일어납니다. 설계 표의 수치(리프레시 14일, 세션 상한 30일)로 이 사용자가 언제 다시 비밀번호를 입력하게 되는지 답하세요. 또 로그인 후 한 번도 쓰지 않았다면 언제 로그인이 풀리나요?

해답

매일 쓰는 경우 refresh 할 때마다 토큰 만료는 min(그날 + 14일, 1일 + 30일) 이 됩니다. 회전은 세션 상한을 늘리지 않으므로, 로그인 후 30일이 지나는 순간(31일)부터 refresh 가 401 이 되고 다시 로그인해야 합니다. 활성 사용자도 30일마다 한 번은 비밀번호를 입력합니다.

한 번도 쓰지 않은 경우 첫 리프레시 토큰의 만료는 min(1일 + 14일, 1일 + 30일) = 로그인 후 14일입니다. 그 뒤 앱을 열면 조용한 갱신이 401 이 되어 로그인 화면이 나옵니다.

5. "리프레시 토큰도 JWT 로 만들면 DB 조회 없이 검증할 수 있으니 더 빠르지 않나?" 라는 제안에 이 장의 내용으로 답하세요.

해답

리프레시 토큰의 요구는 빠른 검증이 아니라 폐기 가능성입니다. 로그아웃, 세션 끊기, 재사용 탐지는 모두 "이미 발급한 리프레시 토큰을 즉시 무효로 만드는" 일인데, 자기 완결 JWT 는 exp 전에 무효로 만들 수 없습니다. 이를 보완하려고 JWT 의 jti 를 DB 에 기록해 두고 폐기 여부를 조회한다면 결국 DB 조회가 다시 필요하므로 얻는 것이 없고, 토큰만 커집니다. 또 리프레시는 약 10분에 한 번 일어나므로 조회 비용 자체가 작습니다. 회전과 재사용 탐지를 하려면 "이 토큰이 이미 쓰였는가" 를 서버가 기억해야 하므로, 서버 기록이 본질인 불투명 토큰이 맞는 선택입니다.