| Access 토큰 | Refresh 토큰 | |
|---|---|---|
| 형식 | JWT (내용 있음, 서명으로 검증) | 불투명 난수 (내용 없음, 서버 저장소의 키) |
| 수명 | 5~15분 | 며칠~몇 주 |
| 검증 | 서명·exp·aud 만 보고 통과 → DB 조회 없음 | 서버 저장소에서 찾아야 함 |
| 보내는 곳 | 매 API 요청, Authorization: Bearer |
갱신 요청 한 곳에만 |
| 유출 시 피해 | 남은 몇 분 | 회전·재사용 감지로 제한 |
핵심은 역할 분리입니다. 리소스 서버(API)는 Access 만 봅니다. 서명이 맞으면 통과시키니 빠르고, DB 를 안 보니 수평 확장이 쉽습니다. "취소할 수 없다" 는 약점은 그대로지만, 수명이 5분이면 그 약점의 크기도 5분입니다.
Refresh 토큰은 인가 서버만 봅니다. 서버 저장소에 있으니 언제든 지울 수 있고(로그아웃, 계정 정지), 내용이 없는 난수이니 훔쳐서 열어 봐야 얻을 게 없습니다. JWT 로 만들 이유가 없습니다. 만들면 "서버가 기억하지 않는 긴 토큰" 이 되어 1단계의 약점을 키운 꼴이 됩니다.
Refresh 토큰을 쓸 때마다 새 Refresh 로 교체하고 구 토큰은 "사용됨" 으로 표시합니다. 이것이 회전(rotation)입니다. 회전이 없으면 유출된 Refresh 하나로 공격자가 몇 주 동안 Access 를 무한히 뽑아 갑니다.
회전에 family 개념을 붙이면 탈취를 감지할 수 있습니다. 로그인 한 번 = family 하나. 회전으로 만들어지는 모든 후속 토큰은 같은 family 입니다.
로그인 → R1 (family F)
R1 사용 → R2 발급, R1 = 사용됨
R2 사용 → R3 발급, R2 = 사용됨
...
누군가 R1 을 다시 보냄 → "사용된 토큰의 재사용" → family F 전체 폐기 (R3 도 죽는다)정상 클라이언트는 사용한 토큰을 다시 보내지 않습니다. 재사용이 왔다는 것은 토큰이 두 곳에 있다는 뜻입니다. 누가 진짜인지 서버는 모르므로 둘 다 끊고 재로그인을 시킵니다. 정상 사용자는 한 번 더 로그인하는 불편을 겪지만, 공격자는 그 순간 쫓겨나고 "이 계정의 세션이 탈취됐었다" 는 신호가 남습니다.
로그아웃하면 Refresh 는 저장소에서 지우면 끝입니다. 문제는 아직 exp 가 남은 Access 입니다. 완전한 무상태를 포기하고 거부 목록을 둡니다. 토큰마다 고유 ID(jti)를 넣어 두고, 로그아웃 시 그 jti 를 exp 와 함께 목록에 넣습니다. 리소스 서버는 서명 검증 뒤 이 목록을 한 번 확인합니다.
거부 목록이 무한히 커지지 않을까요? 아닙니다. exp 가 지난 항목은 지워도 됩니다(어차피 서명 검증에서 만료로 걸립니다). 그래서 목록 크기는 "Access TTL 동안 로그아웃한 수" 를 넘지 않습니다. TTL 이 5분이면 목록은 늘 최근 5분치입니다. Redis 라면 SET jti 1 EX <남은 초> 한 줄입니다.
거부 목록을 두기 싫으면 "로그아웃 = Refresh 삭제만" 으로 타협하고 Access 의 남은 몇 분은 감수하는 서비스도 많습니다. 어느 쪽이든 의식적으로 선택해야 하고, 그 선택은 Access TTL 과 함께 정해집니다.
브라우저에는 저장 장소가 셋 있습니다. localStorage, 일반 쿠키, HttpOnly 쿠키. XSS(페이지에 악성 스크립트가 주입되는 공격)가 터지면 앞의 둘은 document.cookie 와 localStorage.getItem 으로 통째로 털립니다. HttpOnly 쿠키만 JS 가 읽을 수 없습니다.
그래서 표준 배치는 이렇습니다.
Set-Cookie: refresh_token=...; HttpOnly; Secure; SameSite=Strict; Path=/refreshHttpOnly JS 접근 차단. Secure HTTPS 에서만. Path=/refresh 갱신 요청에만 실리므로 다른 요청에는 노출조차 안 됨. SameSite=Strict 다른 사이트에서 시작된 요청에는 안 실림 → CSRF 차단.쿠키의 약점은 CSRF 입니다. 쿠키는 브라우저가 자동으로 붙이므로, 공격자 사이트가 우리 서버로 <form> 을 제출하면 쿠키가 딸려 갑니다. SameSite=Strict 가 이를 막고, 갱신 응답에 담긴 Access 는 공격자 페이지가 읽을 수 없으니(동일 출처 정책) 갱신 CSRF 로 얻는 것도 없습니다.
한 걸음 더 간 것이 BFF(Backend for Frontend) 입니다. 브라우저에는 토큰을 아예 주지 않고 세션 ID 쿠키만 줍니다. 토큰은 우리 백엔드가 세션에 보관하고, 브라우저 대신 API 를 호출합니다. 이 레슨의 클라이언트 앱이 이 구조입니다. Spring Security 의 oauth2Login() 도 기본이 이 형태입니다.
인가 서버 하나가 여러 API(주문, 결제, 관리자)에 토큰을 발급하면, 주문 API 용 토큰을 결제 API 에 들이밀어도 서명은 맞습니다. aud(audience) 클레임에 대상 API 이름을 넣고, 각 리소스 서버가 자기 이름이 맞는지 확인해야 이 재활용을 막습니다. Spring Resource Server 의 JwtValidators 에 aud 검사를 추가하는 것이 정확히 이 부분이며, 빠뜨리기 쉬운 항목 1순위입니다.
OAuth2 에는 역할이 넷 있습니다. 리소스 소유자(사용자), 클라이언트(우리 앱), 인가 서버(구글 로그인 서버), 리소스 서버(구글 프로필 API). 인가 코드 흐름은 이렇게 돕니다.
브라우저 클라이언트 앱 인가 서버 리소스 서버
│ GET /login │ │ │
│────────────────────────▶│ state, verifier 생성·세션 저장 │
│ 302 → /authorize?client_id&redirect_uri&scope&state&code_challenge │
│◀────────────────────────│ │ │
│ GET /authorize ... (앞 채널: 브라우저 주소창을 지나간다) │
│───────────────────────────────────────────────────▶│ 로그인 화면, 동의 화면 │
│ 302 → redirect_uri?code=XXX&state=... │ │
│◀───────────────────────────────────────────────────│ │
│ GET /callback?code&state│ │ │
│────────────────────────▶│ state 대조 │ │
│ │ POST /token code + code_verifier (뒷 채널: 서버↔서버) │
│ │─────────────────────────▶│ 코드·PKCE 검증 │
│ │ {access_token, refresh_token} │
│ │◀─────────────────────────│ │
│ │ GET /api/profile Bearer access │
│ │──────────────────────────────────────────────────▶│
│ 302 → /profile │◀──────────────────────────────────────────────────│왜 토큰을 바로 안 주고 코드를 거칠까요? 앞 채널(브라우저 리다이렉트)은 주소창·히스토리·Referer·프록시 로그에 남습니다. 거기에 토큰을 실으면 유출 경로가 너무 많습니다.
그래서 앞 채널에는 60초짜리 1회용 코드만 흘리고, 진짜 토큰은 클라이언트 서버가 인가 서버에 직접(뒷 채널) 요청해서 받습니다. 브라우저는 토큰을 보지 못합니다. 예전의 Implicit 흐름(토큰을 URL 조각으로 직접 반환)은 이 이유로 폐기됐습니다.
코드가 앞 채널을 지나는 이상, 코드를 가로챌 가능성은 남습니다. 가로챈 코드로 /token 을 부르면 토큰이 나옵니다. 이를 막는 것이 PKCE(Proof Key for Code Exchange, "픽시")입니다.
code_verifier 를 만들어 자기만 보관합니다./authorize 에는 code_challenge = BASE64URL(SHA-256(code_verifier)) 를 보냅니다. 인가 서버는 이 값을 코드와 함께 저장합니다./token 에는 원본 code_verifier 를 보냅니다. 인가 서버가 다시 SHA-256 해서 저장값과 대조합니다.challenge 는 앞 채널에 노출되지만 SHA-256 을 역산할 수 없으니 verifier 는 알 수 없고, 코드만 가로챈 공격자는 3번을 통과하지 못합니다. 원래 모바일 앱(비밀을 숨길 수 없는 공개 클라이언트)용이었지만, 현재는 모든 클라이언트에 PKCE 필수가 권고입니다.
state 는 방향이 반대인 공격을 막습니다. 공격자가 자기 계정으로 로그인해서 받은 콜백 URL(/callback?code=공격자코드)을 피해자에게 클릭시키면, 피해자 브라우저가 공격자 계정으로 로그인됩니다. 피해자가 그 상태로 카드 번호를 저장하면 공격자 계정에 저장됩니다. 이것이 로그인 CSRF 입니다.
클라이언트는 /login 에서 난수 state 를 세션에 저장하고, 콜백에서 세션의 state 와 URL 의 state 가 같은지 확인합니다. 공격자의 콜백 URL 에는 공격자 세션의 state 가 들어 있으니 피해자 세션과 맞지 않습니다.
/authorize 에서 redirect_uri 는 등록값과 문자열 단위로 정확히 일치해야 합니다. 접두어 일치나 와일드카드를 허용하는 순간 https://our.app.evil.com 같은 주소로 코드를 보내게 됩니다.
client_id/redirect_uri 가 틀렸을 때는 리다이렉트 자체를 하면 안 됩니다(400 으로 끝). 그 외 오류(scope, response_type)는 규격대로 redirect_uri?error=...&state=... 로 돌려보냅니다.
/token 에서는 코드 존재 → 미사용 → 미만료(60초) → client_id·redirect_uri 일치 → PKCE 순으로 검사하고, 실패 시 400 에 {"error":"invalid_grant","error_description":"..."} 형식으로 답합니다. 401 이 아닙니다. 코드가 두 번 오면 탈취로 보고, 규격은 그 코드로 이미 발급한 토큰까지 폐기하라고 권고합니다.
| 이 레슨 | Spring |
|---|---|
ClientApp /login, /callback |
oauth2Login() 이 두 엔드포인트를 자동 제공 |
| 설정 파일 | application.yml 의 client registration |
ResourceServer 검증 |
oauth2ResourceServer().jwt() + issuer-uri |
AuthorizationServer |
Spring Authorization Server, Keycloak, 소셜 제공자 |
TokenService 회전·거부 목록 |
OAuth2AuthorizationService(JDBC) + reuseRefreshTokens(false) |
| Refresh HttpOnly 쿠키 | ResponseCookie 직접 생성. BFF 면 세션 쿠키 |
| SAML SSO | saml2Login() |
oauth2Login() 은 /oauth2/authorization/{id} 와 /login/oauth2/code/{id} 를 만들어 주고, state·PKCE·세션 저장이 내장돼 있습니다. 설정은 다음 키로 합니다.
spring.security.oauth2.client:
registration.google: { client-id, client-secret, scope, redirect-uri }
provider.google: { authorization-uri, token-uri }리소스 서버의 aud 검사는 JwtValidators 로 추가합니다. SAML 은 인가 코드 대신 XML Assertion 을 POST 로 받지만 "IdP 로 보냈다가 증표 받아 오기" 구조는 같습니다.
소셜 로그인(구글·카카오·네이버)은 인가 서버를 남이 운영하는 인가 코드 흐름입니다. 우리 앱은 클라이언트 역할만 하고, 받은 토큰으로 프로필 API 를 불러 이메일을 얻은 뒤 자사 계정과 연결하고 자사 토큰을 발급합니다. 구글 토큰을 우리 API 인증에 그대로 쓰지 않습니다.