- Published on
OAuth2 PKCE에서 invalid_grant 나는 7가지
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서드파티 로그인이나 사내 SSO를 붙이다 보면, PKCE까지 제대로 구현했는데도 토큰 교환 단계에서 invalid_grant가 튀어나오는 순간이 있습니다. 문제는 invalid_grant가 너무 포괄적인 에러라서, 실제 원인이 code_verifier 불일치인지, redirect_uri 미스매치인지, 코드 재사용인지 한 번에 감이 안 온다는 점입니다.
이 글은 OAuth2 Authorization Code + PKCE 흐름에서 invalid_grant가 발생하는 대표 원인 7가지를, “어디서 깨지는지” 기준으로 빠르게 좁혀갈 수 있게 정리한 체크리스트입니다.
관련해서 redirect_uri 불일치가 의심된다면 아래 글도 같이 보면 디버깅 시간이 크게 줄어듭니다.
PKCE에서 invalid_grant가 주로 터지는 지점
대부분 다음 요청에서 발생합니다.
POSTtoken endpoint (/oauth/token또는/token)grant_type=authorization_codecode와code_verifier를 함께 제출
서버는 대개 아래 중 하나가 실패하면 invalid_grant로 뭉뚱그려 반환합니다.
- authorization code 검증 실패(만료, 재사용, 클라이언트 불일치)
redirect_uri검증 실패- PKCE 검증 실패(
code_verifier로 계산한 challenge가 저장된 값과 다름)
1) code_verifier 길이/문자셋 규격 위반
증상
- 어떤 IdP는 아예
invalid_grant로만 떨어짐 - 어떤 IdP는
invalid_request로 떨어지기도 함
원인
PKCE code_verifier는 RFC 7636 규격상 길이와 문자셋 제한이 있습니다.
- 길이: 43~128
- 문자: unreserved 문자 집합(대체로 URL safe)
실전에서 흔한 실수는 다음입니다.
- 너무 짧거나(예: 32바이트 base64를 잘라 40자 미만)
+,/,=같은 표준 base64 문자가 섞이거나- URL 인코딩/디코딩 과정에서 값이 변형
해결
- base64url 인코딩을 사용하고 패딩
=를 제거 - verifier를 생성할 때 길이가 43 이상인지 강제
예시: Node.js에서 안전한 code_verifier 생성
import crypto from 'crypto';
function base64url(buf) {
return buf.toString('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/g, '');
}
export function createCodeVerifier() {
// 32바이트면 base64url로 대략 43자 이상이 됨
return base64url(crypto.randomBytes(32));
}
2) code_challenge_method 불일치 또는 plain 처리 실수
증상
- authorization request는 성공
- token request에서
invalid_grant
원인
클라이언트가 S256로 보냈다고 생각했는데 실제로는 다음 중 하나가 됨.
- 서버는
S256만 허용하는데 클라이언트가plain로 보냄 code_challenge_method=S256는 보냈지만code_challenge계산이 표준과 다름
특히 모바일/프론트에서 해시 계산 후 base64url 변환을 잘못하면 100% invalid_grant로 이어집니다.
해결
S256을 사용하고, 해시 결과를 base64url로 인코딩- challenge 계산 로직을 테스트 코드로 고정
예시: S256 challenge 계산 (Node.js)
import crypto from 'crypto';
function base64url(buf) {
return buf.toString('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/g, '');
}
export function createCodeChallengeS256(verifier) {
const hash = crypto.createHash('sha256').update(verifier).digest();
return base64url(hash);
}
검증 팁: 서버에 저장된 code_challenge와, 클라이언트가 code_verifier로부터 다시 계산한 값이 동일해야 합니다.
3) redirect_uri가 authorization 단계와 token 단계에서 1바이트라도 다름
증상
- 로그인/동의 화면까지는 정상
- token 교환에서
invalid_grant
원인
OAuth2 스펙과 다수 IdP 구현은 “authorization request에서 사용한 redirect_uri”와 “token request에서 제출한 redirect_uri”가 정확히 일치해야 한다는 규칙을 강하게 적용합니다.
자주 발생하는 차이:
https와http혼용(프록시/로드밸런서 뒤)- trailing slash 차이:
.../callbackvs.../callback/ - 쿼리스트링 포함 여부
- URL 인코딩 차이
- 포트 유무:
:443포함/미포함
해결
- authorization 요청에 보낸
redirect_uri값을 그대로 저장해두고, token 요청에 동일 문자열을 재사용 - 서버 프록시 환경이면
X-Forwarded-Proto등을 반영해 redirect URL 생성 로직을 고정
redirect_uri 디버깅은 케이스가 많아서 아래 글을 같이 참고하는 것을 권장합니다.
4) authorization code 재사용(중복 token 교환)
증상
- 어떤 요청은 성공, 어떤 요청은
invalid_grant - 특히 SPA에서 더 자주 발생
원인
authorization code는 1회성입니다. 다음 상황에서 “의도치 않은 2회 교환”이 자주 일어납니다.
- 프론트에서 callback 페이지가 두 번 마운트됨(React Strict Mode 개발환경 등)
- 네트워크 재시도 로직이 token 요청을 자동 재전송
- 백엔드와 프론트가 동시에 code를 교환하려고 함
해결
- code 교환은 단 하나의 컴포넌트/서버 경로에서만 수행
- 멱등성 키를 두거나, code를 교환한 즉시 “처리 완료” 상태로 마킹
- 프론트라면 callback 처리 시 1회 실행 가드 추가
예시: 브라우저에서 중복 실행 방지(간단 가드)
const key = 'oauth_code_exchange_done';
if (sessionStorage.getItem(key) === '1') {
// 이미 처리했으면 더 이상 교환하지 않음
} else {
sessionStorage.setItem(key, '1');
// token exchange 수행
}
5) code 만료 또는 서버-클라이언트 시간 불일치
증상
- 사용자가 로그인 후 잠깐 다른 앱을 보다가 돌아오면
invalid_grant - 특정 환경에서만 간헐적으로 발생
원인
authorization code는 보통 유효 시간이 매우 짧습니다(수십 초~수분). 다음이 겹치면 만료로 처리됩니다.
- 사용자 지연(동의 화면에서 오래 머뭄)
- 네트워크 지연
- 서버 시간이 크게 틀어짐(NTP 미설정)
해결
- code 발급부터 교환까지의 시간을 최대한 단축
- 서버 NTP 동기화 확인
- 모바일 딥링크/앱 전환 시 callback이 늦어지는 플로우를 점검
서버 시간 문제는 인증뿐 아니라 TLS에서도 이상 증상을 만들 수 있어, 네트워크 관점 점검이 필요할 때는 아래 글의 접근 방식도 도움이 됩니다.
6) 다른 클라이언트로 발급된 code를 교환(클라이언트 ID 혼선)
증상
- 로컬에서는 되는데 스테이징/프로덕션에서만
invalid_grant - 멀티 테넌트/멀티 앱에서 특히 자주 발생
원인
authorization code는 “발급된 client”에 귀속됩니다.
- A 앱의
client_id로 authorization을 시작했는데 - token 교환은 B 앱의
client_id(또는 다른 환경의 client)로 요청
이때 서버는 보통 invalid_grant로 처리합니다.
해결
- 환경별
client_id/issuer/authorization endpoint/token endpoint 조합을 고정 - callback 처리 서버에서 “어떤 client로 시작했는지” 컨텍스트를 유지
예시: token 요청에 들어가는 파라미터 점검(cURL)
curl -sS -X POST 'https://issuer.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/callback' \
--data-urlencode 'code_verifier=YOUR_CODE_VERIFIER'
점검 포인트:
client_id가 authorization 요청과 동일한지- issuer 도메인이 환경에 맞는지
7) code_verifier 저장/전달 과정에서 값이 바뀜(세션, 쿠키, 인코딩)
증상
- 특정 브라우저(특히 Safari)나 특정 네트워크에서만 실패
- 동일 사용자도 성공/실패가 섞임
원인
PKCE는 “authorization 요청 때 만든 code_verifier를 token 요청 때 그대로 제출”해야 합니다. 그런데 실전에서는 verifier를 다음 매체에 저장했다가 꺼내는 과정에서 깨지는 경우가 많습니다.
- 쿠키에 저장했는데 특수문자 인코딩이 달라짐
- 서버 세션이 유실됨(로드밸런서 뒤 sticky session 미설정)
- SPA에서 새로고침으로 메모리 상태가 날아감
- URL fragment나 query에 실어 나르다 잘못 인코딩
해결
- verifier는 가능하면 서버 사이드 세션(또는 안전한 스토리지)에 저장
- 로드밸런서 환경이면 세션 고정 또는 공유 세션 스토어 사용
- 쿠키/로컬스토리지를 쓴다면 base64url 문자열만 저장하고, 인코딩 변환을 최소화
예시: Express에서 세션에 verifier 저장
import express from 'express';
import session from 'express-session';
const app = express();
app.use(session({
secret: 'replace-me',
resave: false,
saveUninitialized: false,
cookie: { httpOnly: true, sameSite: 'lax', secure: true }
}));
app.get('/login', (req, res) => {
const verifier = createCodeVerifier();
const challenge = createCodeChallengeS256(verifier);
req.session.pkce = { verifier };
const authorizeUrl = new URL('https://issuer.example.com/oauth/authorize');
authorizeUrl.searchParams.set('response_type', 'code');
authorizeUrl.searchParams.set('client_id', 'YOUR_CLIENT_ID');
authorizeUrl.searchParams.set('redirect_uri', 'https://app.example.com/callback');
authorizeUrl.searchParams.set('code_challenge_method', 'S256');
authorizeUrl.searchParams.set('code_challenge', challenge);
res.redirect(authorizeUrl.toString());
});
app.get('/callback', async (req, res) => {
const code = req.query.code;
const verifier = req.session.pkce?.verifier;
if (!verifier) return res.status(400).send('missing pkce verifier');
// 여기서 token endpoint로 code + verifier 교환
res.send('ok');
});
빠른 트러블슈팅 체크리스트(로그로 좁히기)
아래 5가지를 한 번에 수집하면 원인 7개 중 대부분이 바로 좁혀집니다.
- authorization 요청의
code_challenge,code_challenge_method,redirect_uri,client_id - token 요청의
code,code_verifier,redirect_uri,client_id - code 교환이 중복으로 발생했는지(요청 횟수, 재시도 여부)
- code 발급 시각과 교환 시각(만료 여부)
- verifier를 저장한 위치(세션/쿠키/스토리지)와 유실 가능성
서버가 에러 상세를 숨기는 경우가 많으니, 가능하면 IdP 쪽 감사 로그(audit log)나 이벤트 로그에서 “왜 invalid_grant로 판정했는지”를 확인하는 것이 가장 빠릅니다.
마무리
PKCE에서 invalid_grant는 “PKCE가 틀렸다”라기보다, 대개는 redirect_uri 문자열 불일치, code 재사용, verifier 변형처럼 구현 주변부에서 발생합니다. 위 7가지를 순서대로 제거해 나가면, 대부분 30분 안에 재현과 수정이 가능합니다.
특히 redirect_uri는 PKCE 여부와 관계없이 가장 빈도가 높은 지뢰밭이므로, 의심되면 아래 글의 체크리스트를 먼저 적용해보세요.