- Published on
OAuth2 PKCE 400 invalid_grant 원인·해결 가이드
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서드파티 로그인이나 사내 SSO를 붙이다 보면, Authorization Code + PKCE 흐름에서 토큰 엔드포인트가 400 과 함께 invalid_grant 를 반환하는 순간이 자주 옵니다. 문제는 이 에러가 원인 범위가 넓고(코드 재사용, 리다이렉트 불일치, PKCE 검증 실패, 만료 등), IdP마다 메시지가 뭉뚱그려져 있어 로그만 보고는 감이 잘 안 온다는 점입니다.
이 글은 PKCE 기반 OAuth2에서 invalid_grant 를 원인별로 분류하고, 증상-진단 포인트-해결책을 연결해 빠르게 고치도록 돕는 실전 가이드입니다.
invalid_grant가 의미하는 것
OAuth2 스펙에서 invalid_grant 는 “제공된 grant(여기서는 authorization code)가 유효하지 않다”는 포괄적 의미입니다. Authorization Code + PKCE에서는 보통 아래 중 하나입니다.
- authorization code 자체가 유효하지 않음(만료, 이미 사용됨, 잘못된 코드)
- code를 발급받을 때의 조건과 토큰 교환 시 조건이 불일치(redirect URI, client, PKCE verifier 등)
- PKCE 검증 실패(
code_verifier불일치 또는 누락)
즉, 토큰 요청은 정상적으로 도달했지만, 서버가 “이 코드로는 토큰을 줄 수 없다”고 판단한 상황입니다.
가장 흔한 원인 TOP 8
아래는 실제 운영에서 빈도가 높은 순서대로 정리한 원인들입니다.
1) redirect_uri 불일치(가장 흔함)
증상
- 로그인/동의 화면까지는 정상인데 토큰 교환에서
invalid_grant
원인
- 토큰 요청의
redirect_uri가 인가 요청의redirect_uri와 1바이트라도 다르면 실패합니다. - 특히 아래가 자주 틀립니다.
http와https혼용- 트레일링 슬래시(
/) 유무 - 쿼리스트링 포함 여부
- 프록시 뒤에서 외부 도메인과 내부 도메인이 다름
해결
- 인가 요청과 토큰 요청에 완전히 동일한
redirect_uri를 사용하세요. - 프록시 환경에서는 외부 URL을 기준으로 구성하고, 앱에서는
X-Forwarded-Proto/X-Forwarded-Host를 신뢰하도록 설정해야 합니다.
토큰 요청 예시(curl):
curl -X POST "https://idp.example.com/oauth/token" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=authorization_code" \
-d "client_id=my-client" \
-d "code=AUTH_CODE" \
-d "redirect_uri=https://app.example.com/callback" \
-d "code_verifier=VERIFIER_STRING"
2) code_verifier 누락 또는 값 변경(PKCE 검증 실패)
증상
- 토큰 요청에
code_verifier를 넣지 않았거나, 넣었는데도invalid_grant
원인
- 인가 요청에서 만든
code_challenge와 토큰 요청의code_verifier가 매칭되지 않음 code_verifier를 저장해두지 못하고 재생성함- 모바일/SPA에서 페이지 리로드로 메모리 상태가 날아감
- 서버가 여러 대인데 세션 스티키가 없어서 verifier를 못 찾음
해결
code_verifier는 “인가 요청 시작 시 생성”해서 “토큰 교환 시점까지” 안전하게 보관해야 합니다.- SPA:
sessionStorage사용 권장(탭 단위), 보안 요구에 따라 메모리+재시도 전략 - 서버: 사용자 세션 저장소(예: Redis)로 보관
- SPA:
- 토큰 교환 전, 실제로 어떤 verifier가 전송되는지 네트워크 캡처로 확인하세요.
Node.js(개념 예시) PKCE 생성:
import crypto from "crypto";
function base64url(buf) {
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" };
}
3) code_challenge_method 불일치 또는 plain 처리 실수
증상
- 특정 IdP에서만
invalid_grant
원인
- 인가 요청에
code_challenge_method=S256를 보냈는데, 실제 challenge를 SHA-256으로 만들지 않음 - 혹은
plain으로 보냈는데 서버는S256만 허용 - 라이브러리가 자동으로
plain을 선택하거나, 설정이 누락됨
해결
- 가능하면 무조건
S256사용 - 인가 요청 URL에
code_challenge_method=S256가 포함되는지, challenge가 올바른 base64url인지 확인
4) authorization code 만료(짧은 유효시간)
증상
- 로컬에서는 되는데 운영에서 간헐적으로 실패
- 사용자가 로그인 후 잠시 멈췄다가 돌아오면 실패
원인
- code의 TTL이 매우 짧은 IdP가 많습니다(수십 초~수 분)
- 네트워크 지연, 앱 처리 지연, 콜드스타트, 리다이렉트 체인 증가로 토큰 교환이 늦어짐
해결
- 콜백을 받으면 즉시 토큰 교환을 수행
- 콜드스타트/지연이 의심되면 서버 타임아웃/리트라이 정책을 점검(무의미한 리트라이는 코드 만료를 악화)
- 분산 환경에서 데드라인/리트라이를 잘못 잡아 폭주가 나면 인증도 같이 불안정해집니다. 관련해서는 gRPC MSA에서 데드라인·리트라이 폭주 막는 법도 참고할 만합니다.
5) authorization code 재사용(중복 토큰 교환)
증상
- 첫 시도는 성공, 동일 코드로 두 번째 요청부터
invalid_grant - 혹은 사용자 입장에서는 한 번인데 서버 로그에는 토큰 요청이 2번 찍힘
원인
- code는 1회성입니다.
- 다음 케이스가 흔합니다.
- 콜백 처리에서 302 리다이렉트를 잘못 구성해 콜백이 두 번 호출
- 브라우저가 뒤로가기/새로고침으로 콜백 URL을 재호출
- 프론트와 백엔드가 각각 토큰 교환을 시도(이중 교환)
- 네트워크 타임아웃 후 클라이언트가 재시도했지만, 서버에서는 이미 성공 처리
해결
- 콜백 엔드포인트는 멱등성에 가깝게 설계(예:
state를 키로 1회 처리) - 프론트가 토큰 교환을 하면 백엔드는 하지 않거나, 반대로 역할을 명확히 분리
- 타임아웃/재시도 설계 시 “서버에서 성공했는데 클라이언트가 실패로 오인”하는 케이스를 줄이기
6) client_id / 클라이언트 인증 방식 불일치
증상
- 어떤 환경에서는 되는데 특정 환경에서만
invalid_grant
원인
- 퍼블릭 클라이언트(모바일/SPA)인데
client_secret를 잘못 사용하거나 - 컨피덴셜 클라이언트(서버)인데 Basic Auth 또는 폼 파라미터 방식이 IdP 기대와 다름
해결
- IdP 문서에 맞춰 토큰 엔드포인트 인증 방식을 통일
- 퍼블릭 클라이언트는 보통
client_secret없이 PKCE로 보호
7) state 처리 오류로 잘못된 코드에 교환 시도
증상
- 사용자 A의 브라우저에서 사용자 B의 로그인 흐름이 섞이는 듯한 이상 현상
- 간헐적
invalid_grant
원인
state를 세션에 저장하지 않거나, 검증하지 않거나, 동시 로그인 시도를 구분하지 못함- 결과적으로 “내가 받은 code”가 아니라 “다른 흐름의 code”로 토큰 교환을 시도
해결
state는 CSRF 방어이면서 동시성 제어 키입니다.state를 요청 단위로 생성하고, 콜백에서 동일성 검증 후에만 토큰 교환
8) 서버 시간 오차(Clock skew)로 인한 간접 문제
증상
- 특정 서버에서만 유독 실패
- 토큰 발급 후 검증에서도 401이 섞여 나옴
원인
- code 만료 판단은 IdP가 하지만, 애플리케이션이 세션/캐시 TTL을 잘못 계산하거나, 후속 JWT 검증에서 시간 오차로 연쇄 오류가 발생할 수 있습니다.
해결
- NTP 동기화
- 인증/인가 관련 TTL 계산을 단순화
- JWT 401까지 같이 겪는다면 Spring Security JWT 401 원인 - 시계오차·키롤오버에서 시간 오차 관점 점검 포인트를 같이 확인하세요.
재현 가능한 진단 체크리스트
문제를 빠르게 좁히려면 “인가 요청”과 “토큰 요청”을 한 세트로 묶어서 비교해야 합니다.
1) 인가 요청에서 반드시 기록할 것
redirect_uriclient_idstatecode_challengecode_challenge_method- 발급된
code - 발급 시각
2) 토큰 요청에서 반드시 기록할 것
redirect_uri(전송값 그대로)client_idcodecode_verifier길이(값 전체를 로그로 남기기 어렵다면 길이와 해시만)- 요청 시각
- 응답 바디의
error와error_description원문
보안상 code_verifier 를 평문으로 남기기 어렵다면, 아래처럼 해시를 남겨 동일성만 확인할 수 있습니다.
import crypto from "crypto";
function sha256Hex(s) {
return crypto.createHash("sha256").update(s, "utf8").digest("hex");
}
// log: verifierLen, verifierHash, state
console.log({
verifierLen: verifier.length,
verifierHash: sha256Hex(verifier),
state
});
프록시/로드밸런서 환경에서의 함정
운영에서만 invalid_grant 가 나는 경우, 상당수는 프록시 뒤에서 redirect_uri 가 엇갈립니다.
- 외부는
https://app.example.com/callback - 내부 애플리케이션이 인식하는 base URL은
http://10.0.0.12:8080/callback
이 상태에서 인가 요청은 외부 URL로 나가고, 토큰 요청은 내부 URL로 구성되면 redirect_uri 불일치로 즉시 실패합니다.
대응
- 애플리케이션이 외부 스킴/호스트를 인식하도록 설정
- Spring 계열이면
ForwardedHeaderFilter또는 프레임워크 권장 설정을 적용
이런 “인프라 레이어에서만 재현되는 인증 실패”는 클라우드 권한 위임이나 웹 아이덴티티 흐름에서도 자주 나타납니다. 성격은 다르지만, 네트워크/프록시/타임아웃 관점의 트러블슈팅 방식은 EKS IRSA에서 AssumeRoleWithWebIdentity 0s 타임아웃 해결도 참고가 됩니다.
PKCE 파라미터 규격에서 자주 틀리는 디테일
code_verifier길이: 보통 43~128 문자 범위(스펙 권장). 너무 짧거나 너무 길면 IdP가 거부할 수 있음- base64가 아니라 base64url 이어야 함(패딩
=제거,+와/치환) S256일 때 challenge는SHA-256(verifier)를 base64url로 인코딩한 값
테스트할 때는 verifier와 challenge를 고정해두고, 서버가 기대하는 값과 비교하면 빠릅니다.
실전 해결 전략: 원인별 빠른 처방전
아래 순서로 보면 대개 10분 안에 범위를 좁힐 수 있습니다.
redirect_uri를 인가 요청/토큰 요청에서 그대로 비교- 다르면 1순위 원인 확정
- 토큰 요청이 중복으로 나가는지 확인
- 콜백 엔드포인트 로그에서 동일
code로 2회 요청 흔적이 있으면 재사용 문제
- 콜백 엔드포인트 로그에서 동일
code_verifier보관 방식 점검- 리로드/멀티탭/멀티노드에서 verifier가 바뀌는지
code_challenge_method와 실제 계산 로직 확인- 코드 만료 가능성 확인
- 발급 시각과 토큰 요청 시각 차이
- 클라이언트 타입(퍼블릭/컨피덴셜)과 인증 방식 재점검
마무리
400 invalid_grant 는 “토큰 엔드포인트가 싫어하는 뭔가가 있다”는 신호일 뿐, 실제 원인은 대부분 불일치(redirect URI, PKCE verifier, client 설정) 또는 1회성 코드 특성(재사용/만료)에서 나옵니다.
운영에서 재현이 어렵다면, 인가 요청과 토큰 요청의 핵심 파라미터를 한 트랜잭션으로 묶어 관측 가능하게 만들고(특히 redirect_uri, state, verifier 해시), 프록시 환경에서 외부 URL 기준으로 정렬하는 것만으로도 해결되는 경우가 많습니다.