본문으로 건너뛰기
버튼 하나 뒤에서

2장

손목밴드

새로고침해도 로그인이 풀리지 않는 건 어떻게 될까요? 그리고 누가 그 비밀을 복사해 가면요?

  1. 1입구에서 표를 보여주면 손목밴드와 재발급 영수증을 줘요.

  2. 2놀이기구마다 손목밴드만 보여줘요. 밴드는 금방 색이 바래요.

  3. 3밴드가 바래면 창구에 영수증을 내고 새 밴드와 새 영수증을 받아요.

  4. 4누가 영수증을 복사해 오면? 같은 영수증이 두 번 오는 순간 창구가 알아채요.

먼저 입구에서 로그인해 보세요

아이디와 비밀번호를 내면 서버가 두 가지를 줘요. 놀이기구마다 보여줄 손목밴드(액세스 토큰)와, 밴드가 바래면 새 밴드로 바꿔 줄 재발급 영수증(리프레시 토큰)이에요.

지금 상태
확인하는 중…
손목밴드 액세스 토큰
없음
재발급 영수증 리프레시 토큰
없음

데모 계정(아이디 demo, 비밀번호 demo1234)으로 실제 서버에 로그인해요.

재발급손님다시 로그인도둑다시 로그인

지난 실측 기록 (2026-10-19, 로컬 MySQL)

  1. 1

    도둑이 재발급 영수증을 몰래 복사해요

    복사 성공 (손님은 몰라요)

  2. 2

    손님이 영수증으로 새 손목밴드를 받아요

    통과 · 새 밴드와 새 영수증을 받음

  3. 3

    도둑이 복사본으로 재발급을 시도해요

    거절 · 이미 쓴 영수증이라, 이 로그인으로 받은 영수증이 전부 무효

  4. 4

    손님도 다음 재발급에서 막혀요

    거절 · 영수증이 무효가 돼서 다시 로그인해야 함

실제 서버에 요청을 네 번 보내요. 로그인돼 있지 않으면 데모 계정(아이디 demo, 비밀번호 demo1234)으로 먼저 들어가요.

같은 영수증이 두 번 오면 창구는 누가 진짜 주인인지 따지지 않고, 그 로그인으로 받은 영수증을 전부 무효로 해요. 그래서 도둑도 손님도 다시 로그인해야 하고, 도둑은 더 버티지 못해요.

5명이 동시에 재발급 창구로

한 화면을 열 때 목록·알림·프로필처럼 요청 다섯 개가 동시에 나가는 일은 흔해요. 그 순간 손목밴드가 바래 있으면, 다섯 요청이 모두 재발급이 필요해져요.

각자 줄서기

창구

밴드가 바랜 걸 알아챈 다섯 명이 각자 같은 영수증을 들고 창구로 가요. 첫 사람이 영수증을 바꾸고 나면, 나머지 넷은 "이미 쓴 영수증"을 낸 셈이에요.

창구에 간 횟수
5번
결과
로그인 취소됨

창구는 복사본으로 보고 로그인을 통째로 취소해요.

지난 실측 기록 (2026-10-19)

직접 해 보면 정말로 로그아웃돼요. 다음 버튼이 데모 계정으로 다시 들어가요.

대표 한 명만

창구기다리는 넷

다섯 명 중 한 명만 창구에 가고, 나머지 넷은 그 사람이 새 밴드를 받아 올 때까지 기다렸다가 같이 써요.

창구에 간 횟수
1번
결과
로그인 유지

창구에는 한 번만 가고, 다섯 요청이 모두 이어서 성공해요.

지난 실측 기록 (2026-10-19)

새로고침해도 로그인 유지

손목밴드는 주머니(메모리)에만 넣어 둬서, 새로고침하면 사라져요. 대신 재발급 영수증은 브라우저 금고(httpOnly 쿠키)에 있어서, 페이지가 열리자마자 창구에 조용히 한 번 다녀와요.

이 페이지가 열릴 때 영수증을 확인하는 중…

어디에 보관할까

페이지에 나쁜 스크립트가 끼어들었다고 해 볼게요. 버튼을 누르면 이 브라우저에서 그 스크립트가 할 수 있는 방법으로 세 곳을 실제로 뒤져요. (가방에는 연습용 가짜 손목밴드를 넣어 두었어요)

주머니 메모리 (자바스크립트 변수)

내 손에만 있어서 남이 꺼내기 어려워요. 대신 새로고침하면 사라져요. 손목밴드(짧게 쓰는 토큰)를 여기에 둬요.

아직 안 뒤져 봤어요

가방 localStorage

새로고침해도 남지만, 페이지에서 돌아가는 어떤 스크립트든 열어 볼 수 있어요.

아직 안 뒤져 봤어요

금고 httpOnly 쿠키

스크립트는 못 열고, 브라우저가 재발급 창구에 갈 때만 알아서 꺼내 줘요. 재발급 영수증을 여기에 둬요.

아직 안 뒤져 봤어요

숫자로 본 결과

5번 → 1번
재발급 창구에 간 횟수
밴드가 바랜 순간 요청 5개가 동시에 나갈 때. 각자 가면 4개가 "이미 쓴 영수증"으로 걸려 로그인이 통째로 취소됐어요.
첫 사용
복사본이 들킨 때
복사본이 처음 쓰인 그 요청에서 바로 걸렸고, 그 로그인의 영수증 2장이 모두 무효가 됐어요.
3곳 중 1곳
나쁜 스크립트가 토큰을 꺼내 간 곳
가방(localStorage)만 열렸어요. 주머니(메모리)와 금고(httpOnly 쿠키)는 못 열었어요.
2026-10-19 로컬 API(NestJS + MySQL)에 같은 흐름을 직접 보내서 받은 결과예요. 위 버튼으로 언제든 다시 확인할 수 있어요. 코드: refresh-token.store.ts · single-flight.ts
개발자용 자세히토큰 수명 타임라인과 로그인, 요청 기록, 동시 갱신 경주, 토큰 묶음(family) 표, JWT 디코더

흐름 따라가기

비유 ↔ 용어

손목밴드
액세스 토큰 (JWT, 짧게 씀). 화면 기본 15초, 서버 기본 20초
재발급 영수증
리프레시 토큰 (기본 1일). DB에는 원문 대신 SHA-256 해시만 저장
주머니
메모리 (자바스크립트 변수). 액세스 토큰을 여기에 둬요
금고
httpOnly 쿠키 bv_refresh_token (Path=/auth)
가방
localStorage. 스크립트가 다 열어 볼 수 있어요
영수증 바꾸기
회전(rotation): 쓴 토큰은 used, 같은 familyId로 새 토큰
복사본이 들킴
재사용 감지: used 토큰이 또 오면 그 로그인(family) 전체 revoked
대표 한 명만
single-flight: 동시에 401이 나도 refresh는 한 번

요청 흐름

  1. 브라우저 → API POST /auth/login { username, password, accessTokenTtlSeconds }. 모든 요청은 credentials: 'include'로 보내요.
  2. API → MySQL AuthService.login이 scrypt로 비밀번호를 확인하고, RefreshTokenStore.issueNewFamily가 새 familyId로 첫 토큰 행을 만들어요.
    • 비밀번호가 틀리면 401 invalid_credentials
  3. API → 브라우저 200 { user, accessToken, accessTokenExpiresAt, refreshToken } + Set-Cookie: bv_refresh_token=…; HttpOnly; Path=/auth. 액세스 토큰은 React 상태(메모리)에만 둬요.
  4. 브라우저 → API GET /auth/me + Authorization: Bearer <액세스 토큰>. AccessTokenGuard가 서명과 만료만 확인해요 (DB 조회 없음).
    • 만료면 401 access_token_expired → 다음 단계로
    • 헤더가 없거나 위조면 401 access_token_missing / access_token_invalid
  5. 브라우저 → API refreshWithCookieOnce()(single-flight)로 POST /auth/refresh를 한 번만 보내요. 쿠키는 브라우저가 알아서 붙여요.
  6. API → MySQL RefreshTokenStore.rotate가 해시로 행을 찾고, 조건부 UPDATE(status = 'active'일 때만 used)로 한 요청만 통과시켜요.
    • 통과: 같은 familyId, parentId = 방금 쓴 토큰으로 새 행 → 200 + 새 AT + 새 Set-Cookie
    • 이미 used: family 전체 revoked → 401 refresh_token_reuse_detected, 쿠키 삭제
    • revoked / 만료 / 모르는 토큰: 401 refresh_token_revoked / refresh_token_expired / refresh_token_invalid
  7. 브라우저 → API 새 액세스 토큰으로 GET /auth/me를 한 번 더 보내요.
  8. 브라우저 새로고침하면 메모리의 액세스 토큰이 사라져요. 페이지가 뜰 때 쿠키로 POST /auth/refresh를 보내 조용히 복구해요.
  9. 브라우저 → API POST /auth/logout은 그 family의 토큰을 모두 revoked(logout)로 바꾸고 쿠키를 지워요.

DB에는 이렇게 남아요

RefreshToken 행 (한 번 실행한 예)
idparentIdstatusrevokedReasonusedAtrevokedAt
9eedb3f7…NULLrevokedreuse_detected05:00:02.95705:00:03.089
5591f28d…9eedb3f7…revokedreuse_detected05:00:02.98605:00:03.089
56081473…5591f28d…revokedreuse_detectedNULL05:00:03.089

로그인 → 회전 2번 → 도둑이 첫 토큰 복사본을 낸 뒤 읽었어요. tokenHash(64자) 같은 칸은 뺐고, 시각은 UTC예요.

표를 이렇게 읽어요

  • 세 행 모두 familyId가 같아요 (ac03c858…). parentId를 따라가면 회전 순서가 보여요.
  • 복사본을 내기 전에는 위 두 행이 used, 마지막 행이 active였어요. 복사본 한 번에 셋 다 같은 시각에 revoked가 됐어요.

프론트 관점

  • 액세스 토큰은 메모리에만 둬요. localStorage에 두면 끼어든 스크립트가 꺼내 가요.
  • refresh는 꼭 single-flight로 묶어요. 동시에 5개가 따로 가면 4개가 "이미 쓴 토큰"이 되어 로그인이 통째로 취소돼요.
  • React StrictMode에서 effect가 두 번 돌아도 같은 Promise를 공유해서 회전은 한 번이에요.

한계·운영에선

  • 액세스 토큰은 서버가 기록하지 않아요. 로그아웃해도 만료될 때까지는 통과해요. 그래서 짧게 둬요.
  • single-flight는 탭 하나 안에서만 묶여요. 탭 여러 개가 동시에 갱신하면 재사용 감지에 걸릴 수 있어요.
  • 웹과 API가 다른 사이트면 쿠키가 SameSite=None; Secure(+ Partitioned)여야 해요.
  • 도둑 데모용 /auth/demo/*는 AUTH_DEMO_ENDPOINTS=true일 때만 열려요. 운영에선 끄세요.

개발자 도구로 보면

  • Application → Cookies에 bv_refresh_token이 HttpOnly로 보여요. 콘솔의 document.cookie로는 안 보여요.
  • 쿠키는 /auth 경로 요청에만 붙어요. POST /auth/refresh의 요청 헤더에서 Cookie, 응답에서 새 Set-Cookie를 확인할 수 있어요.
  • 아래 JWT 디코더로 액세스 토큰의 sub, username, familyId, iat, exp를 볼 수 있어요.

코드·문서

① 토큰 수명 타임라인

쿠키의 리프레시 토큰으로 로그인 복구 중…

로그인하면 요청이 여기에 쌓입니다.

새로고침하면 액세스 토큰은 사라지는데, 왜 로그인은 유지될까?

  1. 액세스 토큰은 메모리(JS 변수)에만 둡니다. localStorage에 두면 XSS 스크립트가 바로 훔쳐 갈 수 있기 때문입니다. 대신 새로고침하면 사라집니다.
  2. 리프레시 토큰은 httpOnly 쿠키라 JS는 못 읽지만, 브라우저가 /auth 요청에 자동으로 붙여 보냅니다 (fetch(…, { credentials: 'include' })).
  3. 그래서 페이지가 열리면 바로 POST /auth/refresh를 한 번 호출해 새 액세스 토큰을 받습니다 (silent refresh). 이때 리프레시 토큰도 회전됩니다.
  4. React StrictMode(dev)는 effect를 두 번 실행합니다. 같은 쿠키로 refresh를 두 번 보내면 두 번째가 재사용으로 감지돼 로그인이 폐기되므로, 진행 중인 요청을 공유(single-flight)해 한 번만 보냅니다.

localhost:3000(웹)과 localhost:4000(API)은 포트만 다른 같은 사이트라 SameSite=Lax 쿠키가 전송되고, API는 CORS에서 credentials: true로 http://localhost:3000만 허용합니다. 이번 페이지 로드: 복구 중…

② 동시 요청 문제: 만료된 토큰으로 요청 5개가 동시에 나가면?

진행 중인 refresh Promise를 공유합니다. refresh는 딱 1번, 나머지 요청은 그 결과(새 액세스 토큰)를 기다렸다가 재시도합니다. (먼저 ①에서 로그인하세요)

③ 리프레시 토큰 회전 & 탈취 감지

refresh할 때마다 옛 토큰은 사용됨, 새 토큰이 같은 패밀리로 발급됩니다(회전). 이미 사용된 토큰이 다시 오면 정상 사용자와 공격자 중 누군가는 훔친 토큰을 쓰고 있다는 뜻이므로, 서버는 누가 누군지 묻지 않고 패밀리 전체를 폐기합니다. 그래서 둘 다 로그아웃됩니다.

④ 토큰을 어디에 보관할까? (XSS / CSRF)

버튼을 누르면 이 페이지에 주입된 악성 스크립트처럼 localStorage, 전역 변수, document.cookie를 실제로 읽어 봅니다. (localStorage에는 데모용 가짜 토큰을 넣어 두었고, 페이지를 떠나면 지웁니다)

localStorage

XSS: 취약: 페이지에서 실행되는 모든 JS가 읽을 수 있음

CSRF: 안전: 자동 전송되지 않음

새로고침해도 유지되지만, XSS 한 번이면 토큰이 밖으로 유출됩니다.

메모리 (JS 변수)

XSS: 덜 취약: 전역에 없으면 읽기 어려움 (단, 열린 페이지에서 API 호출은 대신 할 수 있음)

CSRF: 안전: 자동 전송되지 않음

새로고침하면 사라짐 → 리프레시 쿠키로 다시 받아옵니다. 이 데모의 액세스 토큰 방식.

httpOnly 쿠키

XSS: 안전: document.cookie로 읽을 수 없음

CSRF: 주의: 브라우저가 자동 전송 → SameSite=Lax, 경로 제한(/auth), CORS로 응답 읽기 차단

이 데모의 리프레시 토큰 방식. 탈취돼도 회전 + 재사용 감지로 피해를 줄입니다.

⑤ JWT 디코더

JWT는 암호화가 아니라 서명입니다. header와 payload는 누구나 base64로 풀어 볼 수 있고, 서명은 서버의 비밀키 (HS256)로만 확인할 수 있습니다. 그래서 payload에 비밀번호 같은 민감 정보를 넣으면 안 됩니다.