Published on

OAuth PKCE invalid_grant·state 불일치 해결 가이드

Authors

서드파티 로그인(OAuth 2.0 Authorization Code + PKCE)을 붙이면, 초반에는 잘 되다가도 특정 브라우저/특정 기기/특정 라우팅에서 갑자기 invalid_grant 혹은 state 불일치가 터지는 경우가 많습니다. 이 두 에러는 겉보기엔 “토큰 교환 실패”와 “CSRF 방어 실패”로 분리되어 보이지만, 실제 현장에서는 PKCE 검증에 필요한 값이 요청 간에 끊기거나(저장/전달/재사용 문제), 리다이렉트/세션/쿠키 정책 때문에 state가 소실되는 문제로 함께 나타나는 일이 잦습니다.

이 글은 다음을 목표로 합니다.

  • invalid_grant의 실제 원인을 PKCE 관점에서 분해
  • state 불일치의 대표 패턴(세션, 쿠키, 라우터, 멀티탭) 정리
  • 브라우저/SPA/모바일/백엔드에서 바로 적용 가능한 해결책과 코드 예제 제공

관련해서 PKCE의 invalid_grant만 따로 깊게 파고들고 싶다면 이 글도 함께 참고하세요: Auth0 OAuth PKCE invalid_grant 원인과 해결

문제를 정확히 정의하기: 어디에서 실패하는가

PKCE 플로우에서 실패 지점은 보통 두 군데입니다.

  1. /authorize 단계(인가 요청)
  • 브라우저가 IdP로 이동
  • state, code_challenge, redirect_uri 등이 포함
  1. /oauth/token 단계(토큰 교환)
  • 앱이 받은 code를 백엔드(또는 SPA)가 IdP에 전달
  • 이때 code_verifier로 PKCE 검증

에러 메시지별로 매핑하면 아래처럼 생각하면 됩니다.

  • state 불일치: 대개 1)과 2) 사이에서 클라이언트 저장소(세션/쿠키/스토리지)에 있던 state가 사라지거나 바뀜
  • invalid_grant: 대개 2)에서 code, redirect_uri, code_verifier, client_id 조합이 맞지 않거나 code가 이미 소모됨

핵심은 “요청 간 상관관계(correlation)를 유지하는 값”이 끊기는지 여부입니다.

PKCE와 state의 역할을 한 문장으로 정리

  • state: 브라우저 리다이렉트 왕복 중 요청 위조/세션 혼선 방지 (CSRF + 요청 상관관계)
  • PKCE(code_verifier / code_challenge): Authorization Code를 탈취당해도 토큰 교환을 못 하게 막는 장치

따라서 state는 “로그인 시도 1회”를 식별하고, code_verifier는 “그 로그인 시도를 시작한 주체”임을 증명합니다.

가장 흔한 원인 Top 10 (현장 체크리스트)

1) code_verifier를 저장하지 않거나, 저장 위치가 요청 간 유지되지 않음

  • SPA에서 메모리 변수에만 저장했다가 리다이렉트로 날아감
  • iOS/Android WebView에서 스토리지 정책이 달라 저장이 누락

해결:

  • 리다이렉트 전 sessionStorage 또는 보안 쿠키(서버 세션) 등에 저장
  • “탭 단위 격리”가 필요하면 sessionStorage, “브라우저 전체” 공유가 필요하면 쿠키/서버 세션

2) 멀티탭/더블클릭으로 로그인 시도가 중첩됨

  • 탭 A에서 로그인 시작, 탭 B에서도 로그인 시작
  • state와 verifier가 덮어써져서 콜백 시점에 불일치

해결:

  • 로그인 버튼 연타 방지(디바운스)
  • state를 단일 키로 저장하지 말고 state를 키로 해서 verifier를 저장

3) redirect_uri 불일치

  • /authorize에서 보낸 redirect_uri/token에서 보내는 값이 다름
  • trailing slash, URL 인코딩, 프록시 환경에서 스킴(http/https) 차이

해결:

  • /authorize/token에 동일한 redirect_uri 문자열을 사용
  • 프록시 뒤라면 X-Forwarded-Proto를 반영해 외부 URL을 계산

4) authorization code 재사용(이미 소모된 code)

  • 콜백 라우트가 두 번 실행됨(React StrictMode, Next.js 라우팅, 뒤로가기)
  • 네트워크 재시도 로직이 토큰 교환을 중복 호출

해결:

  • 콜백 처리 시 code를 1회만 소비하도록 가드
  • 교환 성공/실패를 저장하고 중복 호출 차단

5) code_verifier 인코딩/길이 규격 위반

  • PKCE는 verifier가 43~128 chars, URL-safe charset 권장
  • base64를 썼는데 패딩 = 포함, 또는 URL-safe 치환 누락

해결:

  • URL-safe base64(+-, /_, 패딩 제거) 사용

6) SameSite 쿠키 정책 때문에 state 저장 세션이 콜백에서 사라짐

  • state를 서버 세션에 저장했는데, 콜백 요청에 세션 쿠키가 안 붙음
  • 특히 크로스 사이트 리다이렉트에서 SameSite=Lax/Strict 영향

해결:

  • OAuth 콜백에 필요한 세션 쿠키는 SameSite=None; Secure 고려
  • 가능하면 BFF 패턴으로 도메인 정리(동일 사이트 유지)

7) WebView/인앱 브라우저에서 스토리지 격리

  • 인앱 브라우저가 쿠키/스토리지를 별도로 관리
  • 외부 브라우저로 열었다가 앱으로 돌아오면 저장소가 달라짐

해결:

  • 모바일은 가능한 system browser(Chrome Custom Tabs / ASWebAuthenticationSession) 사용
  • 딥링크/유니버설 링크로 돌아오는 경우 state 저장 위치를 서버로 옮기는 것도 방법

8) 클럭/만료 문제(짧은 code lifetime)

  • IdP가 code 유효시간을 짧게 설정
  • 사용자가 로그인 화면에서 오래 머물면 만료

해결:

  • code lifetime 정책 확인
  • UX 상 로그인 진행 중 이탈 줄이기

9) 클라이언트 타입 혼용(SPA인데 confidential client처럼 처리)

  • SPA가 client secret을 쓰거나, 백엔드/프론트 역할이 섞여 설정이 꼬임

해결:

  • SPA는 public client + PKCE
  • 백엔드가 토큰 교환을 담당하면 BFF로 정리

10) 프록시/로드밸런서 환경에서 세션 스티키 문제

  • state를 서버 메모리 세션에 저장했는데, 콜백이 다른 인스턴스로 감

해결:

  • 세션 스토어를 Redis 등 공유 저장소로
  • 또는 state 자체를 서명해 무상태로 검증

재현과 진단: 로그에 무엇을 남겨야 빨리 잡히나

민감정보를 그대로 로그로 남기면 위험합니다. 대신 다음처럼 해시/마스킹해서 상관관계만 추적하세요.

  • state 원문 대신 sha256(state)
  • code_verifier 원문 대신 sha256(code_verifier)
  • redirect_uri는 원문 기록(민감도 낮음)
  • code는 앞 6자만 기록

서버 로그 예시(의사코드):

import crypto from 'crypto'

function sha256(input: string) {
  return crypto.createHash('sha256').update(input).digest('hex')
}

logger.info('oauth.start', {
  stateHash: sha256(state),
  verifierHash: sha256(codeVerifier),
  redirectUri,
})

logger.info('oauth.callback', {
  stateHash: sha256(receivedState),
  codePrefix: code.slice(0, 6),
  redirectUri,
})

logger.info('oauth.token.exchange', {
  stateHash: sha256(receivedState),
  verifierHash: sha256(codeVerifier),
  codePrefix: code.slice(0, 6),
  redirectUri,
})

이렇게 남기면 “state는 동일한데 verifier가 달라졌다”, “redirect_uri가 토큰 교환에서만 다르다”, “code가 두 번 교환됐다” 같은 패턴이 바로 보입니다.

해결 전략 1: state를 키로 PKCE 데이터를 저장(멀티탭 안정화)

가장 효과가 큰 패턴은 state를 단일 저장 키로 쓰지 않는 것입니다.

잘못된 예:

  • sessionStorage.setItem('pkce_verifier', verifier)
  • 탭/시도 중첩 시 덮어쓰기 발생

권장 예:

  • sessionStorage.setItem('pkce:' + state, verifier)

브라우저(SPA) 예시:

// pkce.js
function base64UrlEncode(uint8Array) {
  let str = ''
  for (const b of uint8Array) str += String.fromCharCode(b)
  return btoa(str).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '')
}

export async function createPkcePair() {
  const verifierBytes = crypto.getRandomValues(new Uint8Array(32))
  const verifier = base64UrlEncode(verifierBytes)

  const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier))
  const challenge = base64UrlEncode(new Uint8Array(digest))

  return { verifier, challenge }
}

export function saveAttempt(state, verifier) {
  sessionStorage.setItem('pkce:' + state, verifier)
}

export function loadAttempt(state) {
  return sessionStorage.getItem('pkce:' + state)
}

export function clearAttempt(state) {
  sessionStorage.removeItem('pkce:' + state)
}

콜백에서:

import { loadAttempt, clearAttempt } from './pkce.js'

export async function handleCallback() {
  const params = new URLSearchParams(window.location.search)
  const code = params.get('code')
  const state = params.get('state')

  if (!code || !state) throw new Error('missing code/state')

  const verifier = loadAttempt(state)
  if (!verifier) throw new Error('state mismatch or verifier missing')

  // 토큰 교환 성공/실패와 무관하게 1회성으로 정리
  clearAttempt(state)

  // 이후 code + verifier로 백엔드에 전달하거나, 직접 토큰 교환
}

이 방식은 멀티탭/중복 시도에서 state와 verifier 매칭을 안정적으로 보장합니다.

해결 전략 2: 콜백 라우트의 “중복 실행”을 막아 invalid_grant 방지

invalid_grant의 흔한 원인은 “code를 두 번 교환”입니다. 특히 Next.js/React 환경에서 다음 케이스가 많습니다.

  • 콜백 페이지가 렌더링/마운트 과정에서 effect가 두 번 실행
  • 라우터가 query 변화를 두 번 트리거

프론트에서 가드(클라이언트) 예시:

let exchanging = false

export async function exchangeOnce(payload) {
  if (exchanging) return
  exchanging = true
  try {
    const res = await fetch('/api/oauth/exchange', {
      method: 'POST',
      headers: { 'content-type': 'application/json' },
      body: JSON.stringify(payload),
    })
    if (!res.ok) throw new Error('token exchange failed')
    return await res.json()
  } finally {
    // 페이지 이동 전까지는 true 유지하는 편이 안전
  }
}

서버에서도 “code 1회 처리”를 보장하면 더 안전합니다(동일 code 재요청 차단). 여기서의 포인트는 재시도 로직을 넣더라도 invalid_grant는 대부분 재시도로 해결되지 않는다는 점입니다. 재시도/백오프 설계 일반론은 OpenAI 429 rate_limit_exceeded 재시도·백오프 설계처럼 “일시 오류”에 적용하고, OAuth에서는 원인 제거가 우선입니다.

해결 전략 3: 서버(BFF)에서 state를 서명해 무상태로 검증

서버 세션/쿠키가 환경에 따라 불안정하다면, state를 “임의 문자열”로만 쓰지 말고 서명된 페이로드로 만들어 무상태로 검증할 수 있습니다.

예: state = base64url(payload) + '.' + base64url(hmac)

Node.js 예시(개념 코드):

import crypto from 'crypto'

function b64url(buf: Buffer) {
  return buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '')
}

function signState(payloadObj: any, secret: string) {
  const payload = Buffer.from(JSON.stringify(payloadObj))
  const payloadPart = b64url(payload)
  const mac = crypto.createHmac('sha256', secret).update(payloadPart).digest()
  const macPart = b64url(mac)
  return payloadPart + '.' + macPart
}

function verifyState(state: string, secret: string) {
  const parts = state.split('.')
  if (parts.length !== 2) return null
  const payloadPart = parts[0]
  const macPart = parts[1]

  const expected = b64url(crypto.createHmac('sha256', secret).update(payloadPart).digest())
  if (!crypto.timingSafeEqual(Buffer.from(macPart), Buffer.from(expected))) return null

  const json = Buffer.from(payloadPart.replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString('utf8')
  return JSON.parse(json)
}

payload에 넣을만한 값:

  • nonce(랜덤)
  • iat(발급 시각), exp(만료)
  • returnTo(로그인 후 돌아갈 경로)
  • pkce_id(서버가 verifier를 저장하는 키)

주의:

  • state 자체가 길어질 수 있으니 IdP 제한 확인
  • 민감정보(토큰, 개인정보)는 넣지 말 것

해결 전략 4: redirect URI와 외부 URL 계산을 “한 군데”로 고정

프록시/로드밸런서 뒤에서 redirect_uri가 흔들리면 /authorize/token이 다른 문자열을 쓰게 됩니다.

백엔드(Express)에서 외부 base URL 계산 예시:

import type { Request } from 'express'

export function getExternalBaseUrl(req: Request) {
  const proto = (req.headers['x-forwarded-proto'] as string) || req.protocol
  const host = (req.headers['x-forwarded-host'] as string) || req.get('host')
  return proto + '://' + host
}

export function getRedirectUri(req: Request) {
  return getExternalBaseUrl(req) + '/oauth/callback'
}

그리고 /authorize를 만들 때도, /token 교환할 때도 동일한 getRedirectUri를 사용하세요.

state 불일치가 계속되면 의심해야 할 브라우저/쿠키 이슈

특히 “로컬에서는 되는데 운영에서만 state mismatch”면 아래를 우선 점검합니다.

  • 쿠키 도메인/경로: Domain, Path가 콜백 경로를 포함하는지
  • Secure: HTTPS에서만 쿠키 전송, 운영은 HTTPS인데 로컬은 HTTP라 동작이 달라질 수 있음
  • SameSite: 크로스 사이트 리다이렉트에서 쿠키가 누락될 수 있음
  • 서브도메인 분리: app.example.com에서 시작하고 api.example.com에서 콜백 처리하면 쿠키 정책이 복잡해짐

이 문제는 본질적으로 “인증 정보가 401/403으로 튕기는 문제”와 결이 비슷합니다. 인증 헤더/쿠키가 어디서 빠지는지 추적하는 관점은 Kubernetes 401 Unauthorized 원인별 해결 가이드와도 통합니다(전송 경로와 경계 지점을 분리해서 보는 접근).

운영에서 바로 쓰는 최종 점검표

배포 전/장애 대응 시 아래 순서로 보면 빠릅니다.

  1. 콜백에서 받은 state가 “내가 발급한 state”인지(저장소/서명 검증)
  2. state에 매핑된 code_verifier가 존재하는지
  3. /authorize/tokenredirect_uri가 완전히 동일한지(문자열 비교)
  4. 토큰 교환이 중복 호출되지 않는지(서버 로그로 동일 codePrefix 2회 등장 여부)
  5. PKCE verifier/challenge 생성 규격이 맞는지(길이, URL-safe)
  6. 쿠키/세션이 콜백에서 유지되는지(브라우저 개발자도구 네트워크에서 Cookie 헤더 확인)
  7. 인스턴스 다중화 시 세션 스토어 공유 여부

결론: invalid_grant와 state 불일치는 “상관관계 끊김” 문제다

invalid_grantstate 불일치는 각각 다른 에러처럼 보여도, 대부분은 “로그인 시도 1회”를 식별하는 값(state)과 “그 시도의 증명값(verifier)”이 리다이렉트 왕복 과정에서 저장/전달/1회성 소비 규칙을 지키지 못해 발생합니다.

  • 멀티탭/중복 시도까지 고려해 state를 키로 저장 구조를 바꾸고
  • 콜백에서 토큰 교환을 1회만 수행하도록 가드하며
  • 프록시 환경에서 redirect_uri를 단일 함수로 고정하고
  • 세션이 불안정하면 state 서명(무상태) 또는 서버 저장소(공유)로 옮기면

대부분의 케이스는 재발 없이 정리됩니다.