- Published on
OAuth2 PKCE에서 invalid_grant 뜰 때 7가지 점검
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서드파티 IdP(Okta, Auth0, Cognito, Keycloak 등)든 사내 Authorization Server든, Authorization Code + PKCE 플로우에서 /token 호출이 invalid_grant로 떨어지면 체감 난이도가 확 올라갑니다. 이유는 간단합니다. invalid_grant는 “그랜트가 유효하지 않다”라는 포괄적 에러라서, 실제 원인은 코드 만료/재사용/리다이렉트 불일치/PKCE 검증 실패/클라이언트 불일치 등 여러 갈래로 흩어지기 때문입니다.
이 글은 “PKCE인데 왜 invalid_grant?” 상황에서 가장 자주 터지는 7가지 원인을, 확인 순서와 함께 정리합니다. (대부분은 서버 로그 한 줄과 요청 파라미터 비교만으로도 바로 잡힙니다.)
> 참고로 인증/권한 이슈는 메시지가 뭉뚱그려 나오는 경우가 많습니다. 비슷한 맥락으로 GitHub OIDC에서도 원인이 다른데 결과가 비슷하게 보일 때가 있는데, 그때는 이 글도 도움이 됩니다: GitHub Actions OIDC STS 실패 - InvalidIdentityToken
PKCE에서 invalid_grant가 의미하는 것
OAuth2 스펙에서 invalid_grant는 대체로 다음 범주를 포함합니다.
authorization_code가 존재하지 않거나- 만료되었거나
- 이미 사용되었거나
- redirect_uri / client / code_verifier 등과 매칭이 실패했거나
- 정책상(테넌트/사용자/세션) 더 이상 유효하지 않게 되었거나
PKCE에서는 특히 code_verifier → code_challenge 검증이 추가되므로, 검증 실패도 invalid_grant로 뭉쳐서 나오는 IdP가 많습니다.
빠른 진단 체크리스트(요약)
아래 7가지를 위에서부터 순서대로 확인하면, 실무에서 대부분의 invalid_grant는 10분 안에 좁혀집니다.
- Authorization Code 만료(짧은 TTL, 지연/재시도)
- Code 재사용(중복 토큰 교환)
- redirect_uri 불일치(문자열 완전 일치 필요)
- code_verifier 불일치/변조(저장/복원/인코딩 문제)
- code_challenge_method 불일치(S256 vs plain)
- client_id/앱 설정 불일치(공용/비공용, confidential 혼용)
- 세션/정책으로 코드 무효화(로그아웃, MFA, nonce/state 꼬임)
이제 각각을 “어떻게 확인하고 어떻게 고치는지”로 들어가겠습니다.
1) Authorization Code가 만료됐다 (TTL/지연/시계 오차)
Authorization Code는 원래 매우 짧게 유효합니다(수십 초~수분). 모바일에서 네트워크가 느리거나, 백엔드에서 토큰 교환을 큐에 넣었다가 처리하거나, 프록시/게이트웨이에서 지연이 생기면 만료로 invalid_grant가 뜹니다.
확인 방법
- Authorization Server 로그에서
code expired류 메시지 확인 - 프론트에서
/authorize로 code 받은 시각과/token호출 시각 차이 측정 - 시스템 시간이 틀어져 있지 않은지(NTP) 확인
해결 방법
/token교환을 즉시 수행(리다이렉트 직후)- 불필요한 재시도/큐잉 제거
- 서버/클라이언트 시간 동기화
재현용 로그 포인트
iat(issued at)와 현재 시간 차이- AS가 기록하는
code_issue_time,code_expire_time
2) Authorization Code를 재사용했다 (중복 교환)
Authorization Code는 1회성입니다. 같은 code로 /token을 두 번 치면 보통 두 번째는 invalid_grant입니다.
실무에서 흔한 패턴:
- 프론트가 리다이렉트 콜백을 두 번 실행(React StrictMode, 라우터 중복)
- 백엔드가 타임아웃으로 판단해 재시도했지만, 사실 첫 요청은 성공했고 응답만 늦게 옴
- 모바일에서 앱이 백그라운드/포그라운드 전환하며 콜백 핸들러가 중복 실행
확인 방법
/token요청이 동일 code로 2번 이상 찍히는지(서버 access log)- 요청의
code파라미터를 마스킹 후 해시로 저장해 중복 여부 확인
해결 방법
- 콜백 핸들러에 단발성 가드(이미 처리한 code면 무시)
- 백엔드 토큰 교환 로직에 idempotency 적용(예: code 해시를 키로 1회 처리)
예시: Node/Express에서 code 1회 처리 가드(간단 버전)
import crypto from "crypto";
const usedCodes = new Set(); // 실제로는 Redis 권장
function codeKey(code) {
return crypto.createHash("sha256").update(code).digest("hex");
}
app.get("/oauth/callback", async (req, res) => {
const { code } = req.query;
const key = codeKey(String(code));
if (usedCodes.has(key)) {
return res.status(409).send("code already processed");
}
usedCodes.add(key);
// 여기서 /token 교환
res.send("ok");
});
3) redirect_uri가 1바이트라도 다르다 (완전 일치)
PKCE 여부와 무관하게, /authorize 요청에 넣은 redirect_uri와 /token 요청에 넣는 redirect_uri는 대부분의 IdP에서 완전 일치(exact match) 해야 합니다.
자주 틀리는 포인트:
- trailing slash:
https://app/callbackvshttps://app/callback/ - URL 인코딩 차이:
%2F처리 - 쿼리 포함 여부:
.../callback?env=prod - http/https, 포트, 대소문자
확인 방법
/authorize와/token에서 실제로 전송된redirect_uri를 그대로 로깅- IdP 콘솔에 등록된 redirect URI와 비교
해결 방법
- 앱에서 redirect URI를 상수화하고 두 요청에 동일 값 사용
- 환경별 도메인/포트가 다르면 IdP에 모두 등록
예시: curl로 /token 요청 시 redirect_uri 포함
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=YOUR_CLIENT_ID" \
--data-urlencode "code=AUTH_CODE" \
--data-urlencode "redirect_uri=https://app.example.com/oauth/callback" \
--data-urlencode "code_verifier=YOUR_CODE_VERIFIER"
4) code_verifier가 다르다 (저장/복원/인코딩 문제)
PKCE의 핵심은 code_verifier입니다. /authorize에서 만든 verifier로부터 challenge를 만들고, /token에서 같은 verifier를 제출해야 합니다.
invalid_grant를 만드는 대표 실수:
- verifier를 로컬스토리지/세션에 저장했는데, 콜백 시점에 다른 탭/다른 세션이 열림
- SPA 라우팅 중 state가 초기화되어 verifier가 사라짐
- base64url이 아닌 base64를 사용(패딩
=포함,+//포함) - verifier 문자열에 개행/공백이 섞임
확인 방법
/authorize직전에 생성한code_verifier를 (개발 환경에서만) 콘솔/로그로 확인- 콜백에서
/token요청에 실린 verifier와 동일한지 비교
해결 방법
- verifier 저장소를 탭/세션에 안전하게 묶기(예: sessionStorage + state 키)
- base64url 인코딩 구현을 검증된 라이브러리로 교체
예시: 브라우저에서 S256 code_challenge 생성(정석)
function base64url(bytes) {
return btoa(String.fromCharCode(...bytes))
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/g, "");
}
async function pkceChallengeFromVerifier(verifier) {
const data = new TextEncoder().encode(verifier);
const digest = await crypto.subtle.digest("SHA-256", data);
return base64url(new Uint8Array(digest));
}
// verifier는 RFC 7636에 맞게 43~128 chars 권장
const verifier = crypto.randomUUID() + crypto.randomUUID();
const challenge = await pkceChallengeFromVerifier(verifier);
console.log({ verifier, challenge, method: "S256" });
5) code_challenge_method가 서버 설정과 다르다 (S256/ plain)
요즘 IdP는 보안상 S256만 허용하는 경우가 많습니다. 그런데 클라이언트가 plain으로 보내거나, 반대로 서버가 plain만 허용하는 특이 설정이면 검증에 실패하고 invalid_grant로 떨어질 수 있습니다.
확인 방법
/authorize요청에code_challenge_method=S256가 실제로 붙는지 확인- IdP 앱 설정에서 PKCE 정책 확인(허용 메서드)
해결 방법
- 가능하면 항상
S256사용 - 라이브러리 기본값이
plain인지 확인 후 강제 설정
예시: /authorize 요청 예시
GET /authorize?
response_type=code&
client_id=...&
redirect_uri=https%3A%2F%2Fapp.example.com%2Foauth%2Fcallback&
scope=openid%20profile&
state=...&
code_challenge=...&
code_challenge_method=S256
6) client_id(또는 클라이언트 타입) 불일치
PKCE는 주로 public client(SPA/모바일)에서 쓰지만, 백엔드가 끼어드는 구조(BFF)에서는 설정이 꼬이기 쉽습니다.
흔한 케이스:
/authorize는 SPA의client_id로 받았는데/token은 서버 앱의client_id로 교환- IdP에서 앱을 confidential로 만들어 놓고
/token에서 client_secret을 요구하거나, 반대로 public인데 secret을 보내서 정책에 걸림 - 멀티테넌트에서 잘못된 issuer/realm로 토큰 교환
확인 방법
/authorize와/token의client_id가 동일한지 확인- IdP 콘솔에서 해당 client가 PKCE를 요구하는지/허용하는지 확인
- issuer(
/.well-known/openid-configuration)가 환경과 일치하는지 확인
해결 방법
- 한 플로우에서 동일 client로 끝까지 처리
- BFF 패턴이면 “서버가 code를 받는지, 브라우저가 code를 받는지”를 명확히 하고 구성 단순화
7) 세션/정책 변화로 code가 무효화됐다 (로그아웃, MFA, state 꼬임)
IdP 정책에 따라 다음 이벤트가 발생하면 발급된 code가 무효화될 수 있습니다.
- 사용자가 로그인 후 바로 로그아웃/세션 종료
- MFA 단계가 완료되지 않았는데 code 교환을 시도
- 특정 위험 정책(디바이스/위치)으로 트랜잭션이 취소
- state/nonce 검증 실패 후 앱이 잘못된 code를 교환(특히 여러 탭)
여기서도 결과는 invalid_grant로 뭉쳐 보일 수 있습니다.
확인 방법
- IdP 이벤트 로그(사용자 세션, MFA, 정책 거부 사유)
- 앱에서
state를 키로 verifier를 매핑했는지(다른 state의 verifier로 교환하면 실패)
해결 방법
state를 세션 키로 사용해 verifier/redirect_uri를 함께 저장- 콜백 처리 시
state불일치면 즉시 중단(토큰 교환 시도 금지)
예시: state 기반으로 verifier 저장(브라우저)
// authorize 직전
const state = crypto.randomUUID();
const verifier = crypto.randomUUID() + crypto.randomUUID();
sessionStorage.setItem(`pkce:${state}`, verifier);
// callback에서
const params = new URLSearchParams(location.search);
const cbState = params.get("state");
const code = params.get("code");
const storedVerifier = sessionStorage.getItem(`pkce:${cbState}`);
if (!storedVerifier) {
throw new Error("missing verifier for state (possible tab/session mixup)");
}
// storedVerifier로 /token 교환
실전 디버깅 팁: 요청/응답을 ‘비교 가능한 형태’로 남겨라
invalid_grant는 결국 “서버가 기대한 값과 실제 값이 다르다”로 귀결되는 경우가 많습니다. 그래서 디버깅의 핵심은 두 요청(/authorize, /token)의 상관관계(correlation) 를 남기는 것입니다.
state를 correlation id로 삼아 로그를 묶기/authorize시점에 생성한code_verifier의 해시(원문은 노출 금지)를 저장/token요청 바디에서redirect_uri,client_id,code(해시)를 저장
인증 문제는 네트워크/환경 변수도 영향을 줍니다. 특히 배포 환경에서만 재현된다면, 인프라 로그/권한 이슈처럼 “원인은 다른데 증상은 한 가지”로 나타날 수 있습니다. 그런 류의 트러블슈팅 접근은 이 글도 참고할 만합니다: systemd 서비스가 재부팅 후 안 뜰 때 12단계 점검
마무리: 가장 흔한 원인 Top 3
현장에서 체감상 빈도가 높은 순으로 꼽으면 보통 이렇습니다.
- redirect_uri 불일치(특히 슬래시/쿼리/포트)
- code_verifier 저장/복원 실패(탭/세션/인코딩)
- code 재사용(중복 콜백/재시도)
위 3개만 제대로 가드해도 invalid_grant의 70~80%는 사라집니다. 그래도 안 잡히면, IdP 이벤트 로그(정책/MFA/세션)와 Authorization Server의 PKCE 검증 로그를 반드시 확인하세요.