- Published on
OAuth2 PKCE 400 invalid_grant 원인과 해결법
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서드파티 로그인이나 사내 SSO를 붙이다 보면, Authorization Code with PKCE 플로우에서 토큰 엔드포인트가 400 과 함께 invalid_grant 를 반환하는 상황을 자주 만납니다. 문제는 invalid_grant 가 너무 포괄적인 에러라서, 로그를 봐도 원인 파악이 느리다는 점입니다.
이 글은 PKCE 기준으로 invalid_grant 를 유발하는 원인을 토큰 교환 단계에 필요한 값들의 불일치라는 관점에서 정리하고, 가장 흔한 실수부터 운영 환경에서만 터지는 함정까지 빠르게 좁혀갈 수 있게 구성했습니다.
관련해서 리다이렉트 설정 문제는 invalid_grant 와 함께 동반되는 경우가 많으니, 아래 글도 함께 보면 진단 속도가 빨라집니다.
1) 먼저 확인할 것: 에러가 나는 “지점”
PKCE 플로우에서 invalid_grant 는 보통 토큰 교환 요청에서 발생합니다.
/authorize에서code발급/token에서code를access_token으로 교환
즉, 브라우저에서 로그인은 끝났는데 백엔드(또는 SPA)가 /token 호출에서 실패하는 형태가 전형적입니다. 이때 토큰 요청의 핵심 입력은 다음 4가지입니다.
code: 인가 코드redirect_uri: 인가 요청 때 사용한 리다이렉트 URI와 “완전 동일”해야 하는 값code_verifier: 최초 생성한 PKCE 검증 문자열client_id(및 클라이언트 인증 방식) : Public/Confidential 설정에 따라 요구 조건이 달라짐
이 중 하나라도 서버가 기대하는 값과 다르면 invalid_grant 로 뭉뚱그려 떨어지는 경우가 많습니다.
2) 원인 1: code_verifier 불일치 (가장 흔함)
증상
/authorize는 성공/token에서invalid_grant- IdP 로그에
PKCE verification failed류 메시지
대표 실수
code_verifier를 요청마다 새로 생성하고, 토큰 교환 시점에 다른 값이 들어감- 멀티탭/뒤로가기/재시도 중에 verifier 저장소가 덮어써짐
- 모바일에서 앱 재시작으로 메모리 저장 값이 사라짐
해결
state를 키로 해서code_verifier를 안정적으로 저장하고, 콜백에서state로 꺼내 쓴다- SPA라면
sessionStorage가 일반적(탭 단위 격리) - 서버가 중간에서 처리한다면 Redis 같은 외부 저장소로
state기반 저장
Node.js 예제: PKCE 생성 및 검증 값 보관
import crypto from 'crypto';
function base64url(buf: Buffer) {
return buf
.toString('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/g, '');
}
export function createPkcePair() {
const verifier = base64url(crypto.randomBytes(32));
const challenge = base64url(
crypto.createHash('sha256').update(verifier).digest()
);
return { verifier, challenge, method: 'S256' as const };
}
콜백 처리에서 state 를 이용해 verifier를 찾는 구조를 강제하면, “다른 verifier로 교환”하는 실수를 크게 줄일 수 있습니다.
3) 원인 2: redirect_uri 가 인가 요청과 1바이트라도 다름
PKCE든 아니든 Authorization Code 교환에서 redirect_uri 는 종종 필수이며, 많은 IdP가 인가 요청 때의 redirect_uri 와 토큰 요청의 redirect_uri 가 완전히 동일하길 요구합니다.
흔한 불일치 패턴
- 쿼리스트링 유무 차이:
...?a=b를 붙였다/안 붙였다 - 트레일링 슬래시 차이:
/callbackvs/callback/ - 스킴 차이:
httpvshttps - 포트 차이:
:3000포함 여부 - URL 인코딩 차이(특히
:/인코딩을 잘못 처리)
해결
- 인가 요청을 만들 때 사용한
redirect_uri를 그대로 저장해 토큰 요청에 재사용 - 환경별(로컬/스테이징/프로덕션) URI를 명시적으로 분리
이 이슈는 워낙 빈도가 높아 별도 체크리스트를 참고하는 게 빠릅니다.
4) 원인 3: code 재사용 또는 만료
Authorization Code는 일반적으로
- 짧은 TTL
- 1회성
입니다. 따라서 다음 상황에서 invalid_grant 가 발생합니다.
발생 시나리오
- 콜백 URL을 사용자가 새로고침해서 같은
code로 토큰 교환을 두 번 시도 - 서버/클라이언트가 재시도 로직을 잘못 넣어 중복 교환
- 네트워크 지연으로 TTL이 짧은 IdP에서 만료
해결
- 콜백 처리 시
code를 “1회 처리”로 만들기- 서버라면
code를 키로 멱등성 테이블/캐시에 기록 - 이미 처리한
code면 토큰 교환을 스킵하고 기존 세션으로 유도
- 서버라면
- 프론트 라우터에서 콜백 처리 후 즉시
history.replaceState로 URL에서code제거
브라우저 예제: 콜백 처리 후 URL 정리
// 콜백 라우트 진입 직후
const url = new URL(window.location.href);
if (url.searchParams.get('code')) {
url.searchParams.delete('code');
url.searchParams.delete('state');
window.history.replaceState({}, document.title, url.toString());
}
5) 원인 4: PKCE 파라미터 자체가 규격/정책과 불일치
5-1) code_challenge_method 미지원
IdP에 따라 plain 을 막고 S256 만 허용하거나, 반대로 레거시로 plain 만 허용하는 경우도 있습니다.
- 인가 요청에
code_challenge_method=S256를 넣었는데 IdP가 실제로는plain만 처리 - 또는 정책상
S256강제인데plain으로 보내버림
해결은 단순합니다.
- 가능하면
S256사용 - IdP 문서에서 허용 메서드 확인
5-2) code_verifier 길이/문자셋 위반
RFC 7636에서 code_verifier 는 대략적으로
- 길이: 43~128
- 문자: URL safe
요구를 따릅니다. 랜덤 생성이 아니라 임의 문자열을 쓰거나, base64를 그대로 써서 + / = 가 섞이면 실패할 수 있습니다.
위의 예제처럼 base64url로 정규화하면 대부분 해결됩니다.
6) 원인 5: Public/Confidential 클라이언트 설정 충돌
PKCE는 원래 “클라이언트 시크릿을 안전하게 보관하기 어려운 앱”을 위해 널리 쓰입니다. 그런데 IdP 설정에서 클라이언트 타입/인증 방법이 꼬이면 invalid_grant 로 떨어질 수 있습니다.
자주 보는 꼬임
- SPA인데 클라이언트를 Confidential로 만들어
client_secret을 요구 - 모바일 앱인데 토큰 요청에
client_secret을 붙여 보내 정책에 걸림 token_endpoint_auth_method가client_secret_basic인데 요청은 body로 보내는 등 방식 불일치
해결 가이드
- SPA/모바일: Public 클라이언트 + PKCE
- 서버 웹앱: Confidential 클라이언트 + (선택적으로) PKCE
- IdP 콘솔에서
token_endpoint_auth_method와 실제 구현을 일치
토큰 요청 예제: Public 클라이언트(PKCE)
curl -sS -X POST "https://idp.example.com/oauth/token" \
-H "Content-Type: application/x-www-form-urlencoded" \
--data-urlencode "grant_type=authorization_code" \
--data-urlencode "client_id=my-public-client" \
--data-urlencode "code=AUTH_CODE" \
--data-urlencode "redirect_uri=https://app.example.com/callback" \
--data-urlencode "code_verifier=VERIFIER"
토큰 요청 예제: Confidential 클라이언트(기본 인증)
curl -sS -X POST "https://idp.example.com/oauth/token" \
-H "Content-Type: application/x-www-form-urlencoded" \
-u "my-client-id:my-client-secret" \
--data-urlencode "grant_type=authorization_code" \
--data-urlencode "code=AUTH_CODE" \
--data-urlencode "redirect_uri=https://app.example.com/callback" \
--data-urlencode "code_verifier=VERIFIER"
주의: 일부 IdP는 Confidential이라도 PKCE를 함께 요구하거나 권장합니다. 반대로 어떤 IdP는 Confidential에서 PKCE 파라미터를 허용하지만 의미 없을 수 있습니다. 결국 “IdP 설정과 구현이 일치”가 핵심입니다.
7) 원인 6: state 검증 실패가 invalid_grant 로 보이는 경우
표준적으로 state 는 CSRF 방어용이고, 실패 시점은 보통 콜백 처리 단계입니다. 하지만 구현에 따라 state 가 꼬이면서 verifier 매핑이 깨져 결과적으로 /token 에서 invalid_grant 로 보이기도 합니다.
해결
state는 충분히 랜덤하게 생성state별로code_verifier를 저장- 콜백에서
state가 없거나 매칭 실패면 토큰 교환 자체를 시도하지 말고 즉시 로그인 플로우를 재시작
8) 원인 7: 시간 동기화 문제(운영에서만 발생)
인가 코드 자체 TTL이 짧을 때, 서버 시간이 크게 틀어져 있거나, 프록시/게이트웨이에서 지연이 커지면 간헐적으로 만료로 처리될 수 있습니다.
체크
- 서버 노드들의 NTP 동기화
- 토큰 엔드포인트까지의 네트워크 지연(특히 사설망 경유)
인프라 레벨에서 네트워크가 꼬여 인증 요청이 지연될 때가 있는데, 이런 경우 다른 장애와 비슷한 접근(네트워크 경로, 보안그룹, DNS)을 점검하는 습관이 도움이 됩니다.
9) 빠른 진단을 위한 체크리스트(실전)
아래 순서로 보면 대부분 10분 내로 좁혀집니다.
9-1) 토큰 요청 payload를 “그대로” 로깅
민감정보는 마스킹하되, 다음 항목은 최소한 길이/존재 여부를 남기세요.
client_idredirect_uricode존재 여부 및 길이code_verifier존재 여부 및 길이code_challenge_method
예: code_verifier_len=64 처럼 길이만 남겨도 큰 도움이 됩니다.
9-2) 인가 요청과 토큰 요청의 redirect_uri 를 문자열 비교
- 공백 포함 여부
- 대소문자
- 트레일링 슬래시
- 포트
9-3) code 재사용 여부 확인
- 콜백 라우트가 두 번 호출되는지
- 프론트에서 새로고침/뒤로가기 시 어떤 일이 일어나는지
9-4) PKCE 생성 로직 검증
- base64url 인코딩인지
S256해시가 맞는지- verifier 길이가 정책 범위인지
10) 마무리: invalid_grant 는 “값 불일치”로 생각하자
PKCE에서 400 invalid_grant 는 대개 다음 중 하나로 귀결됩니다.
code_verifier가 원래 것과 다르다redirect_uri가 인가 요청 때와 다르다code가 만료되었거나 이미 사용됐다- 클라이언트 인증 방식/정책이 설정과 다르다
가장 좋은 해결책은, 단발성 디버깅이 아니라 구조적으로 불일치가 발생하기 어려운 구현으로 바꾸는 것입니다.
state기준으로 verifier를 저장하고, 멀티탭/재시도를 고려- 콜백 처리 후 URL 정리로 중복 교환 방지
- 인가 요청에서 사용한
redirect_uri를 저장해 토큰 요청에 재사용
이 3가지만 지켜도 PKCE의 invalid_grant 는 체감상 대부분 사라집니다.