Published on

OAuth2 PKCE 로그인 루프·state 불일치 10분 해결

Authors

서드파티 로그인에 PKCE를 붙인 뒤 갑자기 로그인 -> 콜백 -> 다시 로그인이 무한 반복되거나, IdP가 state mismatch / invalid_state를 뱉는 상황은 생각보다 흔합니다. 원인은 대부분 state 저장소(쿠키/세션)와 콜백 요청이 만나는 지점이 어긋나는 것입니다. 이 글은 “10분 안에” 원인을 좁히고, 재발을 막는 형태로 정리합니다.

아래 내용은 Next.js(혹은 Express) + OAuth2 Authorization Code + PKCE 기준으로 설명하지만, 프레임워크와 무관하게 동일한 원리가 적용됩니다.

증상 패턴으로 원인 빠르게 분류하기

패턴 A: 로그인은 되는데 다시 로그인 페이지로 돌아감(루프)

  • 콜백에서 토큰 교환은 성공했는데, 앱 세션이 생성되지 않거나 유지되지 않음
  • 혹은 세션은 생성됐지만 다음 요청에서 세션을 못 읽음

대부분 아래 중 하나입니다.

  • 콜백 도메인/프로토콜이 달라 쿠키가 저장되지 않음
  • SameSite / Secure 설정이 환경과 맞지 않음
  • 프록시 뒤에서 X-Forwarded-Proto 처리가 안 돼서 HTTPS로 인식 못함

패턴 B: IdP가 state mismatch / invalid_state를 반환

  • 앱이 저장한 state와 콜백에 온 state가 다름

대부분 아래 중 하나입니다.

  • state를 저장한 쿠키/세션이 콜백 요청에 같이 오지 않음
  • 멀티탭/더블클릭으로 state가 덮어써짐
  • 로드밸런서 환경에서 세션 스토리지 불일치(sticky session 미구성)

패턴 C: 로컬에서는 되는데 운영에서만 터짐

  • 운영은 HTTPS + 프록시 + 서브도메인 + Cloud Run/Ingress 등 변수가 늘어남

프록시/런타임에서 헤더를 어떻게 전달하는지부터 확인하세요. (콜드스타트/프록시 증상과 함께 나타나면 GCP Cloud Run 503·콜드스타트 10분 지연 진단도 같이 보면 원인 분리가 빨라집니다.)

10분 진단 체크리스트(이 순서대로 하면 빨라집니다)

1) 콜백 요청에 쿠키가 실제로 붙어오는지 확인

브라우저 개발자 도구 Network에서 콜백 URL 요청을 클릭하고 Request HeadersCookie가 있는지 봅니다.

  • 쿠키가 없다: SameSite / Secure / Domain / Path / 프로토콜 불일치 문제
  • 쿠키가 있는데 서버가 세션을 못 읽는다: 세션 암호키 불일치, 스토리지 분산 문제

서버 로그에도 다음을 찍어보면 1분 내로 갈립니다.

// Express 예시
app.get('/auth/callback', (req, res) => {
  console.log('callback cookies:', req.headers.cookie);
  console.log('callback query:', req.query);
  res.status(200).send('ok');
});

2) redirect_uri가 “완전히 동일”한지 확인

Authorization 요청에 넣은 redirect_uri와 토큰 교환 시점에 넣는 redirect_uri문자열로 완전히 동일해야 하는 IdP가 많습니다.

  • https://app.example.com/auth/callback vs https://app.example.com/auth/callback/
  • http://localhost:3000/... vs http://127.0.0.1:3000/...

이 차이가 있으면 토큰 교환이 실패하거나, 라이브러리가 내부적으로 재시도하면서 루프처럼 보일 수 있습니다.

3) PKCE code_verifier 저장소가 요청 단위로 유지되는지 확인

PKCE는 code_verifier를 로그인 시작 시 생성하고, 콜백에서 토큰 교환에 다시 써야 합니다.

  • 메모리 변수에 저장했다: 서버리스/스케일아웃에서 깨짐
  • 쿠키에 저장했다: SameSite/Secure 문제로 콜백에 안 옴

권장: 서버 세션(예: Redis) + state 키로 매핑

4) 멀티탭/더블클릭으로 state가 덮어써지는지 확인

사용자가 로그인 버튼을 두 번 누르거나, 새 탭에서 로그인하면 state가 최신 값으로 덮여서 기존 콜백이 state mismatch가 됩니다.

해결책은 둘 중 하나입니다.

  • state를 단일 슬롯에 저장하지 말고, state별로 보관(짧은 TTL)
  • UI에서 로그인 버튼을 누른 뒤 disabled 처리

5) 프록시 뒤에서 Secure 쿠키가 누락되지 않는지 확인

HTTPS 환경에서 세션 쿠키는 보통 Secure가 필요합니다. 그런데 앱이 프록시 뒤에 있을 때 서버가 http로 인식하면 Secure 쿠키를 잘못 설정하거나, 반대로 설정하지 못해서 콜백에서 쿠키가 빠집니다.

Express라면 보통 아래가 필요합니다.

import express from 'express';
import session from 'express-session';

const app = express();

// 프록시(로드밸런서) 뒤라면 필수
app.set('trust proxy', 1);

app.use(session({
  secret: process.env.SESSION_SECRET!,
  resave: false,
  saveUninitialized: false,
  cookie: {
    httpOnly: true,
    secure: true,        // HTTPS에서만
    sameSite: 'lax',     // 보통 OAuth 콜백은 Lax로 충분
    path: '/',
  },
}));

주의: sameSite: 'none'을 쓰면 secure: true가 사실상 강제입니다. 로컬 HTTP에서 테스트할 때는 환경에 따라 분기하세요.

가장 흔한 원인 5가지와 바로 고치는 방법

1) SameSite 설정 실수로 콜백에 쿠키가 안 붙는 문제

OAuth 콜백은 “외부 사이트에서 내 사이트로 들어오는 top-level navigation”인 경우가 많아서 SameSite=Lax면 쿠키가 전송됩니다.

하지만 아래 케이스에서는 Lax로도 안 붙을 수 있습니다.

  • IdP가 POST로 콜백을 보내는 form_post 모드
  • 앱이 콜백을 iframe/팝업으로 받는 구조

이때는 SameSite=None이 필요할 수 있습니다.

cookie: {
  secure: true,
  sameSite: 'none',
}

대신, None은 CSRF 표면을 넓힐 수 있으니 state 검증은 필수이며, 가능하면 콜백은 GET 기반으로 유지하는 것이 운영 난이도가 낮습니다.

2) 서브도메인/커스텀 도메인에서 쿠키 Domain이 어긋나는 문제

  • app.example.com에서 로그인 시작
  • 콜백은 auth.example.com으로 받음

이 경우 Domain을 맞춰야 합니다.

cookie: {
  domain: '.example.com',
  path: '/',
  secure: true,
  sameSite: 'lax',
}

반대로, 단일 도메인만 쓸 거면 domain을 아예 지정하지 않는 편이 안전합니다(브라우저 기본 스코프가 더 예측 가능).

3) state를 “한 칸”에 저장해서 멀티탭에 깨지는 문제

잘못된 예시는 이런 형태입니다.

// 나쁜 예: 세션에 단일 state만 저장
req.session.oauthState = state;

좋은 예시는 state를 키로 여러 개 저장하고 TTL로 정리하는 방식입니다.

// 좋은 예: state별 저장(간단 구현)
req.session.oauthStates = req.session.oauthStates ?? {};
req.session.oauthStates[state] = {
  createdAt: Date.now(),
  codeVerifier,
};

// 콜백에서
const incomingState = String(req.query.state ?? '');
const entry = req.session.oauthStates?.[incomingState];
if (!entry) {
  return res.status(400).send('invalid_state');
}

// 사용 후 즉시 제거(재사용 방지)
delete req.session.oauthStates[incomingState];

운영에서는 메모리 세션 대신 Redis 같은 외부 스토리지를 쓰고, state 엔트리에 5분 정도 TTL을 두는 게 정석입니다.

4) redirect_uri 자동 생성 로직이 환경마다 달라지는 문제

서버가 요청을 보고 redirect_uri를 만들 때, 프록시 환경에서는 스킴이 뒤집힙니다.

// 흔한 실수: req.protocol이 http로 잡힘
const redirectUri = `${req.protocol}://${req.get('host')}/auth/callback`;

해결:

  • trust proxy를 설정하고
  • 가능하면 redirect_uri환경변수로 고정하세요.
const redirectUri = process.env.OAUTH_REDIRECT_URI!;

이 한 줄이 로그인 루프의 50%를 끝냅니다.

5) PKCE/세션은 맞는데도 계속 루프가 도는 문제(앱 세션 발급 실패)

콜백에서 토큰 교환 후, 앱 자체 세션(혹은 JWT 쿠키)을 발급해야 “로그인 완료”가 됩니다. 여기서 실패하면 다시 로그인으로 튕기며 루프가 됩니다.

체크:

  • 콜백 응답에서 Set-Cookie가 내려오는지
  • 내려오는데 다음 요청에 쿠키가 안 붙는지

보안적으로는 여기서 JWT를 쓴다면 헤더/키 관리도 함께 점검하세요. 특히 kid 기반 키 선택 로직이 있다면 키혼동 방어가 필요합니다. 관련해서는 JWT kid 헤더 악용 키혼동 취약점 차단법을 같이 참고하면 좋습니다.

Next.js(App Router)에서 자주 하는 실수와 안전한 패턴

실수: state/code_verifier를 클라이언트 스토리지에 저장

localStorage에 저장하면 XSS에 취약하고, 브라우저/탭 상태에 따라 꼬이기 쉽습니다.

권장 패턴:

  • 로그인 시작은 서버 라우트에서 처리
  • state와 code_verifier는 서버 세션(또는 암호화 쿠키)에 저장

아래는 Next.js Route Handler에서 “개념”을 보여주는 축약 예시입니다. (세션 구현은 프로젝트마다 다르므로, 저장소 인터페이스만 분리해 두는 것을 추천합니다.)

// app/auth/start/route.ts
import { NextResponse } from 'next/server';
import crypto from 'crypto';

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

export async function GET() {
  const state = base64url(crypto.randomBytes(16));
  const codeVerifier = base64url(crypto.randomBytes(32));
  const codeChallenge = base64url(
    crypto.createHash('sha256').update(codeVerifier).digest()
  );

  // TODO: state를 키로 codeVerifier를 서버 저장소에 저장(TTL 5분)
  // await oauthStore.put(state, { codeVerifier }, 300);

  const authUrl = new URL(process.env.OAUTH_AUTHORIZATION_ENDPOINT!);
  authUrl.searchParams.set('response_type', 'code');
  authUrl.searchParams.set('client_id', process.env.OAUTH_CLIENT_ID!);
  authUrl.searchParams.set('redirect_uri', process.env.OAUTH_REDIRECT_URI!);
  authUrl.searchParams.set('state', state);
  authUrl.searchParams.set('code_challenge', codeChallenge);
  authUrl.searchParams.set('code_challenge_method', 'S256');

  return NextResponse.redirect(authUrl.toString());
}
// app/auth/callback/route.ts
import { NextResponse } from 'next/server';

export async function GET(req: Request) {
  const url = new URL(req.url);
  const code = url.searchParams.get('code') ?? '';
  const state = url.searchParams.get('state') ?? '';

  // TODO: state로 codeVerifier 로드 후 1회 사용 처리
  // const entry = await oauthStore.get(state);
  // if (!entry) return new NextResponse('invalid_state', { status: 400 });

  // TODO: 토큰 교환 요청에 code_verifier 포함
  // 이후 앱 세션 쿠키 발급

  return NextResponse.redirect('/');
}

핵심은 단 하나입니다. 콜백에서 검증해야 할 값(state, code_verifier)이 콜백 요청과 같은 “세션 컨텍스트”에서 재현 가능해야 한다는 점입니다.

재발 방지: 운영에서 안정적으로 굴리는 설정 6가지

  1. redirect_uri는 코드에서 조립하지 말고 환경변수로 고정
  2. 프록시 뒤라면 trust proxy 및 forwarded 헤더 처리
  3. state 저장은 단일 슬롯 금지, state별 저장 + TTL
  4. 콜백 처리 후 state는 즉시 삭제(재사용 방지)
  5. 세션 스토리지는 멀티 인스턴스 환경에서 공유(Redis 등)
  6. 로그인 버튼 더블클릭 방지 및 멀티탭 시나리오 테스트

10분 내 결론: 어디를 보면 바로 끝나나

  • state mismatch면: 콜백 요청에 쿠키/세션이 붙는지부터 보고, 그 다음 멀티탭 덮어쓰기 여부를 봅니다.
  • 로그인 루프면: 콜백 응답의 Set-Cookie와 다음 요청의 Cookie를 비교합니다. 여기서 끊기면 SameSite/Secure/도메인/프록시 문제입니다.

이 두 줄만 제대로 확인해도, PKCE 로그인 루프의 대부분은 “디버깅”이 아니라 “설정 수정”으로 끝납니다.