aster®

[로그인인증] 리프레시 토큰을 발급하다 (06)

마무리 · 2026-09

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

이 장을 마치면

  • 리프레시 토큰을 JWT 가 아닌 불투명 난수로 만드는 이유를 설명하고, DB 에 SHA-256 해시만 저장할 수 있다.
  • refresh_tokens 테이블의 각 열이 무엇을 위해 있는지, 그리고 family(세션)의 절대 상한과 토큰별 만료가 어떻게 다른지 설명할 수 있다.
  • 리프레시 토큰 쿠키의 속성(HttpOnly·Secure·SameSite=Strict·Path=/auth·Expires)을 하나하나 근거를 들어 정할 수 있다.
  • 로그인 응답에서 액세스 토큰은 본문으로, 리프레시 토큰은 쿠키로 내려보내고, 그 결과를 curl -i 와 DB 에서 직접 확인할 수 있다.

들어가며

5장에서 로그인을 만들었습니다. 이메일과 비밀번호가 맞으면 10분짜리 액세스 토큰(JWT)을 응답 본문으로 돌려주고, JwtAuthGuard 가 그 토큰을 검사해 /users/me 를 지킵니다. 동작은 하지만 두 가지가 비어 있습니다.

첫째, 10분이 지나면 사용자는 비밀번호를 다시 입력해야 합니다. 액세스 토큰을 길게 주면 이 불편은 사라지지만, 2장에서 보았듯이 JWT 는 한 번 발급하면 서버가 거둬들일 수 없습니다. 탈취된 토큰이 며칠씩 살아 있게 됩니다. 그래서 이 책은 액세스 토큰을 짧게 두고, 새 액세스 토큰을 받는 데만 쓰는 긴 수명의 토큰을 따로 둡니다. 그것이 리프레시 토큰입니다.

둘째, 5장의 액세스 토큰에는 sid 클레임이 있는데, 그 자리에 randomUUID() 로 만든 임시값을 넣어 두었습니다. "세션" 이라는 것이 아직 서버 어디에도 없었기 때문입니다. 이 장에서 리프레시 토큰을 DB 에 저장하기 시작하면 비로소 세션이 생기고, sid 는 그 세션의 id 를 가리키게 됩니다.

이 장이 하는 일은 발급까지입니다. 리프레시 토큰을 만들고, 해시로 저장하고, 쿠키에 담아 내려보냅니다. 그 토큰을 실제로 써서 새 액세스 토큰을 받는 일(POST /auth/refresh), 쓸 때마다 토큰을 바꾸는 회전, 훔친 토큰을 알아채는 재사용 탐지는 7장의 몫입니다. 이 장에서 테이블 설계와 쿠키 속성을 꼼꼼히 정해 두어야 7장의 트랜잭션이 단순해집니다.

6.1 리프레시 토큰은 어떤 모양이어야 하는가

리프레시 토큰을 만드는 방법은 크게 둘입니다. 액세스 토큰처럼 JWT 로 만드는 방법과, 아무 의미 없는 난수 문자열(불투명 토큰, opaque token) 을 만들고 서버가 DB 에 기억해 두는 방법입니다. "불투명" 이라는 말은 토큰을 들여다봐도 안에 든 정보가 없다는 뜻입니다. 토큰은 DB 행을 찾는 열쇠일 뿐입니다.

두 방법을 이 책의 요구사항에 대 보겠습니다.

기준JWT 리프레시 토큰불투명 리프레시 토큰
서버가 검증하는 방법서명·만료 확인. DB 없이 가능DB 에서 해시로 조회
로그아웃·강제 종료서명이 유효하면 막을 수 없다. 막으려면 결국 DB 에 폐기 목록을 둔다행에 revoked_at 을 찍으면 끝
회전·재사용 탐지 (7장)"이 토큰이 이미 쓰였는가" 를 알려면 DB 조회가 필요하다원래 DB 조회로 동작한다
토큰 길이헤더·본문·서명 합쳐 수백 바이트43자
토큰에서 새는 정보base64 만 풀면 사용자 id·만료 시각이 보인다없다
서명 키 관리키가 하나 더 늘거나, 액세스 토큰 키를 같이 쓰게 된다키가 필요 없다

JWT 의 가장 큰 장점은 "DB 를 보지 않고 검증할 수 있다" 는 것입니다. 액세스 토큰에서는 이 장점이 결정적입니다. API 요청마다 DB 를 한 번씩 더 보는 비용을 없애 주기 때문입니다.

그런데 리프레시 토큰은 사정이 다릅니다. 리프레시 토큰이 쓰이는 순간은 10분에 한 번 정도로 드물고, 그 순간에 서버가 해야 하는 일은 "이 토큰이 아직 살아 있는가, 이미 한 번 쓰인 적은 없는가, 사용자가 로그아웃하지는 않았는가" 를 확인하는 것입니다. 이 질문들은 전부 서버가 기억하고 있는 상태에 대한 질문이라 서명만으로는 답할 수 없습니다. 결국 JWT 로 만들어도 DB 조회를 해야 하고, 그러면 JWT 의 장점은 사라지고 단점(길이, 정보 노출, 키 관리)만 남습니다.

그래서 이 책은 리프레시 토큰을 불투명 토큰으로 만듭니다. 한 줄로 줄이면 이렇습니다.

액세스 토큰은 DB 를 보지 않으려고 JWT 로 만들고, 리프레시 토큰은 어차피 DB 를 봐야 하므로 불투명 토큰으로 만든다.

💡 OAuth 2.0 을 구현한 여러 인증 서버도 리프레시 토큰을 불투명 토큰으로 발급합니다. 발급한 쪽만 해석하면 되는 토큰이기 때문입니다.

6.2 토큰을 만들고 해시하기

토큰을 만드는 코드와 해시하는 코드는 TokenService 에 둡니다. 5장에서 만든 signAccessToken·verifyAccessToken 아래에 메서드 두 개를 더합니다.

// api/src/auth/token.service.ts
import { Injectable } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { createHash, randomBytes, randomUUID } from 'node:crypto';
import { AppConfig } from '../config/app-config';

// … AuthUser 타입, constructor, signAccessToken, verifyAccessToken 은 5장 그대로 …

  newRefreshToken(): string {
    return randomBytes(32).toString('base64url');
  }

  hashRefreshToken(token: string): string {
    return createHash('sha256').update(token).digest('hex');
  }
}

import 줄에 createHash 와 randomBytes 가 더해졌습니다. randomUUID 는 5장에서 jwtid 를 만들 때 이미 쓰고 있었습니다.

newRefreshToken — 32바이트 난수

randomBytes(32) 는 운영체제의 암호학적 난수 생성기에서 32바이트, 즉 256비트를 뽑습니다. Math.random() 은 예측 가능한 의사 난수라 토큰에 쓰면 안 됩니다. 반드시 node:crypto 의 randomBytes 를 씁니다.

뽑은 바이트는 base64url 로 문자열로 바꿉니다. base64url 은 base64 에서 + 와 / 를 - 와 _ 로 바꾸고 끝의 = 패딩을 뺀 형식입니다. 쿠키 값이나 URL 에 넣어도 인코딩이 필요 없는 문자만 쓰기 때문에 쿠키에 담을 토큰으로 알맞습니다. 32바이트는 base64url 로 43글자가 됩니다.

hashRefreshToken — SHA-256 이면 충분하다

리프레시 토큰은 DB 에 평문으로 저장하지 않습니다. 해시만 저장합니다. 이유는 비밀번호를 해시로 저장하는 이유와 같습니다. DB 백업 파일이 유출되거나, SQL 인젝션으로 테이블이 읽히거나, 운영자가 실수로 쿼리 결과를 메신저에 붙여 넣어도 그 값으로 로그인 상태를 훔칠 수 없어야 합니다. 해시만 있으면 공격자는 토큰을 알 수 없습니다.

그런데 4장에서 비밀번호는 argon2id 로 해싱했습니다. 같은 "비밀 값" 인데 왜 여기서는 SHA-256 한 번이면 될까요? 답은 엔트로피, 즉 값이 얼마나 예측하기 어려운가에 있습니다.

비밀번호는 사람이 고릅니다. 사람이 고른 비밀번호는 경우의 수가 생각보다 훨씬 적습니다. 유출된 비밀번호 목록과 흔한 변형 규칙만으로 상당수를 맞힐 수 있습니다. 해시가 유출되면 공격자는 후보 비밀번호를 하나씩 해시해서 비교하는데(사전 공격), SHA-256 은 GPU 한 장으로 1초에 수십억 번을 계산할 수 있으니 약한 비밀번호는 금방 뚫립니다. argon2id 는 한 번 계산하는 데 일부러 많은 메모리와 시간을 쓰게 만들어서 이 속도를 떨어뜨리는 함수입니다.

리프레시 토큰은 사람이 고르지 않습니다. 256비트 난수이므로 경우의 수가 2^256 개입니다. 사전 공격에 쓸 "후보 목록" 이라는 것이 애초에 없습니다. 1초에 수십억 번을 계산해도, 우주의 나이만큼 계산해도 2^256 중 의미 있는 비율을 훑을 수 없습니다. 입력 자체가 추측 불가능하므로 해시 함수를 느리게 만들어 얻는 이득이 없습니다.

오히려 argon2 를 쓰면 곤란한 점이 생깁니다.

SHA-256 은 같은 입력에 항상 같은 결과를 내므로 token_hash 에 UNIQUE 인덱스를 걸고 한 번에 찾을 수 있습니다. 결과는 hex 로 인코딩해 64글자 문자열로 저장합니다. psql 에서 눈으로 비교하기 쉬운 형식입니다.

정리하면 이렇습니다.

값누가 만드나엔트로피해시
비밀번호사람낮다argon2id — 느리게, 솔트 섞어서
리프레시 토큰서버(randomBytes(32))256비트SHA-256 — 빠르게, 조회 가능하게

평문을 어디에도 남기지 않는다

해시로 저장하는 것만으로는 부족합니다. 평문 토큰이 다른 경로로 새지 않아야 합니다. 이 책의 코드에서 평문 토큰이 존재하는 곳은 딱 두 군데입니다.

  1. 로그인·refresh 처리 중인 함수의 지역 변수
  2. 브라우저에 보내는 Set-Cookie 헤더와, 브라우저가 돌려보내는 Cookie 헤더

그러므로 요청 로그에 Cookie 헤더를 찍지 않고, 에러 메시지에 토큰 값을 넣지 않고, 디버깅용 console.log(token) 을 커밋하지 않습니다. 14장의 운영 체크리스트에 이 항목이 다시 나옵니다.

6.3 refresh_tokens 테이블 다시 읽기

3장에서 마이그레이션을 만들면서 refresh_tokens 테이블도 함께 만들어 두었습니다. 그때는 "7장까지 가면 다 쓰인다" 고만 하고 넘어갔는데, 이제 각 열이 왜 있는지 하나씩 봅니다.

-- api/db/migrations/001_init.sql (3장에서 만든 것 중 refresh_tokens 부분)
CREATE TABLE IF NOT EXISTS refresh_tokens (
  id                 uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id            uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  family_id          uuid NOT NULL,
  token_hash         text NOT NULL UNIQUE,
  expires_at         timestamptz NOT NULL,
  session_expires_at timestamptz NOT NULL,
  created_at         timestamptz NOT NULL DEFAULT now(),
  revoked_at         timestamptz,
  revoke_reason      text CHECK (revoke_reason IN
                       ('rotated','logout','logout_all','session_deleted','reuse_detected')),
  replaced_by        uuid REFERENCES refresh_tokens(id),
  user_agent         text,
  ip                 inet
);
CREATE INDEX IF NOT EXISTS refresh_tokens_family_idx ON refresh_tokens (family_id);
CREATE INDEX IF NOT EXISTS refresh_tokens_user_idx   ON refresh_tokens (user_id);

가장 먼저 짚을 점은 한 행이 토큰 하나라는 것입니다. 한 사용자가 로그인해서 회전을 열 번 하면 행이 열한 개 생깁니다. 토큰을 바꿀 때 기존 행을 UPDATE 로 덮어쓰지 않고 새 행을 INSERT 합니다. 옛 토큰의 행이 남아 있어야 7장에서 "이미 한 번 쓰인 토큰이 다시 왔다" 는 것을 알아챌 수 있기 때문입니다.

열무엇을 기록하나왜 필요한가
id행(토큰 하나)의 idreplaced_by 가 가리킬 대상
user_id토큰 주인전체 로그아웃, 세션 목록, 남의 세션 삭제 방지(7장). 사용자가 지워지면 ON DELETE CASCADE 로 토큰도 사라진다
family_id이 토큰이 속한 세션로그인 한 번에 하나. 회전해도 유지된다. 액세스 토큰의 sid 와 같은 값
token_hashsha256(token) 의 hex쿠키로 온 토큰을 찾는 열쇠. UNIQUE 제약이 인덱스를 겸한다
expires_at이 토큰 하나의 만료발급 시각 + 14일. 단 세션 상한을 넘지 않는다
session_expires_at세션 전체의 절대 상한로그인 시각 + 30일. 회전해도 바뀌지 않는다
created_at발급 시각세션 목록의 "처음 로그인"·"마지막 사용" 계산(7장)
revoked_at폐기 시각. 살아 있으면 NULL회전·로그아웃 여부. 7장의 grace 계산에 쓴다
revoke_reason왜 폐기했나rotated 인 토큰이 다시 오면 탈취 신호, 그 밖의 이유면 그냥 무효. 이 구분이 재사용 탐지의 핵심이다
replaced_by회전으로 이 토큰을 대신한 새 토큰의 id회전 사슬을 따라가며 조사할 수 있게
user_agent로그인·회전한 브라우저세션 목록에서 "어느 기기인가" 를 보여 줄 때
ip요청한 IP이상 징후 조사용. inet 타입이라 IPv4·IPv6 를 모두 담는다

revoke_reason 에 걸린 CHECK 제약은 오타를 막습니다. 코드에서 'rotate' 처럼 잘못 쓰면 INSERT·UPDATE 가 바로 실패합니다. 문자열 비교로 판단을 내리는 열이라 이런 안전장치가 값어치를 합니다.

인덱스는 세 개입니다. token_hash 의 UNIQUE 제약이 만든 인덱스는 refresh 요청마다 쓰이고, family_id 인덱스는 "이 세션의 토큰을 전부 폐기" 하는 UPDATE 에, user_id 인덱스는 전체 로그아웃과 세션 목록에 쓰입니다.

6.4 family 는 곧 세션이다 — 두 개의 만료

머리말의 용어 표에서 family 를 "로그인 한 번에서 시작해 회전으로 이어진 리프레시 토큰들의 묶음" 이라고 정의했습니다. 이 책에서는 family 와 세션을 같은 말로 씁니다. 로그인하면 family 가 하나 생기고, 로그아웃하면 그 family 가 끝납니다. 노트북과 휴대폰에서 각각 로그인하면 family 가 둘, 즉 세션이 둘입니다.

family 안의 토큰들은 모두 같은 family_id 를 가집니다. 회전하면 토큰(행)은 바뀌지만 family_id 는 그대로입니다. 그래서 family_id 를 세션 id 로 삼을 수 있고, 액세스 토큰의 sid 클레임에 이 값을 넣습니다.

세션에는 만료가 두 겹 있습니다.

시간 축에 그리면 이렇습니다.

 로그인(0일)                                                  session_expires_at(30일)
   │                                                                   │
   ├─ 토큰1 ──────────── expires_at = 0 + 14 = 14일 ─┤                  │
   │        │                                                          │
   │   3일째 회전                                                       │
   │        ├─ 토큰2 ──────────── expires_at = 3 + 14 = 17일 ─┤         │
   │                              │                                    │
   │                         16일째 회전                                │
   │                              ├─ 토큰3 ── 16 + 14 = 30일 과 30일 중 작은 값 ─┤
   │                                                                   │
   │                                                    이 시점 이후로는 회전해도
   │                                                    만료가 30일을 넘지 않는다
   0 ─────────────────────────────────────────────────────────────── 30일

슬라이딩 만료만 있으면 탈취된 토큰을 공격자가 꾸준히 회전시키는 한 세션이 영원히 살아 있습니다. 절대 상한은 그런 세션도 언젠가는 끝나게 만드는 안전장치입니다. 반대로 절대 상한만 있으면 로그인한 지 29일째에 발급받은 토큰도 하루밖에 못 쓰는 것은 같지만, 한 달 동안 방치한 기기의 토큰이 30일 내내 살아 있게 됩니다. 두 겹을 함께 두어야 "안 쓰면 14일 뒤에, 계속 써도 30일 뒤에는" 끝나는 세션이 됩니다.

토큰의 expires_at 은 항상 min(발급 시각 + 14일, session_expires_at) 입니다. 이 한 줄이 다음 절의 SQL 에 그대로 들어갑니다.

6.5 createFamily — 발급을 SQL 한 문장으로

리프레시 토큰을 DB 에 쓰는 코드는 RefreshTokenRepository 에 모읍니다. 7장에서 회전·폐기·세션 목록 메서드가 여기 더해지고, 이 장에서는 로그인 때 새 family 를 여는 createFamily 하나만 만듭니다.

// api/src/auth/refresh-token.repository.ts
import { Inject, Injectable } from '@nestjs/common';
import type { Pool } from 'pg';
import { AppConfig } from '../config/app-config';
import { PG_POOL } from '../database/database.module';

export type ClientMeta = { userAgent: string | null; ip: string | null };
export type IssuedRefresh = { familyId: string; expiresAt: Date };

@Injectable()
export class RefreshTokenRepository {
  constructor(
    @Inject(PG_POOL) private readonly pool: Pool,
    private readonly config: AppConfig,
  ) {}

  /** 로그인: 새 family(= 세션)를 연다. */
  async createFamily(userId: string, tokenHash: string, meta: ClientMeta): Promise<IssuedRefresh> {
    const { rows } = await this.pool.query<{ family_id: string; expires_at: Date }>(
      `INSERT INTO refresh_tokens
         (user_id, family_id, token_hash, expires_at, session_expires_at, user_agent, ip)
       VALUES ($1, gen_random_uuid(), $2,
               LEAST(now() + make_interval(secs => $3::float8), now() + make_interval(secs => $4::float8)),
               now() + make_interval(secs => $4::float8), $5, $6)
       RETURNING family_id, expires_at`,
      [userId, tokenHash, this.config.refreshTokenTtlSec, this.config.sessionMaxAgeSec, meta.userAgent, meta.ip],
    );
    return { familyId: rows[0].family_id, expiresAt: rows[0].expires_at };
  }
}

7장에서 이 파일의 import type { Pool } from 'pg' 는 import type { Pool, PoolClient } from 'pg' 로 바뀌고, RotateOutcome·SessionRow 타입이 더해집니다. 지금은 필요한 것만 둡니다.

UsersRepository 와 마찬가지로 3장의 PG_POOL 을 주입받아 SQL 을 직접 씁니다. 설정값은 AppConfig 에서 읽습니다. 두 타입 ClientMeta 와 IssuedRefresh 는 이 저장소의 입력과 출력입니다. ClientMeta 는 6.6절에서 다시 봅니다.

SQL 을 한 조각씩 읽어 보겠습니다.

family_id 에 gen_random_uuid(). 새 세션의 id 를 DB 가 만듭니다. Node 에서 randomUUID() 로 만들어 넘겨도 되지만, RETURNING 으로 돌려받으면 되므로 굳이 값을 하나 더 들고 다닐 이유가 없습니다. gen_random_uuid() 는 PostgreSQL 13 부터 확장 없이 쓸 수 있고, users.id 의 기본값에도 이미 쓰고 있습니다.

make_interval(secs => $3::float8). 설정의 초 단위 숫자를 PostgreSQL 의 interval 로 바꿉니다. make_interval 은 years, months, weeks, days, hours, mins, secs 인자를 받는 함수이고, secs => … 는 그중 secs 에만 값을 주는 이름 붙은 인자 문법입니다. secs 의 타입은 double precision 이므로 파라미터에 ::float8 캐스트를 붙여 PostgreSQL 이 $3 의 타입을 헷갈리지 않게 합니다. 캐스트가 없으면 드라이버가 보낸 값의 타입을 추론하지 못해 에러가 날 수 있습니다.

💡 now() + ($3 || ' seconds')::interval 처럼 문자열을 이어 붙이는 방법도 흔히 쓰이지만, 숫자를 문자열로 바꿨다가 다시 해석하는 단계가 끼어듭니다. make_interval 은 숫자를 그대로 받으므로 의도가 더 분명합니다.

LEAST(…, …). 두 값 중 작은 쪽을 고릅니다. 앞은 "지금 + 14일", 뒤는 "지금 + 30일" 입니다. 로그인 순간에는 항상 앞쪽이 작으니 LEAST 가 없어도 같은 결과가 나옵니다. 그런데도 넣어 둔 이유는 두 가지입니다. 하나는 6.4절의 규칙 expires_at = min(발급 + 14일, session_expires_at) 을 로그인과 회전(7장)에서 똑같은 모양으로 쓰기 위해서이고, 다른 하나는 설정이 바뀌었을 때를 대비하기 위해서입니다. 7장 실습에서 SESSION_MAX_AGE_SEC=20 으로 띄우면 "지금 + 20초" 가 "지금 + 14일" 보다 작아지는데, 이때 LEAST 가 없으면 세션보다 오래 사는 토큰이 생깁니다.

now() 를 세 번 쓰는데 괜찮은가. PostgreSQL 의 now() 는 트랜잭션이 시작된 시각을 돌려주고, 같은 트랜잭션 안에서는 몇 번을 불러도 같은 값입니다. 그래서 세 번의 now() 는 정확히 같은 시각이고, expires_at 과 session_expires_at 이 서로 어긋나지 않습니다.

시각을 왜 DB 가 계산하나. Node 에서 new Date(Date.now() + ttl * 1000) 을 만들어 넘길 수도 있습니다. 그러면 시각의 출처가 API 서버의 시계와 DB 서버의 시계 둘로 나뉩니다. 7장의 회전 SQL 은 expires_at <= now() 처럼 DB 의 시계로 만료를 판단하므로, 만료 시각도 DB 의 시계로 기록해야 비교가 한 시계 안에서 끝납니다. 서버가 여러 대로 늘어 각자의 시계가 몇 초씩 어긋나도 판단은 DB 한 곳에서 일관되게 내려집니다.

RETURNING family_id, expires_at. INSERT 한 행에서 두 값을 돌려받습니다. family_id 는 액세스 토큰의 sid 에, expires_at 은 쿠키의 만료 시각에 씁니다. pg 드라이버는 timestamptz 열을 JavaScript Date 로 바꿔 주므로 타입 인자에 expires_at: Date 라고 적었습니다.

6.6 요청한 기기 기억하기 — ClientMeta

세션 목록 화면(11장)에서 "Chrome on macOS · 마지막 사용 3분 전" 처럼 기기를 보여 주려면 토큰을 발급할 때 요청의 User-Agent 를 기록해 두어야 합니다. IP 도 같이 기록해 두면 이상한 곳에서 쓰인 세션을 조사할 수 있습니다. 이 둘을 담는 타입이 ClientMeta 이고, Express 요청에서 뽑아내는 함수를 파일 하나로 둡니다.

// api/src/auth/client-meta.ts
import type { Request } from 'express';
import type { ClientMeta } from './refresh-token.repository';

export function clientMeta(req: Request): ClientMeta {
  return { userAgent: req.get('user-agent')?.slice(0, 512) ?? null, ip: req.ip ?? null };
}

req.get('user-agent') 는 헤더가 없으면 undefined 를 돌려줍니다. 헤더는 클라이언트가 마음대로 보낼 수 있는 값이므로 slice(0, 512) 로 길이를 잘라 둡니다. 누군가 수 MB 짜리 User-Agent 를 보내 DB 를 채우는 것을 막는 최소한의 방어입니다. 값이 없으면 null 을 넣어 DB 에서도 NULL 이 되게 합니다.

req.ip 는 Express 가 채워 주는 클라이언트 IP 입니다. 기본 설정에서는 TCP 연결의 상대 주소입니다. 로컬에서 실습하면 ::1 이나 ::ffff:127.0.0.1 같은 값이 찍힙니다. inet 열은 두 형식을 모두 받습니다.

⚠️ 리버스 프록시(Nginx, 로드 밸런서) 뒤에 API 를 두면 req.ip 는 프록시의 주소가 됩니다. 실제 클라이언트 IP 를 얻으려면 Express 의 trust proxy 설정이 필요한데, 이 설정을 잘못 켜면 클라이언트가 보낸 X-Forwarded-For 헤더를 그대로 믿게 되어 IP 를 위조할 수 있게 됩니다. 로컬 실습에서는 프록시가 없으므로 켜지 않습니다. 14장에서 다룹니다.

이 함수를 컨트롤러가 아니라 별도 파일에 둔 이유는 로그인(이 장)과 refresh(7장) 두 곳에서 똑같이 쓰기 때문입니다.

6.7 쿠키에 담아 보내기 — cookies.ts

리프레시 토큰은 쿠키로만 오갑니다. 쿠키 이름과 속성을 한 파일에 모아 둡니다.

// api/src/auth/cookies.ts
import type { CookieOptions, Response } from 'express';
import type { AppConfig } from '../config/app-config';

export const REFRESH_COOKIE = 'refresh_token';

const refreshOptions = (config: AppConfig): CookieOptions => ({
  httpOnly: true,
  secure: config.cookieSecure,
  sameSite: 'strict',
  path: '/auth',
});

export function setRefreshCookie(res: Response, token: string, expiresAt: Date, config: AppConfig) {
  res.cookie(REFRESH_COOKIE, token, { ...refreshOptions(config), expires: expiresAt });
}

/** 설정할 때와 같은 path·sameSite·secure 여야 브라우저가 같은 쿠키로 보고 지운다. */
export function clearRefreshCookie(res: Response, config: AppConfig) {
  res.clearCookie(REFRESH_COOKIE, refreshOptions(config));
}

9장에서 CSRF 토큰 쿠키를 위한 CSRF_COOKIE 상수와 setCsrfCookie 함수가 이 파일에 더해집니다. 지금은 리프레시 쿠키만 다룹니다.

코드의 모양부터 보겠습니다.

쿠키 이름을 상수로. REFRESH_COOKIE 는 쿠키를 설정할 때, 지울 때, 요청에서 읽을 때(7장의 req.cookies?.[REFRESH_COOKIE]) 세 곳에서 쓰입니다. 문자열을 세 번 쓰면 언젠가 한 곳만 바뀝니다.

속성을 함수 하나로. refreshOptions 는 설정할 때와 지울 때 똑같은 속성을 쓰게 하려고 만든 함수입니다. 브라우저는 쿠키를 이름만으로 구별하지 않고 이름·도메인·경로를 묶어서 구별합니다. 그래서 Path=/auth 로 설정한 쿠키를 Path=/ 로 지우라고 하면 브라우저는 "그런 쿠키는 없다" 며 아무것도 지우지 않습니다. 에러도 나지 않으므로 로그아웃했는데 쿠키가 남아 있는 버그가 조용히 생깁니다. 두 함수가 같은 refreshOptions 를 쓰면 이런 어긋남이 생길 수 없습니다.

secure 만 설정에서 읽는다. 나머지 속성은 설계로 고정하고, Secure 만 COOKIE_SECURE 환경변수로 바꿀 수 있게 했습니다. 이유는 6.8절에서 봅니다.

expires 는 설정할 때만. setRefreshCookie 는 refreshOptions 에 expires 를 더합니다. 지울 때는 필요 없습니다. Express 의 res.clearCookie 는 같은 이름·경로로 값이 비고 만료 시각이 1970년인 쿠키를 내려보내서 브라우저가 지우게 만듭니다.

import type. 이 파일은 AppConfig 를 타입으로만 씁니다. import type 으로 적으면 컴파일 결과에서 import 가 사라져 불필요한 모듈 의존이 생기지 않습니다.

clearRefreshCookie 는 이 장에서는 아직 부르는 곳이 없습니다. 7장의 refresh 실패와 로그아웃에서 씁니다. 설정 함수와 지우는 함수가 같은 속성을 공유한다는 점을 한 파일에서 보여 주려고 지금 함께 만듭니다.

6.8 쿠키 속성을 하나씩 정하다

이제 속성 하나하나를 왜 그 값으로 정했는지 봅니다. 1장에서 각 속성의 뜻을 배웠으니 여기서는 이 설계에서의 선택에 집중합니다. 최종적으로 브라우저가 받는 헤더는 이런 모양입니다(예시).

Set-Cookie: refresh_token=q3N1…8fQ; Path=/auth; Expires=Thu, 08 Oct 2026 03:12:45 GMT; HttpOnly; Secure; SameSite=Strict

HttpOnly — JavaScript 가 읽지 못하게

HttpOnly 가 붙은 쿠키는 document.cookie 로 읽을 수 없습니다. 브라우저가 요청에 실어 보내기만 합니다. FE 코드에 XSS 취약점이 하나 있어 공격자의 스크립트가 페이지에서 돌더라도, 그 스크립트는 리프레시 토큰 값을 읽어서 자기 서버로 보낼 수 없습니다.

FE 는 리프레시 토큰을 읽을 일이 애초에 없습니다. refresh 요청을 보낼 때 credentials: 'include' 만 주면 브라우저가 알아서 쿠키를 붙입니다(11·12장). 읽을 필요가 없는 값은 읽을 수 없게 두는 것이 원칙입니다.

⚠️ HttpOnly 가 XSS 를 막아 주지는 않습니다. 공격자 스크립트는 토큰을 읽지는 못해도, 그 페이지 안에서 fetch('/auth/refresh', { credentials: 'include' }) 를 불러 새 액세스 토큰을 받는 것은 할 수 있습니다. HttpOnly 가 막는 것은 "토큰을 들고 나가서 공격자의 기기에서 두고두고 쓰는 것" 입니다. XSS 자체는 CSP 와 출력 이스케이프로 따로 막아야 합니다.

Secure — HTTPS 에서만, 그리고 localhost 예외

Secure 가 붙은 쿠키는 HTTPS 요청에만 실립니다. 공용 와이파이처럼 네트워크를 엿볼 수 있는 곳에서 평문 HTTP 요청에 토큰이 실려 새는 것을 막습니다. 운영에서는 당연히 켭니다.

그런데 실습 환경은 http://localhost:4000 입니다. HTTPS 가 아닌데 Secure 쿠키가 동작할까요? Chrome 은 http://localhost 를 안전한 출처로 간주하는 예외를 두고 있어서, localhost 에서는 HTTP 여도 Secure 쿠키를 받고 보냅니다. 그래서 .env 의 COOKIE_SECURE=true 를 그대로 두고 실습할 수 있고, 운영과 같은 속성으로 테스트하게 됩니다. 다른 브라우저의 localhost 처리는 브라우저마다 다를 수 있으니 이 책의 실습은 Chrome 기준으로 합니다. 다른 브라우저를 쓴다면 그 브라우저의 문서에서 localhost 와 Secure 쿠키의 관계를 확인하세요.

COOKIE_SECURE 를 환경변수로 뺀 이유는 이 예외가 통하지 않는 환경을 위해서입니다. 예를 들어 사내 테스트 서버를 http://test-server:4000 처럼 localhost 가 아닌 이름의 HTTP 로 띄우면 Secure 쿠키가 저장되지 않습니다. 그럴 때만 false 로 끄고, 운영에서는 절대 끄지 않습니다.

SameSite=Strict — 그래도 FE 의 fetch 에는 실린다

SameSite=Strict 는 다른 사이트에서 시작된 요청에 쿠키를 싣지 않습니다. 1장에서 배운 대로 사이트는 스킴과 등록 가능한 도메인으로 정해지고 포트는 보지 않습니다. 이것이 이 설계가 Strict 를 쓸 수 있는 이유입니다.

  FE 페이지  http://localhost:5173   ─┐
                                      ├─ 스킴 http, 도메인 localhost → 같은 사이트
  API        http://localhost:4000   ─┘

  FE 페이지에서 fetch('http://localhost:4000/auth/refresh', { credentials: 'include' })
    → 요청을 시작한 문서(localhost:5173)와 대상(localhost:4000)이 같은 사이트
    → SameSite=Strict 쿠키가 실린다

FE 와 API 는 출처는 다르지만 사이트는 같습니다. 그래서 CORS 는 필요하지만(6.11절) SameSite=Strict 는 문제가 되지 않습니다. 운영에서도 app.example.com 과 api.example.com 처럼 같은 등록 도메인의 서브도메인으로 두면 같은 관계가 됩니다.

Strict 가 흔히 불편을 주는 경우는 다른 사이트에서 링크를 타고 들어올 때입니다. 메일에 있는 링크를 눌러 사이트로 들어오면 그 첫 페이지 요청에는 Strict 쿠키가 실리지 않아서, 전통적인 세션 쿠키 방식의 사이트라면 로그아웃된 것처럼 보입니다. 하지만 이 설계에서 리프레시 쿠키를 쓰는 요청은 페이지 이동이 아니라 FE 페이지가 뜬 뒤에 보내는 fetch 입니다. 그 fetch 는 FE 페이지(localhost:5173)에서 시작되므로 같은 사이트 요청이고, 쿠키가 실립니다. 메일 링크로 들어와도 FE 가 뜨자마자 부르는 refresh 는 정상적으로 동작합니다.

Strict 는 9장에서 CSRF 방어의 첫 겹이 됩니다. 다만 같은 사이트의 다른 출처(예: localhost:5174 에 뜬 공격 페이지)는 막지 못한다는 한계도 9장에서 직접 확인합니다.

Path=/auth — 필요한 경로에만

Path=/auth 로 두면 브라우저는 경로가 /auth 이거나 /auth/ 로 시작하는 요청에만 이 쿠키를 싣습니다. /auth/refresh, /auth/logout 에는 실리고, /users/me 에는 실리지 않습니다.

리프레시 토큰이 필요한 엔드포인트는 전부 /auth 아래에 있습니다. 그 밖의 API 요청에 쿠키가 실려 봐야 쓸모가 없고, 요청이 거쳐 가는 곳(리버스 프록시의 접근 로그, APM 도구, 에러 수집기)에 토큰이 노출될 기회만 늘어납니다. 쿠키가 실리는 요청의 수를 줄이는 것 자체가 방어입니다.

여기에는 포기한 것이 하나 있습니다. 쿠키 이름 앞에 __Host- 를 붙이면(__Host-refresh_token) 브라우저가 Secure 필수, Domain 속성 금지, Path=/ 필수를 강제합니다. 같은 사이트의 다른 서브도메인이 같은 이름의 쿠키를 심는 쿠키 주입에 가장 강한 형태입니다. 그런데 Path=/ 가 필수라서 /auth 로 좁힐 수 없습니다.

선택얻는 것잃는 것
__Host-refresh_token; Path=/서브도메인의 쿠키 주입을 브라우저가 차단모든 API 요청에 토큰이 실린다
refresh_token; Path=/auth (이 책)/auth/* 밖의 요청·로그에 토큰이 안 실린다__Host- 의 주입 차단이 없다

이 책은 경로 제한을 골랐습니다. 리프레시 쿠키에 대한 쿠키 주입은 공격자가 자기 토큰을 피해자 브라우저에 심는 형태인데, 그 결과는 피해자가 공격자 계정으로 로그인되는 것이라 피해가 제한적이고, 7장의 재사용 탐지와 9장의 CSRF 방어가 추가로 막아 줍니다. 반면 모든 요청에 토큰이 실리는 것은 로그·프록시를 통한 유출 경로를 상시로 늘립니다. 판단이 갈릴 수 있는 문제이고, 14장에서 운영 구성에 따라 다시 생각해 볼 거리로 남깁니다.

Domain 속성은 붙이지 않습니다. Domain 이 없으면 쿠키를 설정한 호스트(API 호스트)에만 저장되는 host-only 쿠키가 됩니다. Domain=example.com 을 붙이면 blog.example.com 을 포함한 모든 서브도메인에 쿠키가 실립니다.

Expires — DB 의 만료와 같은 시각

expires 에는 createFamily 가 돌려준 expires_at, 즉 DB 에 기록한 그 토큰의 만료 시각을 그대로 넣습니다. 쿠키의 수명과 토큰의 수명을 일치시키는 것입니다.

일치시키지 않으면 어떻게 될까요. 쿠키가 토큰보다 오래 살면 브라우저는 이미 죽은 토큰을 계속 보내고, 서버는 매번 DB 를 조회한 뒤 401 을 돌려줍니다. 쿠키가 토큰보다 먼저 죽으면 멀쩡한 세션이 브라우저 쪽 사정으로 끊깁니다. 세션 절대 상한에 가까워져 토큰의 expires_at 이 14일보다 짧아진 경우(6.4절의 토큰3)에도 DB 값을 쓰면 쿠키가 알아서 같이 짧아집니다.

expires 옵션을 주면 Express 는 Expires=<날짜> 속성을 씁니다. 날짜는 절대 시각이므로 사용자 기기의 시계가 크게 틀려 있으면 브라우저가 쿠키를 예상보다 일찍 또는 늦게 버릴 수 있습니다. 그래도 토큰이 살았는지 죽었는지의 최종 판단은 언제나 DB 가 하므로 보안상 문제는 생기지 않습니다. 쿠키의 만료는 "죽은 토큰을 보내지 않게 하는 편의" 이고, 방어는 서버의 몫입니다.

속성을 모아 보면 다음과 같습니다.

속성값한 줄 이유
HttpOnly켬XSS 스크립트가 토큰을 읽어 가지 못하게
SecureCOOKIE_SECURE (기본 켬)HTTPS 에서만. Chrome 은 localhost 를 예외로 허용
SameSiteStrictFE 와 API 가 같은 사이트라 가능. CSRF 첫 겹
Path/auth필요한 요청에만 실리게. __Host- 는 포기
Domain없음API 호스트 전용
ExpiresDB 의 expires_at쿠키와 토큰의 수명을 일치

6.9 AuthService.login 을 고치다 — issue() 와 Issued

이제 로그인이 리프레시 토큰을 만들도록 AuthService 를 고칩니다. 바뀌는 점은 세 가지입니다.

  1. 생성자에 RefreshTokenRepository 가 들어온다.
  2. login 이 ClientMeta 를 받고, 리프레시 토큰을 만들어 저장한다.
  3. 반환 값이 응답 본문(TokenResponseDto) 하나가 아니라 본문과 쿠키에 넣을 값의 묶음(Issued)이 된다.
// api/src/auth/auth.service.ts
import { Injectable } from '@nestjs/common';
import { AppError, ErrorCode } from '../common/errors';
import { AppConfig } from '../config/app-config';
import { UserDto, toUserDto } from '../users/dto';
import { UserRow, UsersRepository } from '../users/users.repository';
import { LoginDto, SignupDto, TokenResponseDto } from './dto';
import { PasswordService } from './password.service';
import { ClientMeta, RefreshTokenRepository } from './refresh-token.repository';
import { TokenService } from './token.service';

export type Issued = {
  body: TokenResponseDto;
  refresh: { token: string; expiresAt: Date };
};

@Injectable()
export class AuthService {
  constructor(
    private readonly users: UsersRepository,
    private readonly passwords: PasswordService,
    private readonly tokens: TokenService,
    private readonly refreshTokens: RefreshTokenRepository,
    private readonly config: AppConfig,
  ) {}

  // … signup 은 5장 그대로 …

  async login(dto: LoginDto, meta: ClientMeta): Promise<Issued> {
    const user = await this.users.findByEmail(dto.email);
    const ok = user
      ? await this.passwords.verify(user.password_hash, dto.password)
      : await this.passwords.burn(dto.password);
    if (!user || !ok) {
      throw new AppError(401, ErrorCode.INVALID_CREDENTIALS, '이메일 또는 비밀번호가 맞지 않다');
    }

    const token = this.tokens.newRefreshToken();
    const { familyId, expiresAt } = await this.refreshTokens.createFamily(
      user.id,
      this.tokens.hashRefreshToken(token),
      meta,
    );
    return this.issue(user, familyId, { token, expiresAt });
  }

  private async issue(user: UserRow, familyId: string, refresh: Issued['refresh']): Promise<Issued> {
    const accessToken = await this.tokens.signAccessToken(user.id, familyId);
    return {
      body: { accessToken, tokenType: 'Bearer', expiresIn: this.config.accessTokenTtlSec, user: toUserDto(user) },
      refresh,
    };
  }
}

5장과 비교해 import 가 세 군데 바뀌었습니다. 임시 세션 id 를 만들던 import { randomUUID } from 'node:crypto' 는 더 쓰지 않으므로 지웠고, issue 의 인자 타입으로 쓰려고 UsersRepository 옆에 UserRow 타입을 함께 가져오며, 새 저장소와 ClientMeta 타입을 가져옵니다. 7장에서 이 파일에 refresh·logout·logoutAll·listSessions·deleteSession 이 더해지고, import 에 SessionDto 와 AuthUser 가 들어옵니다.

앞부분은 5장 그대로

사용자 조회, 비밀번호 검증, 없는 사용자일 때의 burn, 실패 시 INVALID_CREDENTIALS 는 5장과 같습니다. 리프레시 토큰은 인증이 끝난 뒤에만 만듭니다. 비밀번호가 틀렸는데 토큰 행이 생기는 일은 없어야 합니다.

토큰을 만들고, 해시만 넘긴다

newRefreshToken() 으로 평문 토큰을 만들고, 저장소에는 hashRefreshToken(token) 결과만 넘깁니다. 저장소의 createFamily 는 평문을 받을 방법이 없습니다. 인자 이름이 tokenHash 인 것도 그 뜻입니다. 평문 token 은 지역 변수로 남아 issue 를 거쳐 컨트롤러까지 가고, 거기서 쿠키에 실린 뒤 사라집니다.

sid 가 familyId 가 된다

5장에서 signAccessToken(user.id, randomUUID()) 처럼 임시값을 넣던 자리에 이제 createFamily 가 돌려준 familyId 가 들어갑니다. 이 한 줄로 액세스 토큰과 리프레시 세션이 연결됩니다.

  액세스 토큰(JWT) 본문                         refresh_tokens
  ─────────────────────                        ─────────────────────────────
  { "sub": "7c1e…",   ← users.id               user_id   = 7c1e…
    "sid": "a9f0…",   ← ─────────────────────▶ family_id = a9f0…
    "jti": "…", "exp": … }                     token_hash = 5d2b… (평문 없음)

이 연결이 쓸모 있는 곳은 7장입니다. 세션 목록에서 "지금 이 요청을 보낸 세션" 을 표시할 때 액세스 토큰의 sid 와 family_id 를 비교하고, 필요하면 가드에서 sid 로 세션이 살아 있는지 조회할 수도 있습니다.

issue() — 로그인과 refresh 가 같은 모양을 돌려주게

액세스 토큰을 서명하고 응답 본문을 조립하는 부분을 issue 라는 private 메서드로 뺐습니다. 7장의 refresh 도 성공하면 똑같이 "새 액세스 토큰 + 사용자 정보 + 새 리프레시 토큰" 을 돌려줘야 하기 때문입니다. 둘이 같은 함수를 거치면 응답 모양이 어긋날 수 없고, FE 는 로그인 응답과 refresh 응답을 같은 코드로 처리할 수 있습니다(11장).

issue 의 세 번째 인자 타입 Issued['refresh'] 는 Issued 타입의 refresh 속성 타입, 즉 { token: string; expiresAt: Date } 입니다. 같은 모양을 두 번 적지 않으려고 이렇게 참조했습니다.

Issued — 서비스는 HTTP 를 모른다

login 이 쿠키를 직접 설정하지 않고 Issued 를 돌려주는 이유는 역할을 나누기 위해서입니다.

서비스가 Express 의 Response 를 알면 테스트할 때도 가짜 Response 를 만들어야 하고, 전송 방식을 바꿀 때(예를 들어 모바일 앱용으로 쿠키 대신 본문에 담는 엔드포인트를 따로 둘 때) 서비스까지 고쳐야 합니다. Issued 에서 body 는 그대로 응답 본문이 되고, refresh 는 컨트롤러가 쿠키로 바꿉니다.

모듈에 등록하기

AuthService 가 RefreshTokenRepository 를 주입받으려면 AuthModule 의 providers 에 저장소가 있어야 합니다.

// api/src/auth/auth.module.ts
// …
import { RefreshTokenRepository } from './refresh-token.repository';
// …
  providers: [AuthService, PasswordService, RefreshTokenRepository, LoginThrottlerGuard],
// …

9장에서 CsrfService·CsrfGuard 가 이 배열에 더해지고, 실습실 모듈을 위한 exports 도 채워집니다. 최종 모습은 부록 C 에 있습니다.

6.10 컨트롤러 — 본문과 쿠키를 나눠 보내기

컨트롤러의 login 핸들러를 고칩니다.

// api/src/auth/auth.controller.ts
import { Body, Controller, HttpCode, Post, Req, Res, UseGuards } from '@nestjs/common';
// … @nestjs/swagger import 는 5장 그대로 …
import type { Request, Response } from 'express';
import { AppConfig } from '../config/app-config';
import { AuthService } from './auth.service';
import { clientMeta } from './client-meta';
import { setRefreshCookie } from './cookies';
// … 나머지 import 는 5장 그대로 …

@ApiTags('auth')
@Controller('auth')
export class AuthController {
  constructor(
    private readonly auth: AuthService,
    private readonly config: AppConfig,
  ) {}

  // … signup 은 5장 그대로 …

  @Post('login')
  @HttpCode(200)
  @UseGuards(LoginThrottlerGuard)
  @ApiOperation({ summary: '로그인', description: '액세스 토큰은 본문으로, 리프레시 토큰은 HttpOnly 쿠키로 준다.' })
  @ApiOkResponse({ type: TokenResponseDto })
  @ApiUnauthorizedResponse({ type: ErrorResponseDto, description: 'INVALID_CREDENTIALS' })
  @ApiBadRequestResponse({ type: ErrorResponseDto, description: 'VALIDATION_FAILED' })
  @ApiTooManyRequestsResponse({ type: ErrorResponseDto, description: 'TOO_MANY_REQUESTS' })
  async login(
    @Body() dto: LoginDto,
    @Req() req: Request,
    @Res({ passthrough: true }) res: Response,
  ): Promise<TokenResponseDto> {
    res.setHeader('Cache-Control', 'no-store');
    const { body, refresh } = await this.auth.login(dto, clientMeta(req));
    setRefreshCookie(res, refresh.token, refresh.expiresAt, this.config);
    return body;
  }
}

생성자에 AppConfig 가 들어왔습니다. 쿠키의 secure 값을 읽어야 하기 때문입니다. 9장에서 이 생성자의 가운데에 CsrfService 가 끼어들어 auth, csrf, config 순서가 됩니다. @UseGuards 에도 9장에서 CsrfGuard 가 더해지고, @ApiSecurity('csrf') 와 CSRF 에러 문서화가 붙습니다. 지금은 로그인 제한 가드만 있습니다.

@Req — 요청 전체가 필요할 때

@Body() 는 요청 본문만 꺼내 줍니다. clientMeta 는 User-Agent 헤더와 IP 가 필요하므로 Express 의 요청 객체 자체를 받습니다. @Req() 가 그 일을 합니다. 타입은 express 패키지의 Request 입니다. 브라우저 전역의 Request(fetch API)와 이름이 같으니 반드시 import type { Request, Response } from 'express' 로 가져와야 합니다.

@Res({ passthrough: true }) — 응답을 조금만 만지기

쿠키를 설정하려면 Express 의 응답 객체가 필요합니다. @Res() 로 받을 수 있는데, 여기에는 주의할 점이 있습니다.

Nest 는 핸들러에 @Res() 가 있으면 "이 핸들러는 응답을 직접 보내겠구나" 라고 판단하고 반환 값을 응답으로 쓰지 않습니다. 그래서 @Res() 만 쓰고 return body 를 하면 응답이 영영 가지 않아 요청이 멈춥니다. 직접 res.json(body) 를 불러야 합니다. 그러면 @HttpCode(200), 인터셉터, Nest 의 직렬화 같은 기능도 우회하게 됩니다.

{ passthrough: true } 는 이 판단을 바꿉니다. "응답 객체를 쓰긴 하지만 헤더나 쿠키만 만지고, 본문은 평소처럼 반환 값으로 보내 달라" 는 뜻입니다. 그래서 핸들러는 res.setHeader 와 res.cookie 로 헤더만 설정하고, 본문은 return body 로 넘깁니다. @HttpCode(200) 도 그대로 적용됩니다.

받는 방식반환 값상태 코드·인터셉터
@Res()무시된다. res.json() 을 직접 불러야 한다직접 처리
@Res({ passthrough: true })평소처럼 응답 본문이 된다Nest 가 처리

핸들러 안에서 예외를 던져도 Nest 의 예외 필터가 평소처럼 에러 응답을 만듭니다. 7장의 refresh 는 이 점을 이용해 "쿠키를 지우는 헤더를 설정한 뒤 예외를 던지는" 코드를 씁니다.

Cache-Control: no-store — 토큰이 든 응답은 저장하지 않는다

로그인 응답 본문에는 액세스 토큰이, 헤더에는 리프레시 토큰이 들어 있습니다. 이런 응답이 브라우저 캐시나 중간 프록시의 캐시에 남으면 곤란합니다. no-store 는 "이 응답을 어디에도 저장하지 말라" 는 가장 강한 캐시 지시입니다.

POST 응답은 원래 캐시되는 경우가 드물지만, 드물다는 것과 절대 없다는 것은 다릅니다. 토큰이 든 응답에는 예외 없이 붙인다는 규칙으로 둡니다. 7장의 refresh 에도, 9장의 CSRF 토큰 발급에도 같은 헤더를 붙입니다.

헤더를 this.auth.login(…) 앞에서 설정한 것도 의도입니다. 로그인이 실패해 예외가 나도 에러 응답에 no-store 가 붙습니다. 에러 응답에는 토큰이 없으니 꼭 필요하지는 않지만, "이 경로의 응답은 전부 저장하지 않는다" 가 더 단순한 규칙입니다.

setRefreshCookie 와 return body

서비스가 돌려준 Issued 를 구조 분해해서 refresh 는 쿠키로, body 는 반환 값으로 보냅니다. 이것으로 머리말 그림의 ② 가 완성됩니다. 액세스 토큰은 FE 가 메모리에 담을 수 있도록 본문으로, 리프레시 토큰은 FE 도 못 만지도록 HttpOnly 쿠키로.

6.11 CORS 에서 credentials: true 가 하는 일

FE(localhost:5173)와 API(localhost:4000)는 출처가 다르므로 FE 의 fetch 는 CORS 규칙을 따릅니다. 3장에서 main.ts 에 넣어 둔 CORS 설정을 다시 봅니다. 그때 "6장부터 필요하다" 고 미뤄 둔 credentials: true 가 이제 일을 하기 시작합니다.

// api/src/main.ts
  app.enableCors({
    origin: config.webOrigins, // 명시적인 배열. true(요청 출처 반사)나 '*' 를 쓰지 않는다
    credentials: true,
    methods: ['GET', 'POST', 'PATCH', 'DELETE'],
    allowedHeaders: ['Content-Type', 'Authorization', 'X-CSRF-Token'],
  });

X-CSRF-Token 헤더는 9장에서 쓰기 시작하지만, 허용 목록에 미리 있어도 문제는 없습니다.

리프레시 쿠키가 동작하려면 FE 와 API 가 짝을 이루는 두 설정이 필요합니다.

 FE (11장)                                         API (main.ts)
 ─────────                                         ────────────
 fetch(url, { credentials: 'include' })    ◀──짝──▶ enableCors({ credentials: true,
   → 다른 출처 요청에도 쿠키를 싣고,                           origin: [명시적 목록] })
     응답의 Set-Cookie 를 받아들이겠다                 → Access-Control-Allow-Credentials: true
                                                     → Access-Control-Allow-Origin: http://localhost:5173

FE 가 credentials: 'include' 로 다른 출처에 요청하면, 브라우저는 응답에 Access-Control-Allow-Credentials: true 가 있고 Access-Control-Allow-Origin 이 요청한 출처와 정확히 같을 때만 그 응답을 JavaScript 에 넘겨줍니다. 둘 중 하나라도 빠지면 fetch 는 CORS 에러로 실패합니다. credentials: true 는 API 가 이 헤더를 붙이게 하는 설정입니다.

origin 에 '*' 를 쓸 수 없는 이유도 여기에 있습니다. 자격 증명을 싣는 요청에는 와일드카드 * 응답이 허용되지 않습니다. 그렇다고 origin: true 로 두면 cors 미들웨어가 요청의 Origin 을 그대로 복사해 돌려주므로, 어떤 사이트에서 온 요청이든 자격 증명을 실은 응답을 읽을 수 있게 됩니다. 사실상 CORS 를 끈 것과 같습니다. 그래서 WEB_ORIGINS 환경변수에서 읽은 명시적인 목록을 넘깁니다.

💡 CORS 와 쿠키 전송의 세부 규칙(어떤 경우에 쿠키가 저장되지 않는지 등)은 브라우저 구현에 따라 조금씩 다를 수 있습니다. 이 책은 credentials: 'include' 와 credentials: true 를 항상 짝으로 두는 것으로 이 문제를 피합니다. 자세한 동작은 MDN 의 CORS 문서에서 확인하세요.

6.12 실행해 보기 — curl 로 헤더 읽기

API 를 다시 띄우고 로그인해 봅니다. 브라우저 개발자 도구 대신 curl 로 확인하는 이유는 헤더를 있는 그대로 볼 수 있기 때문입니다. 개발자 도구는 편하지만 쿠키를 저장했는지, 왜 거부했는지를 해석해서 보여 주므로 처음 배울 때는 원래 모습을 한 번 보는 것이 좋습니다.

4장에서 가입해 둔 계정이 있다고 가정합니다. 없다면 먼저 가입합니다.

$ curl -s -X POST http://localhost:4000/auth/signup \
    -H 'Content-Type: application/json' \
    -d '{"email":"kim@example.com","password":"correct horse battery","nickname":"김개발"}'

-i 로 응답 헤더 보기

$ JAR=$(mktemp)
$ curl -s -i -c "$JAR" -X POST http://localhost:4000/auth/login \
    -H 'Content-Type: application/json' \
    -d '{"email":"kim@example.com","password":"correct horse battery"}'

옵션부터 읽어 보겠습니다.

옵션뜻
-s진행률 표시를 끈다
-i응답 헤더를 본문 앞에 함께 출력한다
-c "$JAR"응답의 Set-Cookie 를 파일(쿠키 저장소)에 저장한다. 브라우저의 쿠키 저장소 역할
-b "$JAR"저장소의 쿠키를 요청에 싣는다(7장에서 씀). 경로·만료·Secure 규칙을 따진다
-v요청 헤더(> 로 시작)와 응답 헤더(< 로 시작)를 모두 보여 준다. 무엇을 보냈는지 의심될 때

예시 출력입니다. helmet 이 붙이는 보안 헤더 여러 줄은 줄였습니다.

HTTP/1.1 200 OK
Content-Security-Policy: default-src 'self';…
…(helmet 이 붙인 보안 헤더 생략)…
Access-Control-Allow-Credentials: true
Cache-Control: no-store
Set-Cookie: refresh_token=q3N1…8fQ; Path=/auth; Expires=Thu, 08 Oct 2026 03:12:45 GMT; HttpOnly; Secure; SameSite=Strict
Content-Type: application/json; charset=utf-8
Content-Length: 512

{"accessToken":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzaWQiOiJh…","tokenType":"Bearer","expiresIn":600,"user":{"id":"7c1e…","email":"kim@example.com","nickname":"김개발","createdAt":"2026-09-24T03:10:02.114Z"}}

읽는 순서는 이렇습니다.

  1. 첫 줄 HTTP/1.1 200 OK — 상태 코드. 로그인은 @HttpCode(200) 이라 201 이 아니라 200 입니다.
  2. Cache-Control: no-store — 컨트롤러가 붙인 헤더.
  3. Set-Cookie: 한 줄 — ; 로 나눠 읽습니다. 맨 앞 이름=값, 이어서 Path, Expires, HttpOnly, Secure, SameSite. Expires 가 지금부터 14일 뒤인지 확인합니다.
  4. 빈 줄 다음이 본문 — accessToken 이 있고, refresh_token 은 본문에 없습니다.

예시 출력에는 Access-Control-Allow-Credentials: true 도 있지만 Access-Control-Allow-Origin 은 없습니다. curl 이 Origin 헤더를 보내지 않았기 때문입니다(Origin 이 없는 요청에 cors 미들웨어가 어떤 헤더를 붙이는지는 버전에 따라 다를 수 있으니 실제 출력을 기준으로 보세요). -H 'Origin: http://localhost:5173' 을 더해서 다시 보내면 그 줄이 생기는 것을 확인할 수 있습니다.

쿠키 저장소 파일 들여다보기

$ cat "$JAR"
# Netscape HTTP Cookie File
# https://curl.se/docs/http-cookies.html
# This file was generated by libcurl! Edit at your own risk.

#HttpOnly_localhost	FALSE	/auth	TRUE	1791429165	refresh_token	q3N1…8fQ

(예시 출력) 탭으로 나뉜 일곱 칸은 차례로 도메인, 서브도메인 포함 여부, 경로, Secure 여부, 만료 시각(유닉스 초), 이름, 값입니다. HttpOnly 쿠키는 도메인 앞에 #HttpOnly_ 가 붙습니다. 경로가 /auth, Secure 가 TRUE 인 것을 확인합니다.

⚠️ 이 줄이 저장소에 없다면 curl 이 Secure 쿠키를 받아들이지 않은 것입니다. 최근 curl 은 브라우저처럼 localhost 를 안전한 출처로 취급하지만, 오래된 curl 은 그렇지 않을 수 있습니다. curl --version 으로 버전을 확인하고, curl 공식 문서의 쿠키 설명을 보세요.

값을 셸 변수로 꺼내 두면 뒤의 확인이 쉬워집니다.

$ RT=$(awk '$6 == "refresh_token" { print $7 }' "$JAR")
$ echo "${#RT}"
43

43글자, 32바이트 base64url 입니다.

DB 에는 해시만 있는지 확인하기

쿠키 값의 SHA-256 을 직접 계산해 봅니다. macOS 는 shasum -a 256, 리눅스는 sha256sum 을 씁니다.

$ printf '%s' "$RT" | shasum -a 256
5d2b9e0c47f1…a4e3  -

printf '%s' 를 쓰는 이유는 echo 가 끝에 줄바꿈을 붙이기 때문입니다. 줄바꿈 하나가 섞이면 전혀 다른 해시가 나옵니다.

이제 DB 를 봅니다.

$ pnpm db:psql
auth_lab=# SELECT left(token_hash, 12) AS hash, family_id, expires_at, session_expires_at,
                  revoked_at, user_agent, ip
             FROM refresh_tokens ORDER BY created_at DESC LIMIT 1;
     hash     |              family_id               |          expires_at           |      session_expires_at       | revoked_at | user_agent  |    ip
--------------+--------------------------------------+-------------------------------+-------------------------------+------------+-------------+-----------
 5d2b9e0c47f1 | a9f03c1e-…                           | 2026-10-08 03:12:45.51+00     | 2026-10-24 03:12:45.51+00     |            | curl/8.7.1  | ::1
(1 row)

(예시 출력) 확인할 점은 다섯 가지입니다.

평문으로 검색해도 나오지 않는 것도 확인해 둡니다.

auth_lab=# SELECT count(*) FROM refresh_tokens WHERE token_hash = 'q3N1…8fQ';  -- 쿠키 값 그대로
 count
-------
     0

sid 와 family_id 가 같은지 확인하기

응답 본문의 액세스 토큰을 풀어 sid 를 봅니다. JWT 의 가운데 조각은 base64url 이므로 Node 로 푸는 것이 가장 확실합니다.

$ ACCESS=$(curl -s -c "$JAR" -X POST http://localhost:4000/auth/login \
    -H 'Content-Type: application/json' \
    -d '{"email":"kim@example.com","password":"correct horse battery"}' | jq -r .accessToken)
$ node -e 'console.log(JSON.parse(Buffer.from(process.argv[1].split(".")[1], "base64url")))' "$ACCESS"
{
  sid: 'b41d77a2-…',
  iat: 1790219890,
  exp: 1790220490,
  aud: 'auth-lab-web',
  iss: 'auth-lab-api',
  sub: '7c1e…',
  jti: '…'
}

(예시 출력) 로그인을 한 번 더 했으므로 새 family 가 생겼습니다. psql 에서 가장 최근 행의 family_id 가 이 sid 와 같은지 봅니다.

auth_lab=# SELECT family_id FROM refresh_tokens ORDER BY created_at DESC LIMIT 1;

같은 사용자가 로그인할 때마다 family_id 가 다른 행이 하나씩 늘어나는 것도 보입니다. 로그인 한 번이 세션 하나입니다.

⚠️ 로그인은 IP 당 1분에 5회로 제한되어 있습니다(5장). 실습하다가 429 TOO_MANY_REQUESTS 가 나오면 1분 쉬었다가 다시 합니다.

확인하기

API 를 띄운 상태에서 아래를 차례로 실행합니다. 모두 기대대로 나와야 7장으로 넘어갑니다.

#명령기대 결과
1curl -s -i -c "$JAR" -X POST localhost:4000/auth/login -H 'Content-Type: application/json' -d '{…}'200, Cache-Control: no-store, Set-Cookie: refresh_token=…; Path=/auth; Expires=…; HttpOnly; Secure; SameSite=Strict
21의 응답 본문accessToken, tokenType, expiresIn, user 가 있고 리프레시 토큰은 없다
3cat "$JAR"#HttpOnly_localhost … /auth TRUE … refresh_token … 한 줄
4printf '%s' "$RT" | shasum -a 256 과 psql 의 token_hash같은 값
5psql 의 expires_at·session_expires_at각각 약 14일 뒤, 30일 뒤
6액세스 토큰의 sid 와 psql 의 family_id같은 값
7틀린 비밀번호로 로그인401 INVALID_CREDENTIALS, Set-Cookie 없음, refresh_tokens 행이 늘지 않음

자주 하는 실수

@Res() 에 passthrough 를 빠뜨렸다. 요청을 보내면 응답이 오지 않고 curl 이 계속 기다립니다. 서버 로그에는 에러도 없습니다. Nest 가 "응답은 핸들러가 직접 보낸다" 고 판단했기 때문입니다. @Res({ passthrough: true }) 로 고칩니다.

Request·Response 를 express 에서 가져오지 않았다. import 를 빠뜨리면 TypeScript 가 전역의 fetch API Request·Response 로 해석합니다. res.cookie 가 없다는 타입 에러가 나거나, req.get 이 없다는 에러가 납니다. import type { Request, Response } from 'express' 를 확인합니다.

AuthModule 의 providers 에 저장소를 넣지 않았다. 앱이 뜨다가 Nest can't resolve dependencies of the AuthService (…, ?, …) 로 멈춥니다. 물음표 자리가 RefreshTokenRepository 입니다.

해시를 echo 로 계산했다. echo "$RT" | shasum -a 256 은 줄바꿈까지 해시합니다. DB 값과 절대 같아지지 않습니다. printf '%s' 를 씁니다.

make_interval 에 캐스트를 빼먹었다. could not determine data type of parameter $3 같은 에러가 납니다. $3::float8 처럼 타입을 밝혀 줍니다.

브라우저를 127.0.0.1:5173 으로 열었다. API 를 localhost:4000 으로 부르면 둘은 다른 사이트가 되어 SameSite=Strict 쿠키가 실리지 않습니다. 11장에서 FE 를 붙일 때 다시 나오지만, 호스트 이름은 항상 localhost 로 맞춥니다.

쿠키를 지우는데 Path 가 다르다. res.clearCookie('refresh_token') 처럼 옵션 없이 부르면 Path=/ 쿠키를 지우라는 뜻이 되어 Path=/auth 쿠키는 그대로 남습니다. 반드시 clearRefreshCookie 를 거칩니다.

정리

연습문제

1. 동료가 "리프레시 토큰도 JWT 로 만들면 refresh 할 때 DB 를 안 봐도 되니 더 빠르지 않나요?" 라고 묻습니다. 이 책의 요구사항(로그아웃, 7장의 재사용 탐지)을 근거로 답해 보세요.

해답

refresh 때 서버가 확인해야 하는 것은 서명만이 아닙니다. 사용자가 로그아웃했는지, 이 토큰이 이미 한 번 회전에 쓰였는지를 알아야 하고, 이것은 서버가 기억하는 상태라서 DB 를 봐야만 알 수 있습니다. JWT 로 만들어도 이 조회는 없어지지 않으므로 속도 이득이 없습니다. 남는 것은 JWT 의 단점뿐입니다. 토큰이 길어지고, 본문을 풀면 사용자 id·만료 시각이 보이고, 서명 키를 하나 더 관리해야 합니다. DB 를 보지 않는 것이 장점인 액세스 토큰에만 JWT 를 씁니다.

2. hashRefreshToken 을 argon2id 로 바꾸면 무엇이 동작하지 않게 되는지 구체적인 SQL 을 들어 설명해 보세요.

해답

argon2 는 해시할 때마다 새 솔트를 섞으므로 같은 토큰도 매번 다른 해시가 나옵니다. 그래서 refresh 에서 SELECT … FROM refresh_tokens WHERE token_hash = $1 로 행을 찾을 수 없습니다. 모든 행을 꺼내 argon2.verify 를 하나씩 돌려야 하는데, 요청에는 쿠키 말고는 사용자를 좁힐 정보도 없어서 테이블 전체를 훑어야 합니다. 게다가 한 번에 19 MiB·수십 ms 가 드는 계산을 행마다 반복하게 됩니다. 토큰이 256비트 난수라 argon2 가 막으려는 사전 공격 자체가 성립하지 않으므로, 얻는 것 없이 조회만 망가집니다.

3. SESSION_MAX_AGE_SEC=600(10분), REFRESH_TOKEN_TTL_SEC=1209600(14일)으로 설정하고 로그인하면 createFamily 가 돌려주는 expires_at 과 쿠키의 Expires 는 각각 언제입니까? LEAST 가 없었다면 어떻게 됩니까?

해답

LEAST(now() + 14일, now() + 10분) 이므로 expires_at 은 지금부터 10분 뒤이고, 쿠키의 Expires 도 같은 시각입니다. LEAST 가 없어 expires_at 을 now() + 14일 로 넣으면, 세션은 10분 뒤 끝나야 하는데 토큰 행과 쿠키는 14일 동안 살아 있게 됩니다. 7장의 회전 SQL 은 토큰의 expires_at 으로 만료를 판단하므로 세션 상한이 사실상 무시됩니다.

4. 누군가 clearRefreshCookie 를 만들지 않고 로그아웃에서 res.clearCookie(REFRESH_COOKIE) 를 바로 불렀습니다. curl 로 보면 로그아웃 응답에 Set-Cookie: refresh_token=; Path=/; Expires=Thu, 01 Jan 1970 … 이 옵니다. 브라우저에서는 무슨 일이 일어납니까?

해답

브라우저는 쿠키를 이름·도메인·경로로 구별합니다. 로그인 때 받은 쿠키는 Path=/auth 이고, 지우라는 쿠키는 Path=/ 이므로 서로 다른 쿠키입니다. Path=/ 인 refresh_token 은 원래 없었으므로 아무것도 지워지지 않고, Path=/auth 쿠키는 그대로 남습니다. 에러도 나지 않아서 "로그아웃했는데 쿠키가 남는" 버그가 조용히 생깁니다. 서버에서 family 를 폐기하므로 그 쿠키로 refresh 는 실패하지만, 죽은 쿠키를 계속 보내게 됩니다. 설정과 삭제가 같은 refreshOptions 를 쓰게 한 이유입니다.

5. Vite 개발 서버를 http://127.0.0.1:5173 으로 열고 API 는 http://localhost:4000 으로 부르면 로그인 응답의 쿠키가 이후 refresh 요청에 실리지 않습니다. 출처와 사이트의 정의로 이유를 설명하고, CORS 설정만 고쳐서 해결되는지 답해 보세요.

해답

127.0.0.1 과 localhost 는 호스트가 다르므로 등록 가능한 도메인이 다르고, 따라서 다른 사이트입니다. FE 페이지(127.0.0.1:5173)에서 시작된 fetch 는 API(localhost:4000) 입장에서 교차 사이트 요청이 되고, SameSite=Strict 쿠키는 교차 사이트 요청에 실리지 않습니다. WEB_ORIGINS 에 http://127.0.0.1:5173 을 더해 CORS 를 통과시켜도 쿠키가 실리지 않는 문제는 그대로입니다. CORS 는 응답을 읽을 수 있는지를 정하고, SameSite 는 쿠키를 실을지를 정하는 별개의 규칙이기 때문입니다. FE 와 API 의 호스트 이름을 localhost 로 맞추는 것이 해결책입니다.