각자 줄서기
밴드가 바랜 걸 알아챈 다섯 명이 각자 같은 영수증을 들고 창구로 가요. 첫 사람이 영수증을 바꾸고 나면, 나머지 넷은 "이미 쓴 영수증"을 낸 셈이에요.
- 창구에 간 횟수
- 5번
- 결과
- 로그인 취소됨
창구는 복사본으로 보고 로그인을 통째로 취소해요.
지난 실측 기록 (2026-10-19)
직접 해 보면 정말로 로그아웃돼요. 다음 버튼이 데모 계정으로 다시 들어가요.
2장
새로고침해도 로그인이 풀리지 않는 건 어떻게 될까요? 그리고 누가 그 비밀을 복사해 가면요?
1입구에서 표를 보여주면 손목밴드와 재발급 영수증을 줘요.
2놀이기구마다 손목밴드만 보여줘요. 밴드는 금방 색이 바래요.
3밴드가 바래면 창구에 영수증을 내고 새 밴드와 새 영수증을 받아요.
4누가 영수증을 복사해 오면? 같은 영수증이 두 번 오는 순간 창구가 알아채요.
아이디와 비밀번호를 내면 서버가 두 가지를 줘요. 놀이기구마다 보여줄 손목밴드(액세스 토큰)와, 밴드가 바래면 새 밴드로 바꿔 줄 재발급 영수증(리프레시 토큰)이에요.
데모 계정(아이디 demo, 비밀번호 demo1234)으로 실제 서버에 로그인해요.
지난 실측 기록 (2026-10-19, 로컬 MySQL)
도둑이 재발급 영수증을 몰래 복사해요
복사 성공 (손님은 몰라요)
손님이 영수증으로 새 손목밴드를 받아요
통과 · 새 밴드와 새 영수증을 받음
도둑이 복사본으로 재발급을 시도해요
거절 · 이미 쓴 영수증이라, 이 로그인으로 받은 영수증이 전부 무효
손님도 다음 재발급에서 막혀요
거절 · 영수증이 무효가 돼서 다시 로그인해야 함
실제 서버에 요청을 네 번 보내요. 로그인돼 있지 않으면 데모 계정(아이디 demo, 비밀번호 demo1234)으로 먼저 들어가요.
같은 영수증이 두 번 오면 창구는 누가 진짜 주인인지 따지지 않고, 그 로그인으로 받은 영수증을 전부 무효로 해요. 그래서 도둑도 손님도 다시 로그인해야 하고, 도둑은 더 버티지 못해요.
한 화면을 열 때 목록·알림·프로필처럼 요청 다섯 개가 동시에 나가는 일은 흔해요. 그 순간 손목밴드가 바래 있으면, 다섯 요청이 모두 재발급이 필요해져요.
밴드가 바랜 걸 알아챈 다섯 명이 각자 같은 영수증을 들고 창구로 가요. 첫 사람이 영수증을 바꾸고 나면, 나머지 넷은 "이미 쓴 영수증"을 낸 셈이에요.
창구는 복사본으로 보고 로그인을 통째로 취소해요.
지난 실측 기록 (2026-10-19)
직접 해 보면 정말로 로그아웃돼요. 다음 버튼이 데모 계정으로 다시 들어가요.
다섯 명 중 한 명만 창구에 가고, 나머지 넷은 그 사람이 새 밴드를 받아 올 때까지 기다렸다가 같이 써요.
창구에는 한 번만 가고, 다섯 요청이 모두 이어서 성공해요.
지난 실측 기록 (2026-10-19)
손목밴드는 주머니(메모리)에만 넣어 둬서, 새로고침하면 사라져요. 대신 재발급 영수증은 브라우저 금고(httpOnly 쿠키)에 있어서, 페이지가 열리자마자 창구에 조용히 한 번 다녀와요.
이 페이지가 열릴 때 영수증을 확인하는 중…
페이지에 나쁜 스크립트가 끼어들었다고 해 볼게요. 버튼을 누르면 이 브라우저에서 그 스크립트가 할 수 있는 방법으로 세 곳을 실제로 뒤져요. (가방에는 연습용 가짜 손목밴드를 넣어 두었어요)
내 손에만 있어서 남이 꺼내기 어려워요. 대신 새로고침하면 사라져요. 손목밴드(짧게 쓰는 토큰)를 여기에 둬요.
아직 안 뒤져 봤어요
새로고침해도 남지만, 페이지에서 돌아가는 어떤 스크립트든 열어 볼 수 있어요.
아직 안 뒤져 봤어요
스크립트는 못 열고, 브라우저가 재발급 창구에 갈 때만 알아서 꺼내 줘요. 재발급 영수증을 여기에 둬요.
아직 안 뒤져 봤어요
bv_refresh_token (Path=/auth)POST /auth/login { username, password, accessTokenTtlSeconds }. 모든 요청은 credentials: 'include'로 보내요.AuthService.login이 scrypt로 비밀번호를 확인하고, RefreshTokenStore.issueNewFamily가 새 familyId로 첫 토큰 행을 만들어요.invalid_credentials{ user, accessToken, accessTokenExpiresAt, refreshToken } + Set-Cookie: bv_refresh_token=…; HttpOnly; Path=/auth. 액세스 토큰은 React 상태(메모리)에만 둬요.GET /auth/me + Authorization: Bearer <액세스 토큰>. AccessTokenGuard가 서명과 만료만 확인해요 (DB 조회 없음).access_token_expired → 다음 단계로access_token_missing / access_token_invalidrefreshWithCookieOnce()(single-flight)로 POST /auth/refresh를 한 번만 보내요. 쿠키는 브라우저가 알아서 붙여요.RefreshTokenStore.rotate가 해시로 행을 찾고, 조건부 UPDATE(status = 'active'일 때만 used)로 한 요청만 통과시켜요.parentId = 방금 쓴 토큰으로 새 행 → 200 + 새 AT + 새 Set-Cookierefresh_token_reuse_detected, 쿠키 삭제refresh_token_revoked / refresh_token_expired / refresh_token_invalidGET /auth/me를 한 번 더 보내요.POST /auth/refresh를 보내 조용히 복구해요.POST /auth/logout은 그 family의 토큰을 모두 revoked(logout)로 바꾸고 쿠키를 지워요.| id | parentId | status | revokedReason | usedAt | revokedAt |
|---|---|---|---|---|---|
| 9eedb3f7… | NULL | revoked | reuse_detected | 05:00:02.957 | 05:00:03.089 |
| 5591f28d… | 9eedb3f7… | revoked | reuse_detected | 05:00:02.986 | 05:00:03.089 |
| 56081473… | 5591f28d… | revoked | reuse_detected | NULL | 05:00:03.089 |
로그인 → 회전 2번 → 도둑이 첫 토큰 복사본을 낸 뒤 읽었어요. tokenHash(64자) 같은 칸은 뺐고, 시각은 UTC예요.
SameSite=None; Secure(+ Partitioned)여야 해요./auth/demo/*는 AUTH_DEMO_ENDPOINTS=true일 때만 열려요. 운영에선 끄세요.bv_refresh_token이 HttpOnly로 보여요. 콘솔의 document.cookie로는 안 보여요./auth 경로 요청에만 붙어요. POST /auth/refresh의 요청 헤더에서 Cookie, 응답에서 새 Set-Cookie를 확인할 수 있어요.쿠키의 리프레시 토큰으로 로그인 복구 중…
로그인하면 요청이 여기에 쌓입니다.
/auth 요청에 자동으로 붙여 보냅니다 (fetch(…, { credentials: 'include' })).POST /auth/refresh를 한 번 호출해 새 액세스 토큰을 받습니다 (silent refresh). 이때 리프레시 토큰도 회전됩니다.localhost:3000(웹)과 localhost:4000(API)은 포트만 다른 같은 사이트라 SameSite=Lax 쿠키가 전송되고, API는 CORS에서 credentials: true로 http://localhost:3000만 허용합니다. 이번 페이지 로드: 복구 중…
진행 중인 refresh Promise를 공유합니다. refresh는 딱 1번, 나머지 요청은 그 결과(새 액세스 토큰)를 기다렸다가 재시도합니다. (먼저 ①에서 로그인하세요)
refresh할 때마다 옛 토큰은 사용됨, 새 토큰이 같은 패밀리로 발급됩니다(회전). 이미 사용된 토큰이 다시 오면 정상 사용자와 공격자 중 누군가는 훔친 토큰을 쓰고 있다는 뜻이므로, 서버는 누가 누군지 묻지 않고 패밀리 전체를 폐기합니다. 그래서 둘 다 로그아웃됩니다.
버튼을 누르면 이 페이지에 주입된 악성 스크립트처럼 localStorage, 전역 변수, document.cookie를 실제로 읽어 봅니다. (localStorage에는 데모용 가짜 토큰을 넣어 두었고, 페이지를 떠나면 지웁니다)
XSS: 취약: 페이지에서 실행되는 모든 JS가 읽을 수 있음
CSRF: 안전: 자동 전송되지 않음
새로고침해도 유지되지만, XSS 한 번이면 토큰이 밖으로 유출됩니다.
XSS: 덜 취약: 전역에 없으면 읽기 어려움 (단, 열린 페이지에서 API 호출은 대신 할 수 있음)
CSRF: 안전: 자동 전송되지 않음
새로고침하면 사라짐 → 리프레시 쿠키로 다시 받아옵니다. 이 데모의 액세스 토큰 방식.
XSS: 안전: document.cookie로 읽을 수 없음
CSRF: 주의: 브라우저가 자동 전송 → SameSite=Lax, 경로 제한(/auth), CORS로 응답 읽기 차단
이 데모의 리프레시 토큰 방식. 탈취돼도 회전 + 재사용 감지로 피해를 줄입니다.
JWT는 암호화가 아니라 서명입니다. header와 payload는 누구나 base64로 풀어 볼 수 있고, 서명은 서버의 비밀키 (HS256)로만 확인할 수 있습니다. 그래서 payload에 비밀번호 같은 민감 정보를 넣으면 안 됩니다.