Published on

Node.js OAuth PKCE invalid_grant 원인과 해결법

Authors

서드파티 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를 매우 엄격하게 비교합니다. 다음 차이도 불일치로 봅니다.

  • http vs https
  • 호스트 localhost vs 127.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_method
  • code_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 해결하기도 같이 점검해보면 좋습니다.