ADFS는 discovery에 광고하지 않아도 PKCE를 강제한다 — negative-first로 실증하기
들어가며
ADFS로 OpenID Connect를 붙이다 보면 한 가지 애매한 지점을 만난다. discovery 문서에 PKCE 지원이 광고되지 않는다는 것이다.
RFC 8414는 authorization server가 지원하는 PKCE 방식을 code_challenge_methods_supported 필드로 광고하도록 정한다. 그런데 ADFS(및 Entra ID)는 이 필드를 discovery에 넣지 않는다. 그러면 자연스레 의문이 생긴다.
ADFS는 PKCE를 지원하지 않는 걸까? 아니면 광고만 안 할 뿐 실제로는 받는 걸까? 받는다면, 검증까지 하는 걸까?
이 글은 그 질문을 문서가 아니라 실측으로 답한 기록이다. 결론부터 말하면, ADFS는 PKCE를 광고하지 않지만 실제로는 검증하고 강제한다. 그리고 그걸 깨끗하게 증명하려면 흔한 방식으로는 부족하고, negative-first라는 작은 기법이 필요하다.
환경 정보
- IdP: ADFS (Windows Server 2019+), 페더레이션 서비스명
sts.example-lab.local(예시값) - 테스트 클라이언트: Python 3.12 + Flask, Native(public) application
- 프로토콜: OIDC Authorization Code + PKCE(S256), client_secret 없음
스크린샷은
/assets/images/27-07-14-pkce/아래에 두고형태로 삽입함. 민감 값(도메인·계정)은 예시로 치환함.
1. discovery는 PKCE를 광고하지 않는다
먼저 discovery 문서를 그대로 떠본다.
import requests
url = f"{ADFS_BASE}/adfs/.well-known/openid-configuration"
conf = requests.get(url, verify=VERIFY, timeout=30).json()
print(conf.get("code_challenge_methods_supported")) # -> None
code_challenge_methods_supported는 없다(None). 대신 다른 필드들은 정상적으로 실려 있다.
| 필드 | 값(요약) |
|---|---|
response_types_supported |
code, id_token, code id_token … |
grant_types_supported |
authorization_code, refresh_token … |
token_endpoint_auth_methods_supported |
client_secret_post, client_secret_basic, private_key_jwt, windows_client_authentication |
여기서 확인되는 건 두 가지다.
response_types_supported에code가,grant_types_supported에authorization_code가 있으니 Authorization Code flow는 사용 가능하다. PKCE는 이 위에 얹는 것이니 전제 조건은 충족된다.- 반면
token_endpoint_auth_methods_supported에none이 없다. public client의 표준 표기가 없다는 뜻인데, 이것도 discovery만으로는 “public client 미지원”이라고 단정할 수 없다. discovery의 침묵과 실제 동작은 별개다.
핵심:
code_challenge_methods_supported의 부재는 PKCE 미지원의 근거가 되지 못한다. ADFS는 광고 없이도 PKCE를 받는 것으로 알려져 있다. 최종 판단은 실제 flow를 던져봐야 한다.
2. Native(public) + PKCE 흐름을 던진다
ADFS에 Native client application(public) 을 하나 등록한다. client_secret이 없는 public client다. RedirectUri는 로컬 루프백(https://localhost:5001/callback)으로 잡는다.

PKCE의 뼈대는 이렇다.
import base64, hashlib, secrets
def b64url(data: bytes) -> str:
return base64.urlsafe_b64encode(data).rstrip(b"=").decode()
verifier = b64url(secrets.token_bytes(32))
challenge = b64url(hashlib.sha256(verifier.encode()).digest())
# authorize 요청에 code_challenge=challenge, code_challenge_method=S256
# token 교환에 code_verifier=verifier (client_secret 없음)
authorize로 리다이렉트해 로그인하면 code가 돌아오고, 그 code를 올바른 verifier와 함께 token 엔드포인트로 교환한다.
결과는 정상 토큰 발급이었다.
{
"positive_exchange": {
"http": 200,
"access_token_present": true,
"refresh_token_present": true
},
"id_token_claims": {
"aud": "<native-client-id>",
"iss": "https://sts.example-lab.local/adfs",
"upn": "user01@example-lab.local",
"unique_name": "EXAMPLE\\user01"
}
}

client_secret 없이 code_verifier만으로 교환이 성공했다. id_token도 서명(RS256, jwks 대조)·audience·issuer·nonce 검증을 모두 통과했다.
1차 실증: ADFS는 discovery에 광고하지 않지만 Native(public)+PKCE 조합을 받아준다. refresh_token까지 발급된다.
하지만 여기엔 함정이 있다. “받아준다”는 것과 “검증한다“는 것은 다르다. PKCE를 받기만 하고 code_verifier를 실제로 대조하지 않는다면, 그건 PKCE가 아니라 장식이다. 이걸 가려내야 한다.
3. 왜 “positive 후 negative”로는 증명이 안 되는가
가장 먼저 떠오르는 검증 방법은 이렇다.
- 올바른 verifier로 교환 → 성공(200)
- 이어서 틀린 verifier로 교환 → 실패하는지 관찰
그런데 이렇게 하면 2단계에서 반드시 invalid_grant이 나긴 나는데, 그 이유가 애매하다. authorization code는 1회용이라 1단계에서 이미 소진됐기 때문이다. 즉 2단계의 거부가
- PKCE 검증 실패 때문인지,
- 아니면 그냥 code 재사용(이미 소진된 code) 때문인지
구분할 수 없다. 실제로 이 경우 ADFS는 다음 에러를 반환한다.
MSIS9612: The authorization code received in 'code' parameter is invalid.
이건 “code가 무효(소진됨)”라는 메시지이지, PKCE에 대한 언급이 아니다. 검증에 대한 결론을 여기서 내릴 수 없다.
4. negative-first — code를 소진하기 전에 틀린 verifier부터
해법은 순서를 뒤집는 것이다. code를 소진하기 전에, 틀린 verifier를 먼저 던진다.
- authorize로 code 1개 확보
- 틀린 verifier로 먼저 교환 시도 → 이때 code는 아직 미소진 상태
- 여기서 나는 거부는 code 재사용이 아니라 순수 PKCE 검증 실패다
- (부가 관찰) 그 다음 올바른 verifier로 재시도 → code가 이미 소진됐는지 확인
핵심 구현은 이렇다. 쿠키 세션 대신 서버측 저장소(state를 키로)를 쓴 이유는 뒤에서 설명한다.
@app.route("/callback")
def callback():
state = request.args.get("state")
flow = FLOW_STORE.pop(state, None) # 1회용: 꺼내면서 삭제
if flow is None:
return "state 불일치", 400
code = request.args.get("code")
token_endpoint = flow["token_endpoint"]
good_verifier = flow["verifier"]
# negative-first: 틀린 verifier 를 '먼저' (code 미소진 상태)
wrong_verifier = b64url(secrets.token_bytes(32))
neg = exchange_code(token_endpoint, code, wrong_verifier)
# 그 다음 올바른 verifier 로 재시도
pos = exchange_code(token_endpoint, code, good_verifier)
verdict = ("PKCE 실검증됨" if neg.status_code != 200
else "주의: 틀린 verifier인데도 발급됨(=검증 안 함)")
# ... 결과 정리 ...
실측 결과는 다음과 같았다.
{
"step1_wrong_verifier": {
"http": 400,
"error": "invalid_grant",
"error_description": "MSIS9720: Unable to validate code_verifier."
},
"step2_correct_verifier_after": {
"http": 400,
"error": "invalid_grant"
},
"VERDICT": "ADFS가 PKCE를 실제로 검증함. 틀린 code_verifier -> 거부(invalid_grant). discovery에 광고 없어도 강제됨."
}

5. 결정적 증거: MSIS9612 vs MSIS9720
이 실측의 핵심은 에러 코드가 갈렸다는 점이다.
| 상황 | 에러 코드 | 의미 |
|---|---|---|
| code 재사용 (소진된 code) | MSIS9612 |
authorization code가 무효 |
| 틀린 verifier (code 미소진) | MSIS9720 |
code_verifier 검증 실패 |
MSIS9720: Unable to validate code_verifier — ADFS가 PKCE 검증 그 자체에서 실패했다고 명시적으로 보고한 것이다. code 소진(MSIS9612)과는 완전히 다른, PKCE에 특정된 에러다.
negative-first로 code를 소진하기 전에 틀린 verifier를 던졌기 때문에, 이 거부가 다른 이유(재사용 등)가 섞이지 않은 순수 PKCE 실패임이 분리 증명됐다. 만약 positive를 먼저 했다면 MSIS9612에 묻혀 이 증거를 볼 수 없었을 것이다.
부가로 step2도 의미가 있다. 틀린 verifier 교환 실패 후에 올바른 verifier로 재시도했더니 이것도 invalid_grant이었다. 즉 ADFS는 실패한 교환에서도 authorization code를 소진시킨다. 한 번 토큰 교환에 쓰인 code는 성공이든 실패든 무효화하는 것으로, 이는 보안적으로 올바른 동작이다. (동시에, 이 때문에 code 하나로 positive와 negative를 모두 깨끗이 관찰할 수 없어 negative-first가 필요했다는 점이 재확인된다.)
6. 곁다리 트러블슈팅 — 세션 쿠키 대신 서버측 state
실측 도중 로컬 Flask 앱에서 콜백마다 세션이 통째로 비는 문제(session keys = [])를 만났다. https 로컬 + 자가서명 인증서 + InPrivate 조합에서 세션 쿠키가 콜백 요청에 실리지 않았다.
PKCE 검증이 목적인 실측 도구였기에, 근본 원인 추적 대신 쿠키 의존을 제거하는 방향으로 우회했다. state를 키로 서버 메모리 dict(FLOW_STORE)에 흐름 상태(verifier/nonce/endpoints)를 저장하고, 콜백에서 돌아온 state로 되찾는 방식이다.
FLOW_STORE = {} # state -> {verifier, nonce, token_endpoint, ...}
# /login 에서 저장
FLOW_STORE[state] = {"verifier": verifier, "nonce": nonce, ...}
# /callback 에서 되찾음 (쿠키 왕복 불필요)
flow = FLOW_STORE.pop(state, None)
주의 — 이건 “정석”이 아니다. 표준 CSRF 방어에서 state는 세션(쿠키)에 저장한 값과 대조해 “이게 이 사용자의 요청인가”를 확인해야 한다. 서버 전역 dict의 키로만 쓰면 그 사용자 바인딩이 약해져 CSRF 방어가 느슨해진다.
그럼에도 이 실측 도구에서 용인되는 이유는, ① localhost 단일 사용자 폐쇄망이라 CSRF 표면이 실질적으로 없고, ② 검증 대상이 CSRF가 아니라 PKCE 강제 여부라 이 저장 방식이 결과에 영향을 주지 않기 때문이다. RFC 9700 관점에서도 PKCE가 있으면 code 탈취 시 재사용을 code_verifier가 막아 state의 역할을 상당 부분 대신한다. 즉 “PKCE가 있으니 용인되는 단순화”이지, 프로덕션 세션 관리의 모범은 아니다. 실무(예: Django 연동)에서는 세션 기반 state 검증을 정상적으로 써야 한다.
정리
이번 실측으로 확정된 사실.
- ADFS는 discovery에 PKCE를 광고하지 않는다 —
code_challenge_methods_supported부재. 하지만 이는 미지원 근거가 아니다. - ADFS는 Native(public)+PKCE를 받는다 — client_secret 없이 code_verifier만으로 토큰(+refresh_token) 발급.
- ADFS는 PKCE를 실제로 검증·강제한다 — 틀린 code_verifier를
MSIS9720으로 거부. 이것이 1·2 사이의 간극을 메우는 결정적 증거다.
교훈을 한 줄로 남기면 — discovery의 침묵을 미지원으로 읽지 말 것. 그리고 “받는다”와 “검증한다”를 가르려면 code를 소진하기 전에 틀린 값을 먼저 던지는 negative-first가 필요하다.
MSIS9612(code 소진)와 MSIS9720(PKCE 실패)의 구분은 검색으로 잘 나오지 않는 실무 지식이다. ADFS OIDC를 붙이는 누군가에게 이 기록이 시간을 아껴주길.
Comments