- Published on
OAuth PKCE 실패 401 invalid_grant 실전 진단
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서드파티 로그인이나 사내 SSO를 붙이다 보면, OAuth 2.0 Authorization Code + PKCE에서 토큰 교환 단계가 401 또는 invalid_grant로 떨어지는 순간이 꼭 옵니다. 문제는 이 에러가 “인증 서버가 코드 교환을 거부했다”는 결과만 말해줄 뿐, 원인이 코드 만료인지, 리다이렉트 URI 불일치인지, code_verifier 불일치인지, 클라이언트 인증 방식 문제인지를 직접 추적해야 한다는 점입니다.
이 글은 PKCE 실패를 재현 가능한 형태로 관찰하고, 가장 흔한 원인을 우선순위대로 제거하는 실전 진단 흐름을 제공합니다.
참고: 네트워크/프록시 계층에서 요청이 변형되는 경우도 많습니다. 인프라 단에서의 리셋/타임아웃류 문제를 함께 다루는 글로는 EKS ALB Ingress 500 Target reset 원인·해결도 같이 보면 도움이 됩니다.
PKCE에서 invalid_grant가 의미하는 것
OAuth 서버 구현체마다 메시지는 조금씩 다르지만, 토큰 엔드포인트에서 invalid_grant는 대개 아래 범주 중 하나입니다.
- Authorization Code가 유효하지 않음
- 이미 사용됨(재사용)
- 만료됨
- 잘못된 클라이언트/리다이렉트 URI에 묶인 코드
- PKCE 검증 실패
code_verifier가 다름code_challenge_method불일치- 인코딩/정규화 문제
- 요청 파라미터 불일치
redirect_uri가 인가 요청과 토큰 요청에서 불일치client_id불일치
- 클라이언트 인증 문제
- Public client인데
client_secret을 보내거나, 반대로 Confidential client인데 인증이 누락됨
- Public client인데
핵심은 “토큰 교환 요청 한 번”이 아니라, 인가 요청부터 토큰 요청까지의 연속된 상관관계를 확인해야 한다는 점입니다.
진단 0단계: 실패를 관측 가능하게 만들기
먼저 아래 3가지를 확보하면 진단 속도가 급격히 빨라집니다.
- 브라우저/앱에서 실제로 나간 인가 요청 URL
- 토큰 엔드포인트로 나간 HTTP 요청 전문(헤더/바디)
- OAuth 서버 로그에서 해당 요청의 트레이스(가능하면
request_id)
프론트/백/게이트웨이/인증 서버 사이를 오가며 디버깅해야 하므로, 요청에 상관 ID를 붙여두는 것이 좋습니다.
토큰 요청을 cURL로 고정해 재현하기
실패 케이스를 가장 빨리 고정하는 방법은 “앱이 만든 요청을 그대로” cURL로 재현하는 것입니다.
curl -i -X POST "https://auth.example.com/oauth/token" \
-H "Content-Type: application/x-www-form-urlencoded" \
--data-urlencode "grant_type=authorization_code" \
--data-urlencode "client_id=my-client" \
--data-urlencode "code=AUTH_CODE_FROM_CALLBACK" \
--data-urlencode "redirect_uri=https://app.example.com/callback" \
--data-urlencode "code_verifier=VERIFIER_FROM_STORAGE"
여기서 redirect_uri와 code_verifier는 “추정”이 아니라, 실제 런타임에서 사용된 값을 그대로 가져와야 합니다.
1단계: redirect_uri 불일치부터 의심하기
PKCE 실패로 보이지만, 실제로는 redirect_uri 불일치가 가장 흔합니다. 특히 아래 상황에서 자주 발생합니다.
- 인가 요청에서는
https://app.example.com/callback인데, 토큰 요청에서https://app.example.com/callback/처럼 슬래시가 붙음 - 로컬 개발에서 인가 요청은
http://localhost:3000/callback인데 토큰 요청은http://127.0.0.1:3000/callback - 프록시/Ingress 뒤에서 외부는 HTTPS인데 내부 앱이 HTTP로 인식해
redirect_uri를 다르게 생성
체크 포인트
- 인가 요청의
redirect_uri와 토큰 요청의redirect_uri는 문자열 완전 일치여야 합니다. - 서버 설정에 등록된 리다이렉트 URI도 동일하게 일치해야 합니다.
프록시 뒤에서 스킴이 바뀌는 문제
Ingress나 리버스 프록시 뒤에서 X-Forwarded-Proto 처리가 잘못되면, 앱이 http로 인식해 콜백 URL을 잘못 만들 수 있습니다.
Node/Express 예시:
import express from "express";
const app = express();
app.set("trust proxy", true); // 프록시 뒤에서 원래 스킴/호스트 인식
app.get("/login", (req, res) => {
const redirectUri = `${req.protocol}://${req.get("host")}/callback`;
res.send(redirectUri);
});
이 설정이 없으면 req.protocol이 내부 통신 기준으로 잡혀 http가 되어, 결과적으로 redirect_uri 불일치로 invalid_grant가 날 수 있습니다.
2단계: Authorization Code “재사용/중복 교환” 확인
인가 코드(authorization code)는 보통 1회성입니다. 아래 같은 버그가 있으면 토큰 교환이 2번 발생해 두 번째 요청이 invalid_grant로 실패합니다.
- SPA에서 콜백 페이지가 리로드되며 토큰 교환이 다시 실행됨
- 모바일에서 딥링크 처리 중 콜백 핸들러가 중복 호출됨
- 백엔드와 프론트가 각각 토큰 교환을 시도함
빠른 확인법
- 토큰 엔드포인트 요청 로그가 동일한
code로 2번 이상 발생하는지 확인 - 프론트에서 콜백 처리 후 URL에서
code파라미터를 제거했는지 확인
SPA에서 콜백 처리 후 주소 정리 예시:
const url = new URL(window.location.href);
if (url.searchParams.get("code")) {
// 토큰 교환 로직 수행
// ...
// 교환 후 code 제거
url.searchParams.delete("code");
url.searchParams.delete("state");
window.history.replaceState({}, document.title, url.toString());
}
3단계: PKCE 핵심인 code_verifier 불일치 점검
PKCE에서 가장 본질적인 실패 원인은 code_verifier가 토큰 요청에서 달라지는 것입니다.
흔한 원인
- 인가 요청 시 생성한
code_verifier를 세션/스토리지에 저장하지 못함- Safari ITP, 서드파티 쿠키 차단, 인앱 브라우저의 스토리지 정책
- 콜백 도메인이 달라져 저장소가 분리됨
app.example.com에서 시작했는데 콜백이www.example.com으로 옴
- 서버 사이드에서 verifier를 저장했는데 로드 밸런싱으로 다른 인스턴스에 붙음
- sticky session 미설정
- 세션 저장소가 인메모리
verifier 저장 전략
- SPA:
sessionStorage에 저장(탭 단위) + 콜백 처리 즉시 삭제 - BFF/서버: Redis 같은 외부 세션 저장소에
state키로 매핑
Redis에 state로 매핑하는 예시(개념 코드):
// 인가 요청 시작
const state = crypto.randomUUID();
const codeVerifier = generateVerifier();
await redis.setex(`pkce:${state}`, 300, codeVerifier);
// 콜백에서 토큰 교환
const savedVerifier = await redis.get(`pkce:${state}`);
if (!savedVerifier) throw new Error("PKCE verifier missing");
여기서 TTL은 인가 코드 만료 시간보다 조금 짧거나 비슷하게 잡는 편이 운영상 안전합니다.
4단계: code_challenge_method와 해시/인코딩 실수
대부분은 S256을 씁니다. 이때 code_challenge는 다음 규칙을 만족해야 합니다.
code_challenge = BASE64URL( SHA256( code_verifier ) )- Base64URL은
+를-로,/를_로 바꾸고,=패딩을 제거
여기서 흔한 실수는 아래와 같습니다.
- Base64를 그대로 보내서
+,/,=가 남아있음 - URL 인코딩을 잘못해서 값이 변형됨
code_verifier를 생성할 때 허용 문자 규칙을 위반
Node.js에서 올바른 S256 생성
import crypto from "crypto";
function base64url(buffer) {
return buffer
.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" };
}
서버 로그에 PKCE verification failed류가 찍히면, 우선 위 로직으로 생성한 값과 현재 구현을 비교해보는 것이 빠릅니다.
5단계: 클라이언트 유형(Public/Confidential)과 인증 방식 충돌
PKCE는 원래 Public client(네이티브/SPA)에서 client_secret 없이도 안전하게 코드를 교환하기 위한 장치입니다. 그런데 설정이 꼬이면 아래 문제가 발생합니다.
- 서버는 Confidential client로 등록되어
client_secret또는client_assertion을 요구 - 클라이언트는 Public로 구현되어 secret을 보낼 수 없음
- 반대로 Public로 등록했는데 서버/라이브러리가 Basic Auth 헤더를 붙여버림
체크 포인트
- OAuth 서버의 클라이언트 설정에서 “PKCE required”, “client authentication method”를 확인
- 토큰 요청에
Authorization: Basic ...가 붙는지 확인
cURL에서 Basic Auth를 쓰는 형태(Confidential client일 때):
curl -i -X POST "https://auth.example.com/oauth/token" \
-H "Content-Type: application/x-www-form-urlencoded" \
-u "my-client:MY_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"
Public client라면 보통 -u를 쓰지 않고 client_id만 보냅니다(서버 정책에 따름).
6단계: 시간/만료/클럭 스큐와 재시도 정책
인가 코드는 만료가 짧습니다(수십 초~수 분). 다음 상황이면 만료로 invalid_grant가 납니다.
- 사용자가 로그인 화면에서 오래 머뭄
- 모바일 네트워크에서 콜백 후 토큰 요청이 지연
- 백엔드가 토큰 요청을 큐잉/재시도하며 늦게 보냄
실전 팁
- 토큰 교환은 “재시도”를 신중히 해야 합니다. 네트워크 오류는 재시도하되, 동일
code로 재시도하면 이미 사용 처리될 수 있습니다. - 서버와 클라이언트의 시간 동기화도 확인(NTP).
7단계: state 검증 실패를 invalid_grant로 오해하지 않기
엄밀히 말하면 state는 토큰 요청의 파라미터가 아니라 인가 응답에서 검증하는 값이지만, 실제 구현에서는 state가 틀려 콜백 처리가 중단되고, 그 결과 토큰 교환에 잘못된 값이 들어가 invalid_grant로 이어지는 경우가 있습니다.
state를 세션에 저장했는데 세션이 유실- 멀티 탭 로그인으로 state가 덮어쓰기
해결은 state를 탭 단위 저장소에 두거나, 로그인 시도별로 별도 키로 관리하는 것입니다.
8단계: 프록시/WAF가 파라미터를 변형하는지 확인
아주 드물지만 운영 환경에서만 재현되는 케이스로, WAF/프록시가 폼 바디를 검사/정규화하며 특정 문자를 변형하는 일이 있습니다.
application/x-www-form-urlencoded바디의+를 공백으로 처리- URL 인코딩을 중간에서 재인코딩
이 문제는 특히 code_verifier나 code에 +가 포함될 때 치명적입니다. 그래서 verifier는 아예 Base64URL처럼 안전한 문자 집합으로 만들고, 전송 시에는 --data-urlencode처럼 확실한 인코딩 방식을 사용하세요.
로그로 원인을 좁히는 체크리스트
운영에서 빠르게 원인 분류를 하려면 아래 순서가 효율적입니다.
- 동일
code로 토큰 요청이 2번 이상인가 - 인가 요청의
redirect_uri와 토큰 요청의redirect_uri가 완전 일치인가 code_verifier를 “인가 요청 생성 시점”과 “토큰 요청 시점”에서 동일하게 유지했나S256생성 로직이 Base64URL 규칙을 지키나- 클라이언트 인증 방식이 서버 설정과 맞나
- 만료/지연/재시도 정책으로 코드가 늦게 교환되지는 않나
이런 식의 “에러 코드 1개를 여러 가능성으로 분해”하는 접근은 다른 API 오류 디버깅에도 그대로 통합니다. 예를 들어 LLM API에서 400 invalid_request를 원인별로 쪼개는 방식은 LangChain Tool Calling 400 invalid_request 오류 9가지와도 결이 같습니다.
실전 구성 예시: BFF에서 PKCE를 안정적으로 처리하기
SPA 단독 구현은 브라우저 정책 영향(스토리지, 쿠키, ITP)을 크게 받습니다. 운영 안정성이 중요하면 BFF(Backend For Frontend)에서 PKCE를 처리하는 구성이 실전에서 많이 쓰입니다.
- 프론트는
/auth/start로 이동 - BFF가
state와code_verifier를 생성하고 Redis에 저장 - BFF가 OAuth 서버로 리다이렉트
- 콜백은 BFF가 받고, Redis에서 verifier를 꺼내 토큰 교환
- 프론트에는 세션 쿠키만 내려줌
이렇게 하면 code_verifier 유실, 멀티 탭, 도메인 분리 문제를 상당 부분 제거할 수 있습니다.
간단한 Express 라우팅 스케치:
app.get("/auth/start", async (req, res) => {
const { verifier, challenge, method } = createPkcePair();
const state = crypto.randomUUID();
await redis.setex(`pkce:${state}`, 300, verifier);
const authorizeUrl = new URL("https://auth.example.com/oauth/authorize");
authorizeUrl.searchParams.set("response_type", "code");
authorizeUrl.searchParams.set("client_id", "my-client");
authorizeUrl.searchParams.set("redirect_uri", "https://app.example.com/auth/callback");
authorizeUrl.searchParams.set("state", state);
authorizeUrl.searchParams.set("code_challenge", challenge);
authorizeUrl.searchParams.set("code_challenge_method", method);
res.redirect(authorizeUrl.toString());
});
app.get("/auth/callback", async (req, res) => {
const code = req.query.code;
const state = req.query.state;
const verifier = await redis.get(`pkce:${state}`);
if (!verifier) return res.status(400).send("state/verifier missing");
// 여기서 토큰 요청 수행 (code + verifier)
res.send("ok");
});
마무리: invalid_grant는 “결과”이고, 원인은 연결고리에 있다
PKCE에서 401/invalid_grant는 대부분 “토큰 교환 요청이 인가 요청과 같은 흐름이 아니다”라는 신호입니다. 즉, 아래 연결고리 중 하나가 끊어진 것입니다.
- 인가 요청과 토큰 요청의
redirect_uri일치 - 1회성 코드 재사용 방지
code_verifier의 안정적 저장/전달- 올바른
S256인코딩 - 클라이언트 인증 방식 일치
위 체크리스트대로 한 단계씩 제거하면, 막연한 PKCE 디버깅이 아니라 재현 가능한 형태로 원인을 확정할 수 있습니다.
추가로, 이런 인증/토큰 교환 문제는 배포/캐시/프록시 변화에 의해 갑자기 발생하기도 합니다. 빌드/배포 파이프라인에서 재현성을 높이는 방법은 GitHub Actions 캐시가 안 먹을 때 속도 3배 올린 실전처럼 “관측과 고정”을 먼저 하는 접근이 도움이 됩니다.