OAuth 2.0 PKCE: 인가 코드를 가로채도 토큰으로 못 바꾸게 하는 방법

PKCE의 code_verifier와 code_challenge가 인가 코드 가로채기를 어떻게 막는지, S256 계산과 서버 검증, plain과 다운그레이드 문제, state와의 경계, PKCE가 막지 못하는 것을 RFC 7636과 RFC 9700으로 정리합니다.

oauthpkcesecurityauthenticationrfc

OAuth 2.0의 인가 코드(authorization code)는 브라우저나 운영체제의 리다이렉트를 거쳐 클라이언트에 도착합니다. 토큰 엔드포인트와 달리 이 구간은 클라이언트가 직접 통제하지 못합니다. PKCE(Proof Key for Code Exchange, RFC 7636)는 이 코드가 중간에 새어 나가더라도 다른 사람이 토큰으로 바꾸지 못하게 하는 확장입니다. 이 글에서는 PKCE가 정확히 무엇을 증명하는지, 서버와 클라이언트가 각각 무엇을 지켜야 하는지, 그리고 PKCE를 넣었다고 해결되지 않는 문제가 무엇인지를 원문 RFC 기준으로 정리합니다.

인가 코드 흐름에서 코드가 지나가는 길

RFC 6749 4.1절의 인가 코드 흐름은 다음 순서로 진행됩니다.

  1. 클라이언트가 사용자의 브라우저(user-agent)를 인가 엔드포인트로 보냅니다. 요청에는 client_id, redirect_uri, scope, state 등이 들어갑니다.
  2. 인가 서버가 사용자를 인증하고 접근 허용 여부를 받습니다.
  3. 인가 서버가 브라우저를 redirect_uri로 돌려보내면서 쿼리에 code를 붙입니다.
  4. 클라이언트가 그 코드를 토큰 엔드포인트로 보내 액세스 토큰을 요청합니다.
  5. 인가 서버가 코드와 리다이렉트 URI를 확인하고 토큰을 발급합니다.

RFC 6749는 인가 코드가 짧게 살아야 하고(최대 10분 권장), 한 번만 쓰여야 하며, client_id와 리다이렉트 URI에 묶인다고 정합니다. 기밀 클라이언트(confidential client)라면 4단계에서 클라이언트 인증까지 하므로, 코드만 훔쳐서는 토큰을 받기 어렵습니다.

문제는 공개 클라이언트(public client)입니다. RFC 6749 2.1절은 기기에 설치되는 네이티브 앱이나 브라우저에서 실행되는 앱을 자격 증명의 기밀성을 유지할 수 없는 공개 클라이언트로 분류합니다. 이런 클라이언트에는 비밀이 없으니, 코드를 손에 넣은 쪽이 누구든 토큰 엔드포인트에 그 코드를 들고 가면 됩니다.

RFC 7636이 다룬 공격

RFC 7636 1절은 스마트폰 시나리오를 예로 듭니다. 네이티브 앱은 리다이렉트 URI로 커스텀 URI 스킴을 쓰는 경우가 많은데, 악성 앱이 같은 스킴의 핸들러로 자신을 등록하면 3단계의 리다이렉트를 받아 인가 코드를 가로챌 수 있습니다. 그 뒤 악성 앱이 직접 토큰을 요청합니다.

RFC는 이 공격이 성립하는 전제 조건을 나열합니다. 운영체제가 여러 앱이 같은 커스텀 스킴을 등록하도록 허용해야 하고, 인가 코드 그랜트를 써야 하며, 공격자가 client_id(그리고 있다면 client_secret)를 알아야 합니다. 같은 네이티브 앱의 모든 설치본은 같은 client_id를 쓰고, 앱 바이너리에 넣은 비밀은 기밀로 볼 수 없다는 점도 함께 지적합니다. 조건이 길지만 RFC는 이 공격이 실제로 관찰되었다고 적고 있습니다.

PKCE가 추가하는 세 가지 값

PKCE는 인가 요청과 토큰 요청에 매개변수를 하나씩 더해, 토큰 요청이 인가 코드에 묶인 challenge에 대응하는 verifier를 제시하는지 서버가 확인하게 합니다.

매개변수 보내는 곳 내용
code_challenge 인가 요청 code_verifier를 변환한 값. 필수
code_challenge_method 인가 요청 S256 또는 plain. 생략하면 plain으로 간주
code_verifier 토큰 요청 클라이언트가 요청마다 새로 만든 비밀 값. 필수

클라이언트는 인가 요청마다 code_verifier를 새로 만들어 보관하고, 변환한 code_challenge만 인가 요청에 실어 보냅니다. 인가 서버는 발급하는 코드에 이 challenge와 method를 연결해 둡니다. 나중에 토큰 요청이 오면 함께 온 code_verifier를 같은 방식으로 변환해 저장된 challenge와 비교합니다. 리다이렉트에서 코드를 가로챈 쪽은 verifier를 모르므로 토큰을 받을 수 없습니다. RFC 7636이 이것을 “code verifier의 소유 증명”이라고 부르는 이유입니다.

code_verifier 형식과 S256 계산

RFC 7636 4.1절의 code_verifier 규칙은 다음과 같습니다.

  • 문자 집합: RFC 3986의 unreserved 문자, 즉 A-Z, a-z, 0-9, -, ., _, ~
  • 길이: 43자 이상 128자 이하
  • 추측이 비현실적일 만큼 엔트로피가 충분해야 함(SHOULD)

권장 생성 방법은 적절한 난수 생성기로 32바이트를 만든 뒤 base64url로 인코딩하는 것입니다. 32바이트는 256비트이고, 패딩 없이 인코딩하면 정확히 43자가 됩니다. 7.1절은 최소 256비트 엔트로피를 권장(SHOULD)하며, 보안 모델 전체가 verifier를 공격자가 알아내거나 추측하지 못한다는 사실에 기대고 있다고 강조합니다.

S256 변환은 이렇게 정의됩니다.

code_challenge = BASE64URL-ENCODE(SHA256(ASCII(code_verifier)))

여기서 base64url은 RFC 4648 5절의 URL 안전 문자 집합(+ 대신 -, / 대신 _)을 쓰고, 끝의 = 패딩을 모두 제거하며, 줄바꿈이나 공백을 넣지 않는 인코딩입니다. 일반 base64 함수 결과를 그대로 쓰거나 패딩을 남겨 두면 서버 계산과 맞지 않습니다. SHA-256 출력은 32바이트이므로 S256 challenge도 43자가 됩니다.

RFC 7636 부록 B의 예시 값입니다.

code_verifier  = dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk
code_challenge = E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM

인가 요청:  code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
            &code_challenge_method=S256
토큰 요청:  code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk

직접 구현했다면 이 값을 테스트 벡터로 쓰면 됩니다. 같은 verifier를 넣었을 때 같은 challenge가 나와야 합니다.

7.3절은 challenge에 솔트를 넣지 않는 이유도 설명합니다. verifier 자체가 256비트 엔트로피를 가지므로 공개된 값을 덧붙여 해시해도 무차별 대입에 필요한 시도 횟수가 늘지 않는다는 것입니다. 비밀번호 해시와 비슷해 보여도, 저엔트로피 비밀번호와는 전제가 다릅니다.

서버가 해야 하는 일

RFC 7636이 서버에 요구하는 내용을 정리하면 다음과 같습니다.

  • 인가 코드를 발급할 때 code_challenge와 code_challenge_method를 그 코드와 연결해야 합니다(MUST). 코드 안에 암호화해 넣어도 되고 서버에 저장해도 됩니다. 다만 challenge를 다른 주체가 추출할 수 있는 형태로 클라이언트 요청에 포함해서는 안 됩니다(MUST NOT).
  • PKCE를 요구하는 서버가 code_challenge 없는 요청을 받으면 invalid_request 오류를 반환해야 합니다(MUST). 지원하지 않는 변환 방식을 요청받았을 때도 마찬가지입니다.
  • 토큰 엔드포인트는 코드 발급 시점에 묶인 method로 code_verifier를 검증해야 합니다(MUST). 값이 다르면 invalid_grant를 반환해야 합니다(MUST).
  • S256은 서버의 필수 구현(MTI) 방식입니다.

RFC 7636 5절은 호환성을 위해 PKCE를 쓰지 않는 클라이언트를 받아도 된다(MAY)고 했습니다. 이 여지가 뒤에서 다룰 다운그레이드 공격의 출발점이 됩니다.

plain이 S256과 같지 않은 이유

plain은 code_challenge = code_verifier, 즉 변환을 하지 않습니다. 그러면 인가 요청에 verifier가 그대로 실립니다.

RFC 7636은 공격자를 두 수준으로 나눕니다. 인가 엔드포인트의 응답만 볼 수 있는 공격자라면 plain으로도 막힙니다. 하지만 운영체제의 HTTP 로그 유출 등으로 요청까지 볼 수 있는 공격자라면 plain의 challenge가 곧 verifier이므로 소용이 없고, S256이나 암호학적으로 안전한 확장 방식이 필요합니다. RFC 9700 3절도 인가 요청을 읽을 수 있는 공격자(A4)를 별도로 상정합니다.

그래서 RFC 7636은 다음과 같이 정합니다.

  • 클라이언트가 S256을 쓸 수 있다면 반드시 S256을 써야 합니다(MUST). plain은 기술적 이유로 S256을 쓸 수 없고 서버가 plain을 지원한다는 것을 대역 외 설정으로 알고 있을 때만 허용됩니다.
  • 새 구현에서는 plain을 쓰지 않아야 합니다(SHOULD NOT).
  • S256을 시도한 뒤 plain으로 내려가면 안 됩니다(MUST NOT). PKCE를 지원하는 서버는 S256을 지원해야 하고, 지원하지 않는 서버는 모르는 매개변수를 무시하므로, S256에서 오류가 났다면 서버 결함이거나 중간자의 다운그레이드 시도라는 설명입니다.

code_challenge_method를 생략하면 기본값이 plain이라는 점도 기억해야 합니다. method를 빼먹으면 의도와 다르게 plain으로 처리됩니다.

RFC 9700 2.1.1절은 현재 기준으로 인가 요청에 verifier를 노출하지 않는 방식은 S256뿐이라고 명시합니다.

PKCE 다운그레이드 공격

RFC 9700 4.8절은 PKCE를 지원하지만 강제하지는 않는 서버에서 생기는 공격을 설명합니다. 전제는 두 가지입니다. 서버가 code_challenge가 있으면 PKCE를 적용하고 없으면 적용하지 않는다는 것, 그리고 클라이언트가 state를 쓰지 않거나 제대로 확인하지 않는다는 것입니다.

  1. 피해자가 클라이언트에서 로그인을 시작합니다. 클라이언트는 code_challenge=hash(abc)를 보내고 응답을 기다립니다.
  2. 공격자가 자기 기기에서 같은 클라이언트로 흐름을 시작합니다. 클라이언트가 만든 code_challenge=hash(xyz)를 공격자가 요청에서 통째로 지웁니다. 자기 기기이니 브라우저 개발자 도구로도 가능합니다.
  3. 서버는 PKCE 없는 흐름으로 보고 challenge에 묶이지 않은 코드를 발급합니다.
  4. 공격자는 그 코드가 담긴 인가 응답 URL로 피해자의 브라우저를 보냅니다.
  5. 클라이언트는 피해자 세션의 code_verifier=abc와 함께 코드를 교환하려 합니다.
  6. 서버는 이 코드에 challenge가 없으니 verifier를 확인하지 않고 토큰을 발급합니다. 피해자의 세션에 공격자 계정의 리소스가 연결됩니다.

RFC 9700은 이 공격을 서버가 막아야 한다(MUST)고 정합니다. 방법은 단순합니다. 인가 요청에 code_challenge가 없었는데 토큰 요청에 code_verifier가 들어 있으면 거부합니다. 모든 흐름(또는 특정 클라이언트)에 PKCE를 강제하는 서버는 이 조건을 자연히 만족합니다. 또한 클라이언트가 유효한 code_challenge를 보냈다면 서버는 토큰 엔드포인트에서 code_verifier의 올바른 사용을 강제해야 합니다(MUST).

state, nonce와의 경계

RFC 6749는 리다이렉트 엔드포인트의 CSRF 방어를 클라이언트에 요구하고(MUST), 그 값을 state로 전달할 것을 권합니다(SHOULD). RFC 9700 2.1절은 여기에 조건을 붙입니다.

  • 인가 서버가 PKCE를 지원한다는 것을 확인한 클라이언트는 PKCE가 주는 CSRF 보호에 기대도 됩니다(MAY).
  • OpenID Connect 흐름에서는 nonce가 CSRF 보호를 제공합니다.
  • 그 외에는 사용자 에이전트에 안전하게 묶인 일회용 CSRF 토큰을 state에 담아 써야 합니다(MUST).

즉 “PKCE를 쓰니 state는 필요 없다”는 서버의 PKCE 지원을 확인했을 때만 성립합니다. RFC 9700 4.7.1절은 서버가 PKCE를 지원하지 않으면 state나 nonce를 써야 한다(MUST)고 하고, 서버는 자신의 PKCE 지원 여부를 알아낼 방법을 제공해야 한다(MUST)고 합니다. 권장 방식은 인가 서버 메타데이터(RFC 8414)의 code_challenge_methods_supported입니다. 앞의 다운그레이드 공격에서 보듯, 서버의 강제 없이 PKCE만 믿고 state를 생략한 클라이언트는 공격 전제 조건 하나를 채워 주게 됩니다. state에 애플리케이션 상태를 담는다면 변조와 바꿔치기를 막아야 한다는 요구도 별도로 있습니다.

nonce는 인가 코드 주입 방어에서도 PKCE의 대안으로 언급됩니다. RFC 9700은 추가 조건을 지키는 기밀 OpenID Connect 클라이언트에 한해 nonce를 PKCE 대신 써도 된다(MAY)고 하면서, nonce는 공개 클라이언트의 코드를 보호하지 못한다고 분명히 합니다. 공격자가 주입할 필요 없이 토큰 엔드포인트를 직접 호출하면 되기 때문입니다.

RFC 7636과 RFC 9700의 역할 구분

두 문서의 요구 수준이 다르므로 섞어 읽지 않는 편이 좋습니다.

항목 RFC 7636 (2015, 표준 트랙) RFC 9700 (2025, BCP 240)
누가 PKCE를 써야 하나 공개 클라이언트를 대상으로 설계. 클라이언트는 모든 서버에 매개변수를 보내야 함(SHOULD) 공개 클라이언트는 필수(MUST), 기밀 클라이언트도 권장(RECOMMENDED). 웹 앱 포함 모든 클라이언트에 적용
서버 지원 PKCE 비사용 클라이언트를 받아도 됨(MAY). 지원한다면 S256은 필수 구현 인가 서버는 PKCE를 지원해야 함(MUST)
방식 S256 가능하면 S256 필수(MUST), plain은 예외적 허용 verifier를 노출하지 않는 방식 사용(SHOULD). 현재는 S256뿐
다운그레이드 클라이언트는 S256 시도 후 plain으로 내려가면 안 됨 challenge 없이 발급된 코드에 verifier가 오면 서버가 거부해야 함(MUST)
지원 여부 노출 규정 없음 탐지 수단 제공 필수(MUST), 메타데이터 공개 권장
값의 성격 요청마다 새 verifier challenge는 트랜잭션마다 달라야 하고 클라이언트와 사용자 에이전트에 묶여야 함(MUST). 서버는 고정값 사용 탐지에 노력하도록 권고

RFC 9700은 RFC 6749, 6750, 6819를 갱신(Updates)하는 BCP이며, 기존 보안 조언을 대체하지 않고 보완한다고 스스로 밝힙니다. RFC 9700 자체는 RFC 7636을 갱신 대상으로 표시하지 않으므로, 위 표의 오른쪽 열은 RFC 7636의 규범 문장이 아니라 그 위에 얹힌 현재의 모범 사례 요구입니다.

PKCE가 막지 못하는 것

PKCE가 증명하는 것은 “이 인가 트랜잭션에 묶인 challenge에 대응하는 verifier를 가지고 있다”는 것뿐입니다. 그 밖의 문제는 다른 장치가 맡습니다.

  • 사용자 인증이 아닙니다. 사용자를 인증하는 것은 인가 흐름 2단계의 인가 서버입니다. PKCE는 누가 로그인했는지에 관해 아무것도 말해 주지 않습니다.
  • 클라이언트 신원 확인도 아닙니다. 코드를 특정 클라이언트 인스턴스와 트랜잭션에 묶을 뿐, 그 클라이언트가 정당한 앱인지 증명하지 않습니다.
  • 리다이렉트 URI 검증을 대체하지 않습니다. RFC 9700은 등록된 리다이렉트 URI와의 정확한 문자열 비교(로컬호스트 네이티브 앱의 포트 번호만 예외)를 서버에 요구하고(MUST), RFC 6749도 토큰 요청의 redirect_uri가 인가 요청과 같은지 확인하도록 합니다. 또 RFC 9700 4.5.4절은 공격자가 피해자의 인가 요청에 들어가는 code_challenge를 바꿀 수 있다면 PKCE를 우회해 코드를 주입할 수 있다고 적고, 그렇기에 인가 응답이 새어 나가지 않게 하는 조치가 여전히 필요하다고 말합니다.
  • 오픈 리다이렉터를 막지 않습니다. RFC 9700은 클라이언트와 인가 서버가 오픈 리다이렉터를 노출해서는 안 된다(MUST NOT)고 별도로 요구합니다.
  • 이미 발급된 토큰은 보호하지 않습니다. PKCE의 검사는 코드를 토큰으로 바꾸는 순간에만 일어납니다. 액세스 토큰이 유출되면 RFC 9700이 권하는 송신자 제약(mTLS, DPoP)이나 대상 제한이 필요하고, 공개 클라이언트의 리프레시 토큰은 송신자 제약이나 로테이션을 써야 합니다(MUST).
  • 서버가 지원을 확인해 주지 않으면 CSRF 방어로 쓸 수 없습니다. 앞 절의 조건이 그대로 적용됩니다.
  • 여러 인가 서버와 통신하는 클라이언트의 믹스업 공격에는 iss 매개변수 같은 별도 방어가 필요합니다(RFC 9700 2.1절).

구현 점검 목록

클라이언트

  • 인가 요청마다 암호학적 난수로 새 code_verifier를 만들고 그 흐름이 끝날 때까지만 보관합니다.
  • code_challenge_method=S256을 명시합니다. 생략하면 plain입니다.
  • base64url 인코딩에서 패딩을 지우고 -, _ 문자 집합을 쓰는지 RFC 부록 B 값으로 확인합니다.
  • S256이 거부되어도 plain으로 재시도하지 않습니다.
  • 서버의 PKCE 지원을 메타데이터 등으로 확인하지 않았다면 state를 CSRF 방어용으로 계속 사용합니다.

인가 서버

  • 발급하는 코드에 challenge와 method를 묶고, 토큰 엔드포인트에서 그 method로 검증합니다. 불일치는 invalid_grant입니다.
  • challenge 없이 발급된 코드에 code_verifier가 붙어 오면 거부합니다. 가능하면 PKCE를 모든 흐름에 강제합니다.
  • code_challenge_methods_supported를 메타데이터에 공개합니다.
  • 리다이렉트 URI 정확 일치, 코드의 짧은 수명과 일회성 같은 기존 요구도 그대로 지킵니다.

출처

블로그로