2단계: Access/Refresh 토큰과 OAuth2 인가 코드
5. 자주 하는 실수 (Tip)
❌ 실수 1: Refresh 토큰을 localStorage 에 둔다
localStorage.setItem('refresh_token', res.refresh_token); // XSS 한 번이면 몇 주짜리 세션이 통째로Refresh 는 HttpOnly 쿠키 아니면 BFF 세션입니다. 프론트 프레임워크 예제 코드가 localStorage 를 쓰는 경우가 많은데, 그건 예제라서입니다. Access 도 localStorage 보다 메모리가 낫고, 새로고침 시 Refresh 쿠키로 다시 받으면 됩니다.
❌ 실수 2: Refresh 토큰을 JWT 로 만들고 서버에 저장하지 않는다
"Refresh 도 JWT 로 만들면 DB 가 필요 없어 편하다" 는 설계는 2주짜리 취소 불가 토큰을 만든 것입니다. 로그아웃도, 계정 정지도, 재사용 감지도 불가능합니다. Refresh 의 존재 이유가 "서버가 기억해서 언제든 끊을 수 있다" 입니다. JWT 로 만들었더라도 jti 를 반드시 저장소에 두고 존재를 확인해야 하며, 그럴 거면 난수가 더 짧고 단순합니다.
❌ 실수 3: 회전 없이 Refresh 를 재사용한다
// rotate 가 같은 refresh 를 계속 받아 주는 구현
return issue(r.subject(), ..., r.family()); // 구 토큰을 '사용됨' 으로 표시하지 않음유출된 Refresh 하나로 공격자가 만료까지 몇 주 동안 Access 를 무한히 뽑습니다. 사용자는 아무 이상을 못 느낍니다. 회전 + 재사용 감지가 있어야 공격자와 정상 사용자가 충돌하고, 그 충돌이 감지 신호가 됩니다.
❌ 실수 4: redirect_uri 를 접두어나 와일드카드로 비교한다
if (redirectUri.startsWith(registered)) ... // https://our.app 등록 → https://our.app.evil.com 통과
if (registered.equals("*")) ... // 개발 편의로 열어 둔 채 배포문자열 완전 일치만 허용합니다. 개발용 http://localhost:3000/callback 도 등록 목록에 정확히 넣습니다. 그리고 redirect_uri 가 틀렸을 때 "친절하게" 그 주소로 error 를 리다이렉트하면 안 됩니다. 공격자 주소로 보내는 것입니다.
❌ 실수 5: state 를 생략하거나 검사하지 않는다
"PKCE 가 있으니 state 는 필요 없다" 는 오해가 있습니다. PKCE 는 공격자가 피해자의 코드를 쓰는 것을 막고, state 는 피해자가 공격자의 코드를 쓰게 되는 것(로그인 CSRF)을 막습니다. 반대 방향입니다. 둘 다 필요합니다. state 는 세션마다 난수여야 하고, 콜백에서 대조한 뒤 즉시 버립니다. 예제 5 (a) 가 이 검사입니다.
❌ 실수 6: 브라우저(JS)가 직접 /token 을 부른다
SPA 에서 콜백 페이지의 JS 가 fetch('/token', {code, code_verifier}) 로 토큰을 받아 오는 구조는 토큰이 브라우저에 떨어집니다. 앞 채널에 토큰을 실은 Implicit 흐름과 결과가 같습니다. PKCE 덕에 코드 가로채기는 막지만, XSS 로 토큰이 털리는 건 못 막습니다.
웹앱은 BFF 로 가고, 정말 백엔드가 없는 SPA 라면 Access 는 메모리·Refresh 는 인가 서버가 HttpOnly 쿠키로 주는 구성을 인가 서버가 지원하는지 확인합니다.
❌ 실수 7: 핸들러가 예외를 던지면 클라이언트는 "응답 없음" 을 본다
이 레슨을 만들며 실제로 겪은 것입니다. 콜백이 성공하면 세션의 state 를 null 로 지우는데, 그 뒤 공격 시연으로 /callback 을 다시 부르자 s.state.equals(...) 에서 NPE 가 났습니다. HttpServer 는 핸들러 예외를 삼키고 연결만 끊으므로 클라이언트에는 이렇게 보였습니다.
java.io.IOException: HTTP/1.1 header parser received no bytes400 도 500 도 아닌 "헤더가 한 바이트도 안 옴" 입니다. 원인 위치를 알 수 없습니다. 인증 서버는 모든 핸들러를 try/catch 로 감싸 최소한 500 과 로그를 남기게 합니다. Spring 은 @ControllerAdvice 와 AuthenticationEntryPoint 가 이 역할을 합니다.
1회용 값을 null 로 지운 뒤에는 그 값을 읽는 모든 경로에 null 검사가 있어야 합니다. 수정은 s.state == null || !s.state.equals(q.get("state")) 입니다.