Published on

Keycloak OAuth 로그인 무한 리다이렉트 원인 7가지

Authors

서버는 정상인데 브라우저만 계속 로그인 -> 콜백 -> 로그인을 반복하는 케이스가 있습니다. Keycloak OAuth/OIDC 연동에서 흔히 말하는 “무한 리다이렉트”입니다. 겉으로는 단순해 보이지만, 실제 원인은 쿠키 정책, 리버스 프록시 헤더, 리다이렉트 URI 미스매치, 세션 스토리지, 시간 동기화 등 여러 층에 걸쳐 발생합니다.

이 글은 “왜 다시 로그인으로 튕기는지”를 원인별로 분해하고, 각 원인에 대해 로그에서 무엇을 찾아야 하는지, 어떤 설정을 바꿔야 하는지를 실전 관점에서 정리합니다.

관련 이슈로 토큰 교환 단계에서 invalid_grant가 같이 보인다면 아래 글도 함께 보시면 문제 범위를 빠르게 좁힐 수 있습니다.

무한 리다이렉트의 전형적인 흐름

대부분의 패턴은 아래 중 하나로 나타납니다.

  1. 앱이 /login에서 Keycloak의 authorization_endpoint로 리다이렉트
  2. 로그인 성공 후 redirect_uri로 돌아옴
  3. 앱이 세션 쿠키를 못 읽거나 토큰 검증에 실패
  4. “인증 안 됨”으로 판단하고 다시 /login으로 리다이렉트

즉 “로그인은 성공”했는데 “앱이 로그인 상태를 유지하지 못하는 것”이 핵심입니다.

원인 1) redirect_uri / baseUrl / issuer 불일치

가장 흔합니다. 앱이 생성한 redirect_uri와 Keycloak 클라이언트 설정의 Valid Redirect URIs가 미세하게 다르면, 로그인은 되는 것처럼 보여도 콜백 처리에서 실패하거나 다른 주소로 튕기며 루프가 생깁니다.

체크 포인트

  • Keycloak Admin Console Clients -> (client) -> Settings -> Valid Redirect URIs에 실제 콜백 URL이 포함되어 있는가
  • http/https, 포트, 경로, 트레일링 슬래시(/)까지 일치하는가
  • 앱에서 사용하는 issuer가 실제 realm 주소와 일치하는가

예시(NextAuth.js)

아래처럼 NEXTAUTH_URL이 외부에서 접근하는 URL과 다르면 콜백이 꼬이면서 루프가 납니다.

# 잘못된 예: 내부 도메인
NEXTAUTH_URL=http://keycloak:8080

# 올바른 예: 브라우저가 접근하는 외부 도메인
NEXTAUTH_URL=https://app.example.com

Keycloak 쪽도 동일한 외부 URL 기반으로 맞춰야 합니다.

원인 2) 리버스 프록시 뒤에서 X-Forwarded-* 헤더 미설정

Kubernetes Ingress, ALB, Nginx 같은 프록시 뒤에 Keycloak 또는 앱이 있을 때, 외부는 https인데 내부는 http로 통신하는 구조가 흔합니다. 이때 애플리케이션이 “내가 http로 접속받았다”고 오해하면 redirect_urihttp로 만들고, 브라우저는 https로 접근 중이라 쿠키/리다이렉트가 계속 어긋납니다.

체크 포인트

  • 프록시가 X-Forwarded-Proto, X-Forwarded-Host, X-Forwarded-Port를 올바르게 전달하는가
  • Keycloak이 프록시 환경을 인지하도록 설정했는가

Keycloak(Quarkus 배포) 대표 설정

# 예시: 컨테이너 환경 변수
KC_PROXY=edge
KC_HTTP_ENABLED=true
KC_HOSTNAME=auth.example.com
KC_HOSTNAME_STRICT=true

환경에 따라 KC_PROXY 값이 달라질 수 있습니다. 핵심은 “Keycloak이 외부 스킴/호스트를 기준으로 URL을 생성”하도록 만드는 것입니다.

Nginx 프록시 헤더 예시

location / {
  proxy_set_header Host $host;
  proxy_set_header X-Forwarded-Host $host;
  proxy_set_header X-Forwarded-Proto $scheme;
  proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
  proxy_pass http://app_upstream;
}

원인 3) SameSite / Secure 쿠키 정책으로 세션 쿠키가 저장되지 않음

로그인 성공 후 콜백에서 세션 쿠키를 심어야 하는데, 브라우저가 쿠키를 차단하면 앱은 계속 “로그인 안 됨”으로 판단합니다. 특히 크로스 도메인(앱 도메인과 Keycloak 도메인이 다름) 또는 iframe/서드파티 컨텍스트가 있으면 SameSite 정책이 크게 영향을 줍니다.

체크 포인트

  • 세션 쿠키에 Secure가 필요한데 http로 테스트하고 있지 않은가
  • SameSite=Lax 또는 Strict 때문에 콜백 요청에 쿠키가 포함되지 않는가
  • Safari/모바일 환경에서 ITP로 서드파티 쿠키가 막히지 않는가

Express 세션 쿠키 예시

import session from 'express-session'

app.set('trust proxy', 1)
app.use(session({
  secret: process.env.SESSION_SECRET,
  resave: false,
  saveUninitialized: false,
  cookie: {
    httpOnly: true,
    secure: true,
    sameSite: 'none',
  }
}))
  • 프록시 뒤라면 app.set('trust proxy', 1)이 중요합니다.
  • sameSite: 'none'을 쓰면 반드시 secure: true가 필요합니다.

원인 4) 콜백 경로에서 세션 저장소가 공유되지 않음(멀티 레플리카/무상태 배포)

로그인 플로우는 보통 “요청 A에서 state/nonce 저장” 후 “요청 B(콜백)에서 검증”을 합니다. 그런데 앱이 레플리카 2개 이상이고 세션 스토리지가 인메모리라면, A는 파드 1에서 저장했는데 B는 파드 2로 가서 state를 못 찾아 실패합니다. 결과는 다시 로그인으로 리다이렉트입니다.

체크 포인트

  • 앱이 여러 인스턴스인데 sticky session이 없는가
  • 세션 스토리지가 Redis 같은 외부 저장소로 공유되지 않는가
  • Ingress가 콜백만 다른 파드로 라우팅하는 패턴이 있는가

Redis 세션 스토어 예시(Express)

import session from 'express-session'
import RedisStore from 'connect-redis'
import { createClient } from 'redis'

const redisClient = createClient({ url: process.env.REDIS_URL })
await redisClient.connect()

app.use(session({
  store: new RedisStore({ client: redisClient }),
  secret: process.env.SESSION_SECRET,
  resave: false,
  saveUninitialized: false,
}))

Kubernetes 환경에서 간헐적으로만 발생한다면 이 원인이 매우 유력합니다.

원인 5) 시간 동기화 문제로 토큰이 “이미 만료” 또는 “아직 유효하지 않음”

JWT는 iat, nbf, exp 같은 시간 클레임에 민감합니다. 앱 서버 시간과 Keycloak 서버 시간이 어긋나면 토큰 검증이 실패하고, 앱은 인증 실패로 처리해 다시 로그인으로 보냅니다.

체크 포인트

  • Keycloak 노드와 애플리케이션 노드의 시간이 NTP로 동기화되어 있는가
  • 컨테이너 런타임/노드의 시간 드리프트가 있는가
  • 라이브러리 검증 옵션에서 clock skew 허용이 너무 엄격한가

시간 드리프트는 JWT 401, 간헐적 인증 실패로도 나타납니다. 아래 글의 “시계 오차” 접근이 동일하게 적용됩니다.

원인 6) state / nonce 검증 실패(PKCE 포함)로 콜백이 거부됨

OIDC 표준 플로우에서 앱은 statenonce로 CSRF/재전송을 방지합니다. 저장/검증이 어긋나면 콜백 처리가 실패하고, 프레임워크는 대개 “다시 로그인”으로 처리해 루프처럼 보입니다.

특히 아래 상황에서 잘 터집니다.

  • 콜백 요청이 다른 도메인/스킴으로 들어와 쿠키가 누락
  • 멀티 레플리카에서 세션 공유 안 됨(원인 4와 결합)
  • PKCE에서 code_verifier가 유실됨
  • 브라우저 확장/프라이버시 설정이 스토리지를 제한

디버깅 팁

  • 앱 로그에서 state mismatch, nonce mismatch, invalid_grant를 찾기
  • 네트워크 탭에서 콜백 요청에 쿠키가 포함되는지 확인
  • Keycloak 이벤트 로그에서 LOGIN_ERROR 유형 확인

원인 7) Keycloak 클라이언트 설정(Access Type, Web Origins, Root URL) 오류

Keycloak의 클라이언트 설정이 애매하게 맞아도 “로그인은 되는데 앱으로 돌아오면 다시 로그인”이 발생할 수 있습니다.

체크 포인트

  • Clients -> (client) -> Settings에서
    • Root URL, Home URL이 실제 앱 URL과 맞는가
    • Valid Redirect URIs가 과하게 좁거나, 반대로 환경별 URL이 누락되지 않았는가
    • Web Origins가 CORS 요구사항에 맞는가(특히 SPA)
  • SPA라면 Web Origins에 앱 오리진을 넣거나 필요 시 * 대신 명시적으로 관리

Web Origins 문제는 보통 콘솔에 CORS 에러가 보이면서 토큰 교환/유저정보 호출이 실패하고, 그 결과 앱이 로그인 상태를 확정하지 못해 루프가 됩니다.

빠른 확인 순서(현장용 체크리스트)

  1. 브라우저 개발자도구 Network에서 콜백 URL이 정확한지 확인(스킴/호스트/경로)
  2. 콜백 요청에 세션 쿠키가 포함되는지 확인(SameSite/Secure)
  3. 멀티 레플리카면 세션 공유 여부 또는 sticky session 확인
  4. 프록시 헤더 X-Forwarded-Proto 설정과 앱의 trust proxy 설정 확인
  5. Keycloak 이벤트 로그에서 LOGIN_ERROR 및 원인 메시지 확인
  6. 앱에서 토큰 검증 실패 로그(특히 시간 관련) 확인

재현 가능한 최소 예제: Keycloak OIDC + Express

아래는 openid-client로 Authorization Code Flow를 처리하는 매우 단순화된 예시입니다. 핵심은 “콜백에서 세션에 토큰을 저장하고, 이후 요청에서 세션을 읽어 인증 상태를 유지”하는 구조를 확인하는 것입니다.

import express from 'express'
import session from 'express-session'
import { Issuer, generators } from 'openid-client'

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,
    sameSite: 'none',
  },
}))

const issuer = await Issuer.discover(process.env.OIDC_ISSUER)
const client = new issuer.Client({
  client_id: process.env.OIDC_CLIENT_ID,
  client_secret: process.env.OIDC_CLIENT_SECRET,
  redirect_uris: [process.env.OIDC_REDIRECT_URI],
  response_types: ['code'],
})

app.get('/login', (req, res) => {
  const codeVerifier = generators.codeVerifier()
  const codeChallenge = generators.codeChallenge(codeVerifier)

  req.session.codeVerifier = codeVerifier

  const url = client.authorizationUrl({
    scope: 'openid profile email',
    code_challenge: codeChallenge,
    code_challenge_method: 'S256',
  })
  res.redirect(url)
})

app.get('/callback', async (req, res, next) => {
  try {
    const params = client.callbackParams(req)
    const tokenSet = await client.callback(
      process.env.OIDC_REDIRECT_URI,
      params,
      { code_verifier: req.session.codeVerifier }
    )

    req.session.tokenSet = tokenSet
    res.redirect('/')
  } catch (e) {
    next(e)
  }
})

app.get('/', (req, res) => {
  if (!req.session.tokenSet) return res.redirect('/login')
  res.type('text').send('logged in')
})

app.listen(3000)

위 코드에서 무한 리다이렉트가 난다면, 거의 항상 다음 중 하나입니다.

  • OIDC_REDIRECT_URI가 실제 접근 URL과 불일치
  • /callback 요청에 세션 쿠키가 안 실림(SameSite/Secure/프록시)
  • 멀티 인스턴스에서 req.session.codeVerifier가 유실됨(세션 공유 필요)

마무리

Keycloak OAuth/OIDC 무한 리다이렉트는 “인증 자체”보다 “인증 결과를 앱이 유지/검증하는 과정”에서 깨지는 경우가 대부분입니다. 따라서 원인 분석은 redirect_uri 정합성, 프록시 헤더, 쿠키 정책, 세션 공유, 시간 동기화 순으로 좁혀가면 빠릅니다.

특히 운영 환경에서만 간헐적으로 발생한다면 원인 4(세션 공유)와 원인 5(시간 드리프트), 그리고 원인 2(프록시 헤더)가 결합되어 나타나는 경우가 많습니다. 로그와 네트워크 캡처로 “콜백 요청에 쿠키가 포함되는지”부터 확인하면 디버깅 시간이 크게 줄어듭니다.