PKCEのフローを一段ずつ
한국어 원문으로 표시합니다.
한 줄 요약
PKCE 는 인가 코드에 "이 코드는 내가 시작한 요청의 것"이라는 증명을 붙이는 장치다. 시크릿 없이도 코드 가로채기가 무력해진다.
왜 이게 필요했나
모바일 앱에서 커스텀 URL 스킴으로 콜백을 받던 시절, 악성 앱이 같은 스킴을 등록해 인가 코드를 가로채는 공격이 실제로 가능했습니다. 코드만 있으면 토큰을 받을 수 있었기 때문입니다(공개 클라이언트라 시크릿이 없으므로).
PKCE 는 이 문제를 해결합니다. 코드를 가로채도 토큰을 못 받게 만듭니다.
어떻게 동작하나
흐름을 순서대로 보면 이렇습니다.
먼저 클라이언트가 무작위 문자열 code_verifier 를 만듭니다. 43자 이상 128자 이하, URL 안전 문자만 씁니다. 이 값은 클라이언트만 알고 있습니다.
다음으로 code_challenge = BASE64URL(SHA256(code_verifier)) 를 계산합니다. 인가 요청 URL 에 code_challenge 와 code_challenge_method=S256 을 실어 보냅니다. plain 방식도 규격에는 있지만 해시를 하지 않는 것이라 의미가 없습니다.
사용자가 인증 서버에서 로그인하면 redirect_uri 로 인가 코드가 돌아옵니다. 이때 state 도 함께 돌아오는데, 클라이언트가 처음 보낸 값과 같은지 반드시 확인해야 합니다. 다르면 자기가 시작한 요청이 아닙니다.
마지막으로 토큰 엔드포인트에 인가 코드와 함께 원본 code_verifier 를 보냅니다. 서버가 해시를 다시 계산해 저장해 둔 code_challenge 와 비교합니다. 맞으면 토큰을 줍니다.
공격자가 코드를 가로챘다고 해도 code_verifier 를 모르므로 교환에 실패합니다.
현장에서 만나는 모습
리다이렉트 URI 등록에서 자주 사고가 납니다. http://localhost:* 나 https://example.com/* 같은 넓은 와일드카드를 등록하면, 그 도메인에 열린 리다이렉터가 하나만 있어도 토큰이 새어 나갑니다. 정확한 경로를 등록하는 것이 원칙입니다.
리프레시 토큰 회전도 실무의 기본입니다. 갱신할 때마다 새 리프레시 토큰을 주고 옛것을 무효화합니다. 옛 리프레시 토큰이 다시 쓰이면 탈취를 의심해 그 세션 전체를 끊습니다. 저자의 권고 조합은 짧은 액세스 토큰(5~15분)과 리프레시 토큰 회전입니다 — 블랙리스트 없이도 실질적 무효화 효과를 얻습니다.
그리고 BFF 패턴을 고려할 만합니다. 토큰을 브라우저에 아예 주지 않고 백엔드가 보관하며 세션 쿠키로만 통신하는 방식입니다. XSS 로 토큰이 털리는 경로 자체가 사라집니다.
PKCE 가 막는 것
PKCE 없이 authorization code 흐름을 쓰면, 코드가 리다이렉트 URL 에 실려 오는 동안
가로채일 수 있습니다. 모바일 앱에서는 커스텀 스킴(myapp://)을 다른 앱이 가로챌
수 있어 특히 위험했습니다.
1. 클라이언트가 무작위 verifier 를 만든다
verifier = base64url(random(32~96 bytes))
2. challenge = base64url(sha256(verifier)) ← 이것만 보낸다
3. 인가 요청에 challenge 를 실어 보낸다
4. 코드를 받아 토큰으로 바꿀 때 verifier 를 보낸다
5. 서버가 sha256(verifier) == challenge 인지 확인
코드를 가로챈 공격자는 verifier 를 모르므로 토큰으로 바꾸지 못합니다. 해시의 단방향성이 그것을 보장합니다.
code_challenge_method 는 반드시 S256 을 씁니다. plain 은 verifier 를 그대로
보내는 것이라 아무것도 막지 못합니다.
지금은 웹 앱에도 PKCE 를 권합니다. 예전에는 공개 클라이언트(모바일·SPA)만의 것이었지만, OAuth 2.1 은 모든 authorization code 흐름에 요구합니다.
state 와 nonce 는 다른 것을 막는다
셋이 헷갈리기 쉬운데 각각 다른 공격을 막습니다.
| 값 | 막는 것 | 어디서 확인 |
|---|---|---|
state |
CSRF — 남의 로그인을 내 세션에 붙이기 | 리다이렉트로 돌아왔을 때 |
nonce |
ID 토큰 재생 공격 | ID 토큰의 클레임 |
| PKCE | 인가 코드 가로채기 | 토큰 교환 시 |
셋 다 씁니다. state 는 세션에 저장해 두고 돌아온 값과 비교하며, nonce 는 ID 토큰 안에 그대로 실려 오므로 그것을 확인합니다.
리다이렉트 URI 는 정확히 일치해야 한다
가장 흔한 설정 사고입니다. 인증 서버에 등록한 URI 와 요청의 URI 가 문자 하나까지 같아야 합니다.
등록: https://app.example.com/callback
요청: https://app.example.com/callback/ ← 슬래시 하나 차이로 거절
와일드카드는 쓰지 않습니다. https://app.example.com/* 를 허용하면 그 도메인의
아무 경로로나 코드를 보낼 수 있어, XSS 하나로 코드가 새어 나갑니다.
로컬 개발은 http://localhost:3000/callback 처럼 포트까지 등록합니다. 포트가
바뀌면 다시 등록해야 하므로, 개발용 포트를 팀에서 고정해 두는 편이 편합니다.
다음 실습에서 할 것
PKCE 흐름 전체를 curl 과 스크립트로 완주합니다. verifier 와 challenge 를 만들고, 인가 요청을 보내고, 로그인 폼을 POST 해서 코드를 받고, 토큰으로 교환하고, 틀린 verifier 로는 실패하는 것까지 확인합니다. 그다음 별도 실습에서 그 토큰을 검증하는 미들웨어를 FastAPI 앱에 붙입니다.