PKCE (Proof Key for Code Exchange)
Authorization Code Flow의 코드 가로채기 방지 확장
PKCE는 OAuth 2.0 Authorization Code Flow에서 code를 가로채는 공격을 막기 위한 확장(RFC 7636)이다. 로그인을 시작한 클라이언트만 알고 있는 code_verifier로 토큰 교환 시 본인임을 증명한다. 원래 모바일·SPA용이었지만 RFC 9700(2025)에서 서버 측 confidential client에도 권장된다.
개념
Authorization Code Flow에서 Keycloak이 콜백 URL로 돌려주는 code는 URL에 담겨 온다. 이 code를 공격자가 중간에 가로채면 자신의 서버에서 토큰 교환이 가능하다. PKCE(RFC 7636)는 이를 막기 위해 로그인을 시작한 당사자만 알고 있는 비밀값(code_verifier)을 추가해, code를 가져가도 토큰 교환을 못 하게 한다.
왜 필요한가
Authorization Code Flow만으로는 code 가로채기 공격(Authorization Code Interception Attack)에 취약하다.
정상 흐름:
클라이언트 → Keycloak → code → 클라이언트 → 토큰 교환
공격 흐름:
클라이언트 → Keycloak → code → (가로챈 공격자) → 토큰 교환
code가 URL에 들어오기 때문에 브라우저 히스토리, 서버 로그, Referer 헤더, 브라우저 확장 프로그램 등 여러 경로로 노출될 수 있다. client_secret이 있는 서버 클라이언트라면 어느 정도 방어가 되지만, 모바일이나 SPA처럼 secret을 안전하게 보관하기 어려운 환경에서는 code만 있으면 누구나 토큰 교환이 가능하다.
동작 방식
로그인 시작 시
# 1. code_verifier 생성 (랜덤 43~128자, URL-safe base64)
code_verifier = base64url(random_bytes(64))
# 2. code_challenge 계산 (S256 방식)
code_challenge = base64url(SHA256(code_verifier))
# 3. Redis에 임시 저장 (state를 key로)
redis.set(f"pkce_state:{state}", code_verifier, ex=300)
# 4. Keycloak authorize URL에 포함
# code_verifier는 비공개, code_challenge만 Keycloak에 전달
?response_type=code
&code_challenge=<code_challenge>
&code_challenge_method=S256
&state=<state>
콜백 수신 시
# 5. state로 code_verifier 꺼내기 (GETDEL — 일회성 소비)
code_verifier = redis.getdel(f"pkce_state:{state}")
# 6. Keycloak 토큰 교환
POST /token
code=<code>
code_verifier=<code_verifier>
# 7. Keycloak 내부 검증
# SHA256(code_verifier) == 처음 받은 code_challenge? → 통과
# 다르면 → 거부
code_verifier는 처음 로그인 요청을 만든 서버만 알고 있다. 공격자가 code를 가로채도 code_verifier 없이는 토큰 교환이 불가능하다.
state의 역할
state는 PKCE와 별개의 목적을 가진다. CSRF 방지다.
공격 시도:
공격자가 만든 콜백 URL을 피해자가 방문
→ 피해자의 세션에 공격자 계정으로 로그인됨
state 방어:
로그인 시작 시 랜덤 state 생성 → Redis 저장
콜백 수신 시 state 대조 → 불일치면 거부
Redis에서 GETDEL로 꺼내는 이유도 여기 있다. 같은 state로 두 번 콜백이 오면 첫 번째만 성공하고 두 번째는 state가 이미 삭제되어 실패한다. GETDEL은 조회와 삭제를 원자적으로 처리하므로 race condition 없이 일회성을 보장한다.
S256 방식
code_challenge_method는 S256(SHA-256)과 plain 두 가지가 있다.
plain: code_challenge == code_verifier 그대로 전달 — PKCE가 의미 없어짐S256: code_challenge == base64url(SHA256(code_verifier)) — 표준
S256을 쓰면 Keycloak이 code_challenge를 알아도 code_verifier를 역산할 수 없다. plain은 레거시 클라이언트 호환용이므로 신규 구현에서는 S256만 쓴다.
Confidential Client에서도 쓰는 이유
PKCE는 원래 client_secret을 안전하게 보관할 수 없는 공개 클라이언트(모바일, SPA)용으로 나왔다.
서버 사이드 BFF처럼 client_secret이 있는 confidential client는 이론적으로 PKCE가 없어도 code 가로채기 방어가 된다. 그러나 RFC 9700(OAuth 2.0 for Browser-Based Apps, 2025)은 confidential client에도 PKCE를 권장한다. client_secret이 유출되는 상황을 가정한 defense-in-depth 원칙이다.
더 보기
- OAuth-OIDC-SAML — Authorization Code Flow 전체 개요
- 세션-보안 — 토큰 교환 이후 Redis 세션 설계
- API-Gateway — BFF 게이트웨이에서 PKCE를 처리하는 구조
- XSS-CSRF — state가 방어하는 CSRF 공격 원리