LabHub
배우기 러닝패스 코스

Keycloak and Enterprise Identity

The PKCE Flow, One Step at a Time

LabHub 에서 이어서 보기

한국어 원문으로 표시합니다.

한 줄 요약

PKCE 는 인가 코드에 "이 코드는 내가 시작한 요청의 것"이라는 증명을 붙이는 장치다. 시크릿 없이도 코드 가로채기가 무력해진다.

Flow map: verifier 를 모르므로 · 지금은 웹 앱에도 PKCE 를 권합니다. · 셋 다 씁니다. · 문자 하나까지

왜 이게 필요했나

모바일 앱에서 커스텀 URL 스킴으로 콜백을 받던 시절, 악성 앱이 같은 스킴을 등록해 인가 코드를 가로채는 공격이 실제로 가능했습니다. 코드만 있으면 토큰을 받을 수 있었기 때문입니다(공개 클라이언트라 시크릿이 없으므로).

PKCE 는 이 문제를 해결합니다. 코드를 가로채도 토큰을 못 받게 만듭니다.

어떻게 동작하나

흐름을 순서대로 보면 이렇습니다.

PKCE 흐름 — 클라이언트가 무작위 verifier 를 만들고 그 해시인 challenge 만 인가 요청에 실어 보낸다. 서버가 인가 코드를 돌려주면 클라이언트는 코드와 원본 verifier 를 함께 보내고, 서버가 해시를 다시 계산해 저장해 둔 challenge 와 대조한 뒤 토큰을 준다. 코드를 가로챈 공격자는 verifier 를 모르므로 교환에 실패한다

먼저 클라이언트가 무작위 문자열 code_verifier 를 만듭니다. 43자 이상 128자 이하, URL 안전 문자만 씁니다. 이 값은 클라이언트만 알고 있습니다.

다음으로 code_challenge = BASE64URL(SHA256(code_verifier)) 를 계산합니다. 인가 요청 URL 에 code_challengecode_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 앱에 붙입니다.