Published on

OIDC PKCE 로그인 무한 리다이렉트, SameSite 쿠키로 끝내기

Authors

서드파티 IdP(Okta, Auth0, Keycloak 등)와 OIDC Authorization Code + PKCE로 로그인 붙이다 보면, 특정 브라우저(특히 Chrome)에서만 로그인 성공 직전부터 callbacklogin 사이를 무한히 왕복하는 증상을 자주 만납니다. 서버 로그에는 인증 코드 교환까지는 된 것 같은데, 앱 입장에서는 “로그인 안 됨”으로 판단해 다시 인증을 시작합니다.

이 문제는 대개 PKCE 자체가 아니라, PKCE 흐름을 성립시키는 세션 쿠키(또는 state 저장 쿠키)가 SameSite 정책 때문에 콜백 요청에 실리지 않는 것에서 시작합니다. 즉, IdP에서 우리 서비스로 돌아오는 순간(리다이렉트) 세션이 끊겨 state 검증이 실패하고, 애플리케이션이 다시 로그인으로 보내면서 루프가 만들어집니다.

아래에서 증상을 재현 가능한 형태로 분해하고, 어디를 어떻게 고쳐야 “한 번에” 끝나는지 정리합니다.

1) OIDC PKCE 로그인에서 쿠키가 왜 중요한가

OIDC Authorization Code + PKCE의 핵심은 다음 4가지가 맞물려야 한다는 점입니다.

  • 브라우저가 /login 진입
  • 서버(또는 BFF)가 state, nonce, code_verifier 를 생성
  • 서버가 이 값을 세션/쿠키에 저장 (혹은 암호화해 쿠키에 직접 저장)
  • 브라우저가 IdP로 이동했다가, IdP가 redirect_uricodestate 를 붙여 리다이렉트

이때 콜백(/callback)에서 서버는 반드시:

  • 요청으로 들어온 state저장된 state와 동일한지 검증
  • 저장된 code_verifier 로 토큰 엔드포인트에 코드 교환

을 해야 합니다.

문제는, 콜백 요청이 “외부 사이트(IdP)에서 우리 사이트로 넘어오는” 크로스 사이트 내비게이션이라는 점입니다. 이 순간 브라우저는 SameSite 정책에 따라 쿠키를 제한할 수 있고, 그 결과 /callback 에서 세션이 비어 state 를 찾지 못합니다.

결론적으로 서버는 이런 식으로 오동작합니다.

  • state mismatch 또는 missing verifier 발생
  • 보안상 실패 처리 후 세션 초기화
  • “로그인 안 됨”으로 간주하고 다시 /login 으로 리다이렉트
  • 반복

2) 전형적인 증상과 로그 패턴

프런트에서는 다음처럼 보입니다.

  • 로그인 버튼 클릭
  • IdP 로그인 화면 정상
  • 로그인 성공 후 우리 서비스로 돌아오지만 곧바로 다시 IdP로 이동
  • 주소창이 callbacklogin 을 빠르게 오가며 끝나지 않음

서버 로그에서 자주 보이는 단서:

  • state not found in session
  • invalid_state
  • PKCE code_verifier missing
  • nonce cookie missing

또는 프레임워크가 더 친절하게:

  • “세션이 새로 발급됨”
  • “요청마다 세션 id가 달라짐”

같은 힌트를 줍니다.

3) SameSite가 콜백 쿠키를 막는 원리 (핵심)

SameSite는 쿠키가 “어떤 컨텍스트에서” 전송되는지 제한합니다.

  • SameSite=Strict: 크로스 사이트 컨텍스트에서는 거의 전송되지 않음
  • SameSite=Lax: 탑레벨 GET 내비게이션에는 전송될 수 있음(현대 브라우저 기준)
  • SameSite=None: 크로스 사이트에서도 전송됨. 단, 반드시 Secure 필요

OIDC 콜백은 보통:

  • IdP 도메인 https://idp.example.com 에서
  • 우리 도메인 https://app.example.com/callback
  • 302 리다이렉트 + GET

형태의 탑레벨 내비게이션입니다.

여기서 흔한 함정은 다음입니다.

함정 A: 세션 쿠키가 SameSite=Strict 로 발급됨

보안 강화 목적으로 Strict를 걸어두면, 콜백에서 세션 쿠키가 빠질 수 있습니다. 그러면 state 를 못 찾고 루프가 납니다.

함정 B: SameSite=None 로 바꿨는데 Secure 가 없음

Chrome은 SameSite=None 인 쿠키에 Secure 가 없으면 쿠키를 아예 무시합니다. 개발 환경에서 http://localhost 로 테스트할 때 특히 많이 터집니다.

함정 C: 프록시/로드밸런서 뒤에서 HTTPS 인식이 깨짐

사용자는 HTTPS로 접속하지만, 앱 서버는 내부에서 HTTP로 보거나 X-Forwarded-Proto 를 신뢰하지 않아 Secure 쿠키를 잘못 처리하는 경우가 있습니다.

이 경우 “쿠키를 설정한 것 같은데 브라우저에 남지 않는다” 또는 “다음 요청에 안 실린다”로 나타납니다.

프록시 환경 이슈는 OAuth 콜백에서 자주 같이 터지므로, Nginx 헤더/HTTPS 관련 점검은 아래 글도 함께 보면 좋습니다.

4) 빠르게 원인 좁히는 체크리스트 (DevTools 기준)

Chrome DevTools로 아래만 확인해도 원인의 80%는 바로 잡힙니다.

4.1 콜백 요청에 쿠키가 실렸는지

  • Network 탭에서 /callback 요청 클릭
  • Request Headers의 Cookie 확인

여기서 세션 쿠키(예: sid, session, connect.sid)가 없다면 SameSite 또는 Secure 또는 도메인/경로 설정 문제입니다.

4.2 Application 탭에서 쿠키 속성 확인

  • Application 탭 Cookies 에서 해당 쿠키 선택
  • SameSite, Secure, Domain, Path, Expires/Max-Age 확인

4.3 “쿠키가 차단됨” 경고 확인

Network 탭에서 요청을 보면 “This Set-Cookie was blocked because…” 같은 메시지가 뜨는 경우가 많습니다.

5) 해결책 1: OIDC 관련 세션 쿠키는 SameSite=None; Secure 를 우선 검토

가장 확실한 해결은, OIDC 흐름을 유지하는 쿠키(세션 쿠키 또는 state 저장 쿠키)가 크로스 사이트 콜백에서도 전송되도록 만드는 것입니다.

즉:

  • SameSite=None
  • Secure=true

조합을 사용합니다.

단, 이 조합은 HTTPS가 필수입니다. 로컬 개발에서도 https://localhost 를 쓰거나, 프록시로 TLS를 붙여 테스트 환경을 맞추는 편이 장기적으로 안전합니다.

Express 세션 예시

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

const app = express()

app.set('trust proxy', 1) // 프록시 뒤에서 Secure 쿠키를 쓰려면 중요

app.use(
  session({
    name: 'sid',
    secret: process.env.SESSION_SECRET,
    resave: false,
    saveUninitialized: false,
    cookie: {
      httpOnly: true,
      secure: true,
      sameSite: 'none',
      path: '/',
    },
  })
)

여기서 trust proxy 를 안 켜면, TLS 종료가 프록시에서 일어나는 구성에서 secure: true 쿠키가 제대로 세팅되지 않거나, 프레임워크가 “HTTPS 아님”으로 판단해 쿠키를 누락시키는 문제가 생깁니다.

import { NextResponse } from 'next/server'

export function GET() {
  const res = NextResponse.redirect(new URL('/login', 'https://app.example.com'))

  res.headers.append(
    'Set-Cookie',
    [
      'oidc_state=abc123',
      'Path=/',
      'HttpOnly',
      'Secure',
      'SameSite=None',
      'Max-Age=300',
    ].join('; ')
  )

  return res
}

주의: 본문에 부등호가 있는 문법(예: 제네릭 Array<T>)을 쓰면 MDX에서 문제가 될 수 있으니, 항상 인라인 코드로 감싸거나 엔티티로 치환하세요.

6) 해결책 2: SameSite=Lax 로 해결되는 경우와 한계

OIDC 콜백이 “탑레벨 GET” 리다이렉트라면 SameSite=Lax 로도 쿠키가 전송되는 경우가 많습니다. 그래서 보수적으로는:

  • SameSite=Lax
  • Secure=true (권장)

로도 해결이 될 수 있습니다.

하지만 다음 조건이 끼면 Lax가 불안정해집니다.

  • IdP 또는 중간 페이지가 POST 기반으로 콜백을 구성하는 경우
  • 앱이 iframe, 팝업, 임베디드 웹뷰 등 특수 컨텍스트에서 동작
  • 브라우저 정책 변화(특히 모바일 웹뷰 계열)

“무한 리다이렉트가 특정 환경에서만 난다”면, Lax로 임시 봉합하지 말고 None으로 정석 처리하는 편이 운영 안정성 측면에서 낫습니다.

7) 해결책 3: 도메인, 경로, 서브도메인 구성 점검

SameSite만 맞춰도 안 되면, 쿠키 스코프가 틀린 경우가 많습니다.

7.1 서브도메인이 다른데 쿠키 Domain이 좁음

예를 들어:

  • 앱: app.example.com
  • API/BFF: api.example.com

인데 세션 쿠키가 api.example.com 에만 묶이면, 브라우저 내비게이션이 app.example.com/callback 으로 들어올 때 쿠키가 안 실립니다.

해결은 보통:

  • BFF와 콜백을 같은 호스트로 맞추거나
  • 쿠키 Domain=example.com 으로 통일

입니다.

7.2 Path가 너무 좁음

쿠키가 Path=/auth 로 되어 있는데 콜백이 /callback 이면 당연히 안 실립니다. OIDC 관련 쿠키는 대개 Path=/ 로 둡니다.

8) 해결책 4: 프록시/로드밸런서 환경에서 HTTPS 인식 고치기

운영에서 자주 보는 구성이:

  • CloudFront 또는 ALB에서 TLS 종료
  • 내부로는 HTTP
  • 앱은 “HTTP 요청”으로 오인

이때 앱이 Secure 쿠키를 세팅하지 않거나, 리다이렉트 URI 생성 시 스킴을 http 로 만들어 IdP 설정과 불일치가 나기도 합니다.

Nginx를 쓴다면 최소한 아래 헤더는 일관되게 전달하세요.

proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

그리고 애플리케이션 프레임워크에서 프록시 신뢰 설정을 켭니다(Express의 trust proxy, Spring의 server.forward-headers-strategy, Next.js 배포 환경 변수 등).

이 주제는 OAuth 콜백 오류(400 등)와 함께 묶여 나타나는 경우가 많습니다.

9) “무한 루프”를 끊기 위한 서버 측 방어 로직

근본 해결은 쿠키 설정이지만, 운영 장애를 줄이려면 루프를 감지해 빠르게 실패시키는 방어도 유용합니다.

9.1 state 검증 실패 시 무조건 로그인으로 보내지 말기

state 가 없거나 mismatch면:

  • 현재 세션을 폐기하고
  • 사용자에게 “브라우저 쿠키 차단 또는 SameSite 설정 문제”를 안내하는 에러 페이지로 보냅니다.

무조건 /login 으로 보내면 사용자는 계속 루프에 갇힙니다.

9.2 리다이렉트 횟수 제한 쿠키/파라미터

예: login_attempt=1..N 을 짧은 TTL로 두고 N 초과 시 에러로 전환.

function guardRedirectLoop(req, res, next) {
  const n = Number(req.cookies.login_attempt || '0')
  if (n >= 3) {
    return res.status(400).send('Login failed: possible cookie SameSite/Secure issue')
  }
  res.cookie('login_attempt', String(n + 1), {
    maxAge: 60_000,
    httpOnly: true,
    sameSite: 'lax',
    secure: true,
    path: '/',
  })
  next()
}

이 쿠키는 OIDC 세션 쿠키와 목적이 다르므로 SameSite=Lax 로 둬도 됩니다.

10) 로컬 개발 환경에서 특히 많이 터지는 포인트

  • SameSite=None + Secure 필요한데 로컬이 HTTP
  • localhost127.0.0.1 를 섞어서 쿠키 호스트가 달라짐
  • 프런트는 http://localhost:3000, 백엔드는 http://localhost:8080 인데 콜백이 다른 호스트로 찍힘

권장 패턴:

  • 개발에서도 HTTPS를 켜기
  • 프런트와 BFF를 같은 오리진으로 프록시(예: Next.js rewrites)
  • 콜백 엔드포인트를 “세션을 발급한 동일 호스트”로 고정

Next.js에서 캐시나 ISR처럼 “환경 차이 때문에 특정 상황에서만 재현되는” 문제를 빠르게 좁히는 방식이 도움이 될 때가 있습니다.

11) 정리: 가장 흔한 정답 조합

무한 리다이렉트가 OIDC PKCE에서 발생한다면, 아래 순서로 보면 빠릅니다.

  1. /callback 요청에 세션 쿠키가 실리는지 확인
  2. 안 실리면 쿠키 속성 확인
  3. OIDC 세션 쿠키를 SameSite=None; Secure 로 변경
  4. 프록시 뒤라면 X-Forwarded-Proto 와 앱의 프록시 신뢰 설정 적용
  5. Domain/Path 스코프를 콜백 호스트와 일치
  6. 루프 방지용 서버 측 가드 추가

PKCE는 “코드 탈취”를 막는 장치이고, 실제 장애의 원인은 대개 “브라우저가 쿠키를 안 보내서 state를 잃어버리는 것”입니다. 쿠키의 SameSite와 Secure, 그리고 HTTPS 인식(프록시 헤더)만 바로 잡아도 로그인 루프는 대부분 깔끔하게 사라집니다.