- Published on
Node.js OAuth PKCE invalid_grant 원인과 해결법
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서드파티 OAuth 2.0 로그인에서 PKCE(Proof Key for Code Exchange)를 붙이면 보안은 좋아지지만, 구현이 조금만 어긋나도 토큰 교환 단계에서 invalid_grant가 터집니다. 문제는 이 에러가 너무 뭉뚱그려져 있어서, 실제 원인이 redirect_uri 불일치인지, code_verifier 저장/전달 문제인지, 코드 재사용인지 한 번에 알기 어렵다는 점입니다.
이 글은 Node.js(Express 기준)에서 Authorization Code + PKCE 플로우를 구현할 때 invalid_grant를 가장 빠르게 좁혀가는 방법을 “증상별 체크리스트 + 실전 코드”로 정리합니다.
참고로 state/세션 불일치 이슈는 프레임워크가 달라도 본질이 같습니다. 비슷한 맥락의 디버깅 포인트는 Spring Security OAuth2 로그인 401·state 불일치 해결도 같이 보면 도움이 됩니다.
invalid_grant가 발생하는 위치부터 확정하기
PKCE에서 invalid_grant는 보통 토큰 엔드포인트 호출에서 발생합니다.
/authorize단계: 브라우저 리다이렉트,code발급/callback단계:code수신/token단계:code+code_verifier로 토큰 교환 실패 시invalid_grant
즉 “토큰 교환 요청에 포함된 값들”이 서버가 기대하는 값과 다르거나, 이미 만료/사용된 코드일 가능성이 큽니다.
가장 흔한 원인 TOP 7 체크리스트
1) Authorization Code 재사용 (가장 흔함)
Authorization Code는 1회용입니다.
- 브라우저 뒤로 가기 후 콜백 URL 재호출
- 콜백 핸들러에서 예외가 나서 재시도 로직이 코드 교환을 두 번 수행
- 프론트와 백이 모두 토큰 교환을 시도(이중 교환)
해결:
- 콜백 처리에서
code를 “1회만” 사용하도록 idempotency 설계 - 서버에서
code를 사용 처리(예: Redis에used:code:{code}저장) 후 중복 차단
2) redirect_uri가 1바이트라도 다름
OAuth 서버는 redirect_uri를 매우 엄격하게 비교합니다. 다음 차이도 불일치로 봅니다.
httpvshttps- 호스트
localhostvs127.0.0.1 - 포트 유무
- 경로 끝 슬래시 유무
- 쿼리스트링 포함 여부
특히 주의할 점:
/authorize요청에 보낸redirect_uri와/token교환 요청에 보낸redirect_uri가 “완전히 동일”해야 하는 공급자가 많습니다.
해결:
redirect_uri를 코드 상수로 고정하고 두 단계에서 동일 값을 재사용- 배포 환경에서는 프록시(예: Nginx, Cloud Run) 뒤에서
X-Forwarded-Proto처리 누락으로http로 인식되는 문제를 점검
3) code_verifier 저장/전달 실패 (PKCE 핵심)
invalid_grant의 PKCE 버전은 사실상 code_verifier가 틀렸다는 뜻인 경우가 많습니다.
실수 패턴:
code_verifier를 세션에 저장했는데 콜백 시 세션이 새로 발급됨- 서버가 여러 대인데 세션 스토어가 메모리라서 다른 인스턴스로 라우팅됨
code_verifier를 URL 쿼리에 넣어 노출되거나, 인코딩이 깨짐
해결:
code_verifier는 서버 세션 또는 Redis 같은 중앙 스토어에 저장- 세션 쿠키
SameSite,Secure설정을 환경에 맞게 정확히 - 멀티 인스턴스면 sticky session 또는 중앙 세션 스토어 필수
4) code_challenge_method 불일치 또는 구현 오류
대부분 S256을 요구합니다.
plain으로 보냈는데 서버가S256만 허용S256계산 시 base64url 인코딩을 base64로 해버림(패딩=포함)
해결:
- base64url 변환을 정확히 구현
- 패딩 제거,
+를-,/를_로 치환
5) 시간 문제(서버 시간 오차) 또는 코드 만료
Authorization Code는 보통 수십 초~수분 내 만료됩니다.
- 로그인 후 콜백을 오래 방치
- 서버 시간이 NTP로 동기화되지 않아 만료 판정이 어긋남
해결:
- 서버 시간 동기화(NTP)
- 토큰 교환을 콜백 즉시 수행, 불필요한 I/O 최소화
6) 클라이언트 타입 혼동(Confidential vs Public)
Node.js 백엔드에서 토큰 교환을 한다면 보통 confidential client로 취급됩니다.
- 공급자가 요구하는
client_secret을 누락 - 반대로 PKCE public client인데 secret을 넣으면 정책상 거부하는 곳도 있음
해결:
- 공급자 콘솔에서 앱 타입과 허용 플로우 확인
- 문서에 나온 토큰 요청 파라미터를 그대로 맞추기
7) 콜백 처리 중 state/nonce 불일치로 인해 재시도 유발
직접 원인이 invalid_grant가 아니라도, state 검증 실패로 사용자가 재로그인을 반복하면서 “이미 사용된 code를 다시 교환”하는 상황이 생깁니다.
- state를 쿠키에 저장했는데
SameSite때문에 콜백에서 쿠키가 안 옴 - 프록시 환경에서 도메인/서브도메인이 달라 쿠키가 분리됨
이 포인트는 Spring Security OAuth2 로그인 401·state 불일치 해결의 원인 분류가 그대로 적용됩니다.
Node.js(Express) PKCE 구현 예제: 안전한 기본 골격
아래 예제는 핵심 실수 지점을 피하는 형태로 구성했습니다.
code_verifier는 세션에 저장redirect_uri는 단일 상수로 고정- PKCE
S256을 base64url로 정확히 계산
import express from 'express';
import session from 'express-session';
import crypto from 'crypto';
const app = express();
app.use(session({
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
cookie: {
httpOnly: true,
sameSite: 'lax',
secure: process.env.NODE_ENV === 'production',
},
}));
const OAUTH_AUTHORIZE_URL = process.env.OAUTH_AUTHORIZE_URL;
const OAUTH_TOKEN_URL = process.env.OAUTH_TOKEN_URL;
const CLIENT_ID = process.env.OAUTH_CLIENT_ID;
const CLIENT_SECRET = process.env.OAUTH_CLIENT_SECRET; // 필요 없는 공급자도 있음
// 두 단계에서 반드시 동일해야 함
const REDIRECT_URI = process.env.OAUTH_REDIRECT_URI;
function base64url(buf) {
return buf
.toString('base64')
.replace(/=/g, '')
.replace(/\+/g, '-')
.replace(/\//g, '_');
}
function createVerifier() {
return base64url(crypto.randomBytes(32));
}
function createChallengeS256(verifier) {
const hash = crypto.createHash('sha256').update(verifier).digest();
return base64url(hash);
}
app.get('/login', (req, res) => {
const state = base64url(crypto.randomBytes(16));
const codeVerifier = createVerifier();
const codeChallenge = createChallengeS256(codeVerifier);
req.session.oauthState = state;
req.session.codeVerifier = codeVerifier;
const url = new URL(OAUTH_AUTHORIZE_URL);
url.searchParams.set('response_type', 'code');
url.searchParams.set('client_id', CLIENT_ID);
url.searchParams.set('redirect_uri', REDIRECT_URI);
url.searchParams.set('scope', 'openid profile email');
url.searchParams.set('state', state);
url.searchParams.set('code_challenge', codeChallenge);
url.searchParams.set('code_challenge_method', 'S256');
res.redirect(url.toString());
});
app.get('/callback', async (req, res) => {
const { code, state } = req.query;
if (!code || !state) {
return res.status(400).send('missing code/state');
}
if (state !== req.session.oauthState) {
return res.status(400).send('state mismatch');
}
const codeVerifier = req.session.codeVerifier;
if (!codeVerifier) {
return res.status(400).send('missing code_verifier in session');
}
// 재사용 방지: 세션에서 즉시 제거
delete req.session.codeVerifier;
delete req.session.oauthState;
const body = new URLSearchParams();
body.set('grant_type', 'authorization_code');
body.set('client_id', CLIENT_ID);
body.set('redirect_uri', REDIRECT_URI);
body.set('code', String(code));
body.set('code_verifier', codeVerifier);
// 공급자에 따라 Basic Auth 또는 body에 client_secret 필요
// body.set('client_secret', CLIENT_SECRET);
const tokenRes = await fetch(OAUTH_TOKEN_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
// 예: Basic 인증을 요구하는 경우
// 'Authorization': 'Basic ' + Buffer.from(CLIENT_ID + ':' + CLIENT_SECRET).toString('base64'),
},
body,
});
const tokenJson = await tokenRes.json().catch(() => ({}));
if (!tokenRes.ok) {
// 여기서 invalid_grant가 주로 확인됨
return res.status(500).json({
status: tokenRes.status,
tokenError: tokenJson,
});
}
res.json(tokenJson);
});
app.listen(3000);
invalid_grant를 빠르게 잡는 디버깅 로그 설계
운영에서 invalid_grant를 재현하기 어려운 경우가 많아서, “민감정보를 제외한” 진단 로그가 중요합니다.
권장 로깅 항목:
redirect_uri문자열(그대로)code_challenge_methodcode_verifier는 원문 대신 해시(예: sha256)만- 토큰 요청 시각과 콜백 수신 시각(지연 시간)
- 동일
code로 토큰 교환이 두 번 호출됐는지 여부
예시:
function sha256hex(s) {
return crypto.createHash('sha256').update(s).digest('hex');
}
console.log('[oauth]', {
redirectUri: REDIRECT_URI,
codeVerifierHash: sha256hex(codeVerifier),
receivedAt: Date.now(),
});
이렇게 해두면,
redirect_uri가 환경별로 달라지는지- 세션이 바뀌어
code_verifier가 바뀌는지 - 토큰 교환이 중복 호출되는지 를 로그만으로도 상당히 좁힐 수 있습니다.
프록시/배포 환경에서 자주 터지는 함정
HTTPS 종단(termination) 뒤에서 redirect_uri가 바뀌는 문제
Cloud Run, ALB, Nginx 같은 프록시 뒤에서는 앱이 실제로는 http로 요청을 받는 것처럼 보일 수 있습니다. 이때 redirect_uri를 런타임에 조합(req.protocol 등)하면 환경마다 값이 달라져 invalid_grant로 이어집니다.
해결:
redirect_uri는 환경변수로 고정- Express를 쓴다면
app.set('trust proxy', 1)등 프록시 신뢰 설정을 검토(단, 보안 요구사항에 맞게 제한)
멀티 인스턴스에서 세션이 날아가는 문제
메모리 세션을 쓰면 인스턴스가 바뀌는 순간 code_verifier를 못 찾아서 실패합니다.
해결:
- Redis 세션 스토어 사용
- 또는 로드밸런서 sticky session
공급자별 문서 차이로 생기는 케이스
invalid_grant라도 공급자마다 요구 파라미터가 다릅니다.
- 어떤 곳은 토큰 요청에
client_id를 body에 반드시 포함 - 어떤 곳은
client_secret을 Basic 헤더로만 허용 - 어떤 곳은
redirect_uri를 토큰 요청에서 생략 가능, 어떤 곳은 필수
따라서 “문서대로 보냈는데 실패”라면, 아래를 먼저 확인하세요.
- 토큰 엔드포인트가 v1/v2로 나뉘어 있고 잘못된 URL을 호출
- 앱 설정 콘솔에서 PKCE가 켜져 있는데
code_challenge_method가 누락 scope에 따라 토큰 교환 정책이 달라짐
재발 방지용 최소 체크리스트
redirect_uri는 상수로 고정하고/authorize와/token에 동일 적용code_verifier는 서버에 저장하고 콜백에서 즉시 삭제(재사용 방지)S256계산은 base64url(패딩 제거)로 정확히 구현- 세션 쿠키
SameSite/Secure를 환경에 맞게 설정 - 멀티 인스턴스면 중앙 세션 스토어 사용
- 토큰 교환 중복 호출을 막는 가드(로그 + idempotency)
마무리
Node.js에서 PKCE를 붙인 OAuth 로그인에서 invalid_grant는 대부분 “코드가 이미 사용됨” 또는 “redirect_uri/code_verifier가 미묘하게 다름”으로 귀결됩니다. 위 체크리스트 순서대로 보면, 재현이 어려운 운영 이슈도 로그 몇 줄로 원인을 특정할 수 있습니다.
추가로 Node 런타임/모듈 환경 문제로 인증 SDK가 꼬이는 경우도 있는데, ESM/CJS 혼용으로 예기치 않은 오류가 날 때는 Node.js ESM/CJS 충돌로 ERR_REQUIRE_ESM 해결하기도 같이 점검해보면 좋습니다.