Published on

OAuth 콜백 400/redirect_uri 불일치 10분 진단

Authors

서드파티 로그인(OAuth 2.0 / OIDC)을 붙이다 보면, 가장 자주 그리고 가장 빨리 팀의 시간을 갉아먹는 에러가 있습니다.

  • 콜백에서 400 Bad Request
  • 에러 메시지에 redirect_uri mismatch 혹은 The redirect URI is not registered

문제는 단순해 보이지만, 실제 원인은 프록시/HTTPS 종단/인코딩/환경 설정 등으로 갈라져서 디버깅이 길어지기 쉽습니다. 이 글은 “10분 안에” 원인을 좁히는 순서로 정리합니다.

> 운영 환경에서 프록시/Ingress가 개입되는 경우가 많습니다. ALB/Ingress 이슈로 요청이 변형되는 패턴은 EKS ALB Ingress 502 target timeout 원인·해결에서도 자주 등장하는데, OAuth에서도 비슷하게 원본 스킴/호스트가 바뀌는 문제가 핵심입니다.

0) 증상 정의: “불일치”는 무엇과 무엇이 다른가

redirect_uri mismatch는 보통 아래 두 값이 문자열로 완전 일치하지 않아서 발생합니다.

  1. Authorization Request에 포함된 redirect_uri
  2. IdP(구글/깃허브/카카오/애플/Keycloak/Auth0 등)에 등록된 Redirect URI

대부분의 IdP는 다음을 엄격히 봅니다.

  • 스킴(http vs https)
  • 호스트(app.example.com vs example.com)
  • 포트(:3000, :443 포함 여부)
  • 경로(/oauth/callback vs /oauth/callback/)
  • 쿼리스트링 포함 여부(허용/불허는 IdP마다 다름)
  • URL 인코딩 결과(특히 : / ? &)

즉, “대충 비슷한 URL”은 통하지 않습니다.

1) 1분: 실제로 IdP에 전달된 redirect_uri를 캡처

브라우저에서 가장 빠른 방법

  • 크롬 DevTools → Network → 로그인 버튼 클릭 → IdP로 이동하는 요청(대개 authorize) 선택
  • Query String Parameters에서 redirect_uri 확인

혹은 주소창에 보이는 authorize URL에서 redirect_uri= 뒤를 그대로 복사합니다.

서버 로그에서 확인(백엔드가 authorize URL을 생성하는 경우)

백엔드가 authorize URL을 만들어 프론트에 전달한다면, 해당 시점에 로그를 찍어두는 게 가장 확실합니다.

// Node/Express 예시: authorize URL 생성 직후
console.log('authorize redirect_uri:', redirectUri);
console.log('authorize url:', authorizeUrl);

여기서 얻어야 하는 것:

  • 최종 redirect_uri 문자열(인코딩 전/후)
  • 실제 authorize URL 전체

2) 2분: IdP 콘솔에 등록된 Redirect URI와 “문자열 비교”

이 단계에서 70%는 끝납니다.

  • 등록된 값과 완전히 동일한지 비교
  • 특히 아래 4개를 눈으로 체크
    • http/https
    • www 포함 여부
    • trailing slash(/callback vs /callback/)
    • 포트 포함 여부

흔한 함정 1: trailing slash

  • 등록: https://app.example.com/oauth/callback
  • 실제: https://app.example.com/oauth/callback/

IdP는 보통 다른 URL로 취급합니다.

흔한 함정 2: 포트

  • 로컬: http://localhost:3000/oauth/callback
  • 등록: http://localhost/oauth/callback

로컬 개발 중이면 포트까지 맞춰야 합니다.

3) 2분: HTTPS 종단(프록시/Ingress/ALB) 때문에 스킴이 바뀌는지 확인

운영에서 가장 흔한 케이스입니다.

  • 외부: https://app.example.com (ALB/Ingress에서 TLS 종료)
  • 내부 앱: http://app:8080

이때 앱이 요청을 보고 redirect_uri를 조립하면 http://...로 만들어 버립니다. 결과적으로 IdP에 등록된 https://...와 불일치.

체크 포인트

  • 앱이 redirect_uri요청 기반으로 동적 생성하는가?
  • X-Forwarded-Proto, X-Forwarded-Host를 신뢰하도록 설정했는가?

Express에서의 대표 해결(프록시 신뢰)

import express from 'express';

const app = express();

// ALB/Nginx/Ingress 뒤라면 필수인 경우가 많음
app.set('trust proxy', true);

app.get('/login', (req, res) => {
  // req.protocol이 https로 잡히도록
  const redirectUri = `${req.protocol}://${req.get('host')}/oauth/callback`;
  res.send({ redirectUri });
});

Nginx/Ingress에서 X-Forwarded-* 헤더가 제대로 전달되는지도 확인해야 합니다.

> Ingress/ALB가 개입된 장애는 재현이 어려워 로그/헤더 확인이 핵심입니다. 네트워크 계층에서 요청이 어떻게 바뀌는지 추적하는 접근은 systemd 서비스 재시작 루프 10분 진단 가이드처럼 “관측→가설→검증” 루틴이 가장 빠릅니다.

4) 2분: redirect_uri 인코딩/디코딩 이중 적용 여부

redirect_uri는 보통 authorize URL의 쿼리 파라미터로 들어가므로 URL 인코딩이 필요합니다. 문제는 다음 두 가지가 섞일 때 발생합니다.

  • 한 번 인코딩해야 하는데 안 함
  • 이미 인코딩된 값을 다시 인코딩(이중 인코딩)

정상 예시

  • 원문: https://app.example.com/oauth/callback
  • 인코딩: https%3A%2F%2Fapp.example.com%2Foauth%2Fcallback

이중 인코딩 예시(문제)

  • 1차 인코딩: https%3A%2F%2Fapp.example.com%2Foauth%2Fcallback
  • 2차 인코딩: https%253A%252F%252Fapp.example.com%252Foauth%252Fcallback

IdP는 보통 최종적으로 디코딩한 값이 등록 값과 일치해야 하는데, 구현에 따라 불일치로 떨어집니다.

Node에서 안전한 조립 패턴

const base = 'https://idp.example.com/oauth/authorize';
const redirectUri = 'https://app.example.com/oauth/callback';

const url = new URL(base);
url.searchParams.set('client_id', process.env.CLIENT_ID);
url.searchParams.set('response_type', 'code');
url.searchParams.set('redirect_uri', redirectUri); // URLSearchParams가 인코딩 처리
url.searchParams.set('scope', 'openid profile email');

console.log(url.toString());
  • 문자열 더하기로 ...?redirect_uri=${encodeURIComponent(redirectUri)}를 하다가
  • 나중에 다른 레이어에서 또 인코딩하는 실수가 잦습니다.

5) 2분: “정확히 같은 redirect_uri”를 토큰 교환 단계에서도 쓰는지 확인

일부 OAuth 클라이언트/서버 구현은 토큰 교환(token endpoint) 요청에서도 redirect_uri를 함께 보내며, 이 값이 authorize 때 사용한 값과 완전 일치해야 합니다.

  • authorize 요청: redirect_uri=A
  • token 요청: redirect_uri=B

이면 IdP가 invalid_grant 혹은 redirect_uri mismatch로 거절합니다.

예: token 교환에서 redirect_uri 누락/불일치

curl -X POST https://idp.example.com/oauth/token \
  -H 'Content-Type: application/x-www-form-urlencoded' \
  -d 'grant_type=authorization_code' \
  -d 'client_id=...' \
  -d 'client_secret=...' \
  -d 'code=AUTH_CODE' \
  -d 'redirect_uri=https://app.example.com/oauth/callback'

진단 팁: authorize 단계에서 사용한 redirect_uri를 서버 세션/상태에 저장해 두고, token 교환에서도 동일 값을 재사용하면 안전합니다.

6) 1분: 환경별 설정(DEV/STG/PROD) 혼선 제거

redirect_uri mismatch는 “설정이 틀렸다”기보다 “환경이 섞였다”인 경우가 많습니다.

  • 프론트는 PROD 도메인으로 authorize 요청
  • 백엔드는 STG redirect_uri로 token 교환
  • IdP에는 PROD만 등록

체크리스트

  • CLIENT_ID / CLIENT_SECRET이 환경에 맞는가
  • 프론트의 NEXT_PUBLIC_BASE_URL(또는 앱 base URL)이 올바른가
  • 백엔드의 APP_BASE_URL(또는 callback base)가 올바른가

예: 환경변수로 base URL을 고정(동적 조립 최소화)

// TypeScript 예시
const APP_BASE_URL = process.env.APP_BASE_URL; // 예: https://app.example.com
if (!APP_BASE_URL) throw new Error('APP_BASE_URL is required');

export const REDIRECT_URI = `${APP_BASE_URL}/oauth/callback`;

동적 조립(요청 기반)은 프록시/헤더 신뢰 문제를 계속 유발하므로, 가능하면 환경변수로 고정하는 편이 운영에서 덜 아픕니다.

7) 자주 나오는 케이스별 “즉시 수정” 처방

케이스 A) 로컬 개발: http/https 혼재

  • 로컬은 http://localhost:3000/callback
  • IdP 설정에서 로컬 redirect URI를 별도로 추가
  • 가능하면 로컬용 OAuth 앱(클라이언트)을 따로 만들기

케이스 B) 서브도메인/WWW 차이

  • example.comwww.example.com은 다릅니다.
  • 한 쪽으로 통일하거나, IdP에 둘 다 등록(가능한 경우)

케이스 C) 경로가 라우터에서 리다이렉트됨

예: /oauth/callback로 와야 하는데 앱이 /login/callback로 301/302를 걸어버림.

  • IdP는 리다이렉트 체인을 따라가 주지 않는 경우가 많습니다.
  • 콜백 엔드포인트는 “정확한 경로”로 직접 처리하세요.

케이스 D) 쿼리스트링 포함 redirect_uri

어떤 IdP는 redirect_uri에 쿼리를 허용하지 않거나, 등록 시 쿼리까지 동일해야 합니다.

  • 가능하면 콜백 URL은 쿼리 없이 고정
  • 추가 정보는 state 파라미터에 넣고 서명/검증

8) 10분 내 결론을 내기 위한 최소 로그/관측 포인트

운영에서 재현이 어렵다면, 아래 3가지만 남겨도 원인 파악 속도가 급상승합니다.

  1. authorize 요청 생성 시점의 redirect_uri (인코딩 전/후)
  2. 콜백 엔드포인트로 들어온 요청의 Host, X-Forwarded-Proto, X-Forwarded-Host
  3. token 교환 시점의 redirect_uri

Express 콜백에서 헤더를 빠르게 덤프하는 예:

app.get('/oauth/callback', (req, res) => {
  console.log('callback host:', req.get('host'));
  console.log('x-forwarded-proto:', req.get('x-forwarded-proto'));
  console.log('x-forwarded-host:', req.get('x-forwarded-host'));
  console.log('full url:', `${req.protocol}://${req.get('host')}${req.originalUrl}`);

  res.send('ok');
});

마무리: redirect_uri는 “문자열 동일성” 게임이다

redirect_uri mismatch는 결국 IdP에 등록된 문자열실제로 전송된 문자열이 1바이트라도 다르면 실패하는 문제입니다. 그래서 감으로 수정하기보다,

  • 실제 전송값 캡처 →
  • 등록값과 문자 단위 비교 →
  • 프록시/인코딩/환경 혼선을 제거

이 순서로 가면 10분 안에 끝낼 확률이 높습니다.

운영에서 특히 많이 터지는 지점은 “TLS 종료 뒤에서 앱이 http로 인식”하는 케이스이니, X-Forwarded-*와 프레임워크의 프록시 신뢰 설정을 먼저 의심해 보세요.