- Published on
OAuth 2.0 PKCE invalid_grant 5분 디버깅
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
OAuth 2.0 Authorization Code + PKCE를 붙여서 구현하면, 토큰 교환 단계에서 종종 invalid_grant를 만납니다. 문제는 이 에러가 너무 포괄적이라서, 실제 원인이 code_verifier 불일치인지, redirect_uri 미스매치인지, 코드 재사용인지, 시계 오차인지 한 번에 감이 안 온다는 점입니다.
이 글은 “5분 안에” 원인을 좁히는 데 목적이 있습니다. 즉, 완벽한 이론보다 재현 가능한 로그/체크 포인트와 즉시 확인 가능한 분기에 집중합니다.
관련해서 더 넓은 원인 목록이 필요하면 OAuth2 PKCE에서 invalid_grant 나는 7가지도 함께 참고하세요. 콜백 단계에서 redirect_uri_mismatch가 뜬다면 토큰 교환 이전 문제이므로 OAuth 콜백 400 redirect_uri_mismatch 즉시 해결가 더 빠릅니다.
0) 5분 디버깅 목표: 원인을 3분류로 나누기
invalid_grant는 대개 아래 3가지 축 중 하나로 귀결됩니다.
- Authorization Code 자체가 유효하지 않음: 만료, 재사용, 다른 클라이언트/리다이렉트로 발급됨
- PKCE 검증 실패:
code_verifier가 다르거나 변형됨,code_challenge_method불일치 - 요청 파라미터 미스매치:
redirect_uri가 토큰 요청에서 다름(또는 누락), 잘못된 grant_type 등
5분 디버깅은 “토큰 엔드포인트 요청 1건”을 기준으로, 위 분류를 빠르게 판정하는 과정입니다.
1) 1분: 토큰 요청을 한 줄로 고정(재현 가능한 형태)
먼저 실제 실패한 토큰 요청을 복제 가능한 curl로 고정하세요. 이 단계가 되면, 프론트/백엔드/모바일/프록시 등 복잡한 경로를 잠시 잊고 “토큰 엔드포인트 입력값”만 보게 됩니다.
아래는 일반적인 PKCE 토큰 교환 요청 예시입니다.
curl -sS -X POST "https://auth.example.com/oauth/token" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=authorization_code" \
-d "client_id=YOUR_CLIENT_ID" \
-d "code=AUTHORIZATION_CODE" \
-d "redirect_uri=https://app.example.com/callback" \
-d "code_verifier=YOUR_CODE_VERIFIER"
체크포인트
- 실패 케이스에서 반드시 아래 4가지를 로그로 남기세요(보안상 운영 로그는 마스킹 권장).
code(앞 6~10자만)redirect_uri(전체)code_verifier길이와 해시(원문 대신)- 요청 시각(서버 기준)
code_verifier는 민감정보에 가깝습니다. 운영에서는 원문 대신 아래처럼 길이 + SHA-256만 남겨도 디버깅이 됩니다.
import crypto from "crypto";
export function maskVerifier(verifier) {
const hash = crypto.createHash("sha256").update(verifier, "utf8").digest("hex");
return { len: verifier.length, sha256: hash };
}
2) 2분: PKCE 값이 “정말 같은 것”인지 독립적으로 검증
PKCE에서 가장 흔한 실수는 “내가 보낸 code_verifier가 맞다”는 믿음입니다. 앱/브라우저/서버/리다이렉트 경로를 거치면서 공백, URL 인코딩, 문자열 정규화, 저장소 손상이 섞여 달라지는 일이 실제로 많습니다.
PKCE 규칙 빠른 점검
code_verifier길이: 43~128- 허용 문자: unreserved 문자(대개
A-Z,a-z,0-9,-,.,_,~) S256인 경우code_challenge는:BASE64URL( SHA256( code_verifier ) )- Base64가 아니라 Base64URL (패딩
=제거,+는-,/는_)
로컬에서 code_challenge 재계산(독립 검증)
아래 Node.js 스니펫으로, 프론트에서 만들었다는 code_verifier로 code_challenge를 재계산해 로그에 찍힌 code_challenge와 동일한지 확인합니다.
import crypto from "crypto";
function base64url(buffer) {
return buffer
.toString("base64")
.replace(/=/g, "")
.replace(/\+/g, "-")
.replace(/\//g, "_");
}
export function pkceChallengeS256(verifier) {
const hash = crypto.createHash("sha256").update(verifier, "utf8").digest();
return base64url(hash);
}
// 사용 예
const verifier = process.env.CODE_VERIFIER;
console.log(pkceChallengeS256(verifier));
여기서 바로 갈리는 분기
- 재계산한
code_challenge가 “처음 authorize 요청에 넣었던code_challenge”와 다르다- 거의 확실히 verifier 저장/복원/인코딩 문제입니다.
- 재계산한 값이 일치한다
- PKCE 값 자체는 정상일 가능성이 높고, code/redirect_uri/재사용/만료 쪽으로 이동합니다.
3) 3분: redirect_uri 미스매치 여부를 “토큰 요청” 기준으로 확인
많은 개발자가 콜백이 정상으로 돌아오면 redirect_uri는 끝났다고 생각합니다. 하지만 일부 OAuth 서버는 토큰 교환 요청에서의 redirect_uri까지 authorization request와 “완전 일치”해야 합니다.
즉시 확인할 것
- 토큰 요청에
redirect_uri를 넣는 구현인가? (넣지 않는 경우도 있는데, 서버 정책에 따라 실패) - authorize 요청의
redirect_uri와 토큰 요청의redirect_uri가 문자열로 완전 동일한가?https://app.example.com/callbackvshttps://app.example.com/callback/- 쿼리 포함 여부:
callback?env=prod - 대소문자, 포트, 스킴(
httpvshttps)
서버/프록시 환경에서 자주 생기는 함정
- 프론트는
https로 인식하지만 백엔드가 내부에서http로 조립 - 리버스 프록시가
X-Forwarded-Proto를 전달하지 않아, 백엔드가redirect_uri를 잘못 생성
Node/Express에서 스킴 추론을 안전하게 하려면(프록시 뒤라면) 대개 아래가 필요합니다.
import express from "express";
const app = express();
app.set("trust proxy", true);
app.get("/oauth/start", (req, res) => {
const baseUrl = `${req.protocol}://${req.get("host")}`;
const redirectUri = `${baseUrl}/callback`;
// redirectUri를 authorize 요청에 사용
res.json({ redirectUri });
});
redirect_uri 문제는 콜백에서 redirect_uri_mismatch로 먼저 터지기도 하지만, 콜백은 통과했는데 토큰에서 invalid_grant로 뭉뚱그려지는 서버도 있습니다. 이 경우 위의 “문자열 완전 일치” 검사가 가장 빠릅니다.
4) 4분: Authorization Code 재사용/중복 교환 여부
Authorization Code는 1회성입니다. 같은 code로 토큰 교환을 두 번 시도하면 두 번째는 흔히 invalid_grant입니다.
재사용이 생기는 전형적인 패턴
- 프론트에서 토큰 교환 요청을 두 번 날림
- React Strict Mode 개발 환경에서
useEffect가 두 번 실행 - 버튼 더블 클릭/중복 submit
- React Strict Mode 개발 환경에서
- 백엔드에서 재시도 로직이 무심코 붙어 있음
- 네트워크 타임아웃을 “실패”로 간주하고 동일
code로 재전송
- 네트워크 타임아웃을 “실패”로 간주하고 동일
- 콜백 라우트가 새로고침되며 동일
code로 다시 교환
프론트에서 중복 교환 방지 예시
let exchanging = false;
export async function exchangeOnce(params: URLSearchParams) {
if (exchanging) return;
exchanging = true;
try {
const code = params.get("code");
if (!code) throw new Error("missing code");
const res = await fetch("/api/oauth/token", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ code }),
});
if (!res.ok) throw new Error(`token exchange failed: ${res.status}`);
return await res.json();
} finally {
exchanging = false;
}
}
백엔드에서 멱등성 키로 막기(권장)
code를 키로 해서 “이미 교환 중/교환 완료” 상태를 짧게 캐시해도 효과가 큽니다.
- Redis에
SETNX oauth:code:{codeHash} 1 EX 60 - 성공/실패에 따라 상태 기록
이렇게 하면 “재시도”가 필요할 때도 같은 code로 무한 시도하는 상황을 막고, 원인 로그가 깨끗해집니다.
5) 5분: 만료/시간 오차(Clock Skew)와 서버 정책 확인
Authorization Code는 보통 만료가 매우 짧습니다(수십 초~수분). 특히 모바일에서 앱 전환이 길어지거나, 사용자 SSO 화면에서 오래 머물면 코드가 만료될 수 있습니다.
빠른 진단법
- 콜백에서
code를 받은 시각과 토큰 교환 요청 시각의 차이를 로그로 남기기 - 차이가 30초~수분을 넘는다면, 만료 가능성이 큼
const t0 = Date.now(); // code 수신 시각 저장
// ...
const t1 = Date.now(); // 토큰 교환 시각
console.log({ codeAgeMs: t1 - t0 });
또 다른 함정은 서버/컨테이너의 시간이 어긋난 경우입니다. OAuth 서버가 발급/검증 시각을 엄격히 보면, 수십 초의 오차로도 실패할 수 있습니다.
- NTP 동기화 확인
- 컨테이너 노드 시간 확인
- 멀티 리전에서 시간/라우팅이 꼬이지 않는지 확인
실전 체크리스트(한 장 요약)
아래 순서대로 보면 “5분 안에” 대부분 좁혀집니다.
- 실패한 토큰 요청을
curl로 고정 (grant_type,client_id,code,redirect_uri,code_verifier) code_verifier길이/허용문자 확인, 원문 대신len과sha256로깅- 로컬에서
code_challenge재계산해 authorize 때 값과 비교 redirect_uri가 authorize와 토큰에서 완전 동일한지 비교(슬래시, 쿼리, 스킴)- 동일
code로 중복 교환이 일어나지 않는지(Strict Mode, 새로고침, 재시도) 추적 code수명(발급 후 경과 시간)과 서버 시간 동기화 확인
자주 쓰는 “원인별” 로그 포맷 제안
운영에서 재현이 어려울수록 로그가 전부입니다. 아래처럼 “한 줄 구조 로그”로 남기면, invalid_grant가 떠도 원인 분류가 빨라집니다.
{
"event": "oauth_token_exchange_failed",
"provider": "example",
"clientId": "...",
"redirectUri": "https://app.example.com/callback",
"codePrefix": "ab12cd",
"verifier": { "len": 64, "sha256": "..." },
"codeAgeMs": 18422,
"httpStatus": 400,
"error": "invalid_grant"
}
이 로그가 쌓이면 다음이 쉬워집니다.
- 특정 배포 이후
verifier.len이 갑자기 짧아짐(문자열 잘림) - 특정 도메인에서만
redirectUri가 달라짐(프록시 설정) codeAgeMs가 특정 디바이스에서 유독 큼(앱 전환 UX)
마무리: invalid_grant는 “입력값 불일치”로 생각하면 빨라진다
PKCE에서 invalid_grant는 대개 “서버가 기대한 것”과 “내가 보낸 것”의 불일치입니다. 따라서 토큰 요청을 고정하고, PKCE를 독립 재계산하고, redirect_uri 완전 일치와 code 1회성을 확인하면 디버깅 시간이 급격히 줄어듭니다.
더 많은 케이스(모바일 딥링크, 멀티 탭, 저장소 이슈 등)를 폭넓게 보려면 OAuth2 PKCE에서 invalid_grant 나는 7가지에서 원인별로 확장해서 점검해보세요.