- Published on
OAuth PKCE 우회? redirect_uri 검증 실전
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서드파티 로그인(OAuth 2.0 / OIDC)을 붙이면 보통 “PKCE까지 했으니 안전하다”는 기대가 생깁니다. 하지만 실제 사고 사례를 보면 PKCE 자체가 깨졌다기보다, redirect_uri 검증이 허술해서 인증 코드가 공격자에게 흘러가는 흐름이 반복됩니다.
이 글은 “PKCE 우회”라는 자극적인 표현이 왜 나오는지(대부분은 오해 또는 구현 실수), 그리고 서버/인가 서버(Authorization Server)에서 redirect_uri를 어떻게 검증해야 실전에서 안 터지는지를 다룹니다.
참고: 토큰/쿠키/캐시 문제로 401이 간헐적으로 발생하는 운영 이슈는 원인이 전혀 다른 축입니다. 인증 장애 트러블슈팅은 Spring Boot JWT 인증 401 간헐 발생 원인 7가지, 프록시 단의 시계 오차는 Nginx에서 JWT 401 간헐 발생 - 시계오차 해결도 함께 보시면 좋습니다.
PKCE가 막아주는 것과 못 막는 것
PKCE가 막는 위협: 코드 가로채기(Code Interception)
PKCE는 원래 **공개 클라이언트(모바일/SPA)**에서 “인가 코드가 중간에 탈취되더라도 토큰 교환을 못 하게” 하는 장치입니다.
- 클라이언트가
code_verifier(비밀값)를 만들고 - 그 해시인
code_challenge를 인가 요청에 싣고 - 토큰 교환 시
code_verifier를 제시해야만 토큰을 발급
즉, 공격자가 인가 코드만 훔쳐도 code_verifier가 없으면 토큰을 못 받습니다.
PKCE가 못 막는 위협: 잘못된 redirect로 “정상 사용자 세션”을 공격자에게 연결
문제는 여기서 시작됩니다.
- 공격자가 **자기 도메인으로 향하는
redirect_uri**를 인가 서버가 받아주게 만들면 - 인가 코드는 공격자에게 도착합니다.
PKCE가 있으면 공격자는 토큰 교환이 어렵지 않냐고요? 맞습니다. 그런데 실전에서는 다음 실수들이 겹치면서 PKCE의 보호막이 무력화됩니다.
- 인가 서버가
redirect_uri를 느슨하게 검증해서 공격자 도메인을 허용 - 클라이언트가
code_verifier를 안전하지 않은 저장소에 두거나(브라우저 확장, XSS, 로그) - 혹은 서버가 PKCE를 “선택”으로 처리(일부 클라이언트만)하거나
- OIDC에서
state/nonce검증이 부실하거나 - 토큰 교환 엔드포인트가 CORS/클라이언트 인증 정책이 엉켜서 우회 경로가 생김
결론: PKCE는 중요하지만, redirect_uri 검증이 허술하면 전체 플로우가 무너집니다.
공격 시나리오: redirect_uri 검증이 느슨할 때 무슨 일이 생기나
아래는 “PKCE 우회”로 오해받는 대표적인 흐름입니다.
- 공격자가 인가 요청을 만든다
client_id는 정상 앱의 것을 쓰고redirect_uri만 공격자 도메인으로 바꾼다- 인가 서버가 이를 허용하면, 사용자가 로그인/동의를 마친 뒤 인가 코드가 공격자에게 전달된다
인가 요청 예시(개념):
GET /authorize?
response_type=code&
client_id=client123&
redirect_uri=https%3A%2F%2Fevil.example%2Fcb&
scope=openid%20profile&
state=...&
code_challenge=...&
code_challenge_method=S256
여기서 핵심은 인가 서버가 redirect_uri를 등록된 값과 “완전 일치”로 비교하지 않으면 사고가 난다는 점입니다.
redirect_uri 검증에서 자주 터지는 실수 8가지
실무에서 실제로 자주 보는 패턴을 정리합니다.
1) Prefix 매칭(startsWith)으로 허용
등록값이 https://app.example/callback인데, https://app.example/callback.evil.example 같은 변종을 통과시키는 유형입니다.
- 문자열 prefix 매칭은 URL 보안에서 거의 항상 금지에 가깝습니다.
2) 서브도메인 와일드카드 허용(*.example.com)
서브도메인을 “모두 우리 것”이라고 가정하면 위험합니다.
- 마케팅/테스트/레거시 시스템이 서브도메인을 쉽게 만들 수 있음
- 서브도메인 takeover(미사용 DNS 레코드, S3/Heroku/GitHub Pages 등)로 탈취 가능
3) redirect_uri에 쿼리 파라미터를 자유롭게 허용
예: 등록값이 https://app.example/cb인데 요청은 https://app.example/cb?next=https://evil.example.
- 이 자체는 “동일한 redirect_uri”일 수 있지만, 앱의 콜백 핸들러가
next를 그대로 리다이렉트하면 오픈 리다이렉트가 됩니다. - 즉, 인가 서버의 문제 + 클라이언트의 문제(오픈 리다이렉트)가 결합되어 사고가 커집니다.
4) URL 정규화(canonicalization) 차이를 악용
https://app.example/%2e%2e/cb 처럼 인코딩/디코딩, 경로 정규화 차이를 이용해 필터를 우회합니다.
- 인가 서버는 “문자열 비교”를 하고
- 실제 브라우저/리버스 프록시는 “정규화 후 이동”을 하면
- 검증과 실제 이동 결과가 달라집니다.
5) http 허용(특히 운영)
운영에서 http 콜백을 허용하면 네트워크 중간자 공격에 취약합니다.
- 로컬 개발용
http://localhost예외는 가능하지만, 운영 도메인에서는 금지 권장
6) 포트/스킴 비교 누락
https://app.example:444/cb 같은 변종을 허용하거나, 스킴을 무시하면 http로 떨어질 수 있습니다.
7) Fragment(#...)를 허용한다고 착각
브라우저는 #fragment를 서버로 보내지 않습니다.
- 인가 서버가 fragment를 포함한 등록을 허용하면 운영자가 “검증이 된다”고 착각하기 쉽고
- 클라이언트 측 라우팅과 섞이면 예상치 못한 리다이렉트가 생깁니다.
8) 다중 redirect 파라미터 처리
요청에 redirect_uri가 두 번 들어오거나(redirect_uri=a&redirect_uri=b), 프레임워크가 첫 번째/마지막 값을 다르게 취하는 경우가 있습니다.
- 인가 서버/게이트웨이/프록시 계층마다 파싱 규칙이 다르면 우회가 생깁니다.
실전 원칙: redirect_uri는 “완전 일치(Exact match)”가 기본
정답에 가까운 운영 원칙은 단순합니다.
- 사전에 등록된
redirect_uri목록만 허용 - 요청의
redirect_uri는 문자열 완전 일치로 비교 - 스킴/호스트/포트/패스/쿼리까지 포함해 등록
- 운영은
https만 허용(예외는http://localhost정도)
여기서 “쿼리까지 완전 일치”를 하면 불편하지 않냐는 질문이 나옵니다. 맞습니다.
- OIDC/OAuth 콜백은 보통 쿼리가 필요 없습니다(인가 서버가
code,state를 붙여줌). - 앱이 추가 파라미터를 원한다면,
state에 넣어 서명/암호화해서 전달하는 쪽이 안전합니다.
구현 예시 1: Node.js(Express)에서 redirect_uri 완전 일치 검증
아래 코드는 인가 서버(또는 BFF)가 redirect_uri를 받을 때의 검증 예시입니다.
import express from 'express';
const app = express();
// 클라이언트별로 등록된 redirect_uri를 “완전한 문자열”로 저장
const registeredRedirectUris = new Map([
['client123', new Set([
'https://app.example.com/oauth/callback',
'https://app.example.com/oauth/callback/google'
])],
]);
function isAllowedRedirectUri(clientId, redirectUri) {
if (!redirectUri || typeof redirectUri !== 'string') return false;
// 1) 파싱이 되는지 확인
let url;
try {
url = new URL(redirectUri);
} catch {
return false;
}
// 2) 운영 정책(예: https 강제, localhost만 http 허용)
const isLocalhost = url.hostname === 'localhost' || url.hostname === '127.0.0.1';
if (url.protocol !== 'https:' && !(isLocalhost && url.protocol === 'http:')) return false;
// 3) fragment 금지(의미도 없고 혼선을 만듦)
if (url.hash && url.hash.length > 0) return false;
// 4) 완전 일치 비교
const allowed = registeredRedirectUris.get(clientId);
if (!allowed) return false;
return allowed.has(redirectUri);
}
app.get('/authorize', (req, res) => {
const clientId = req.query.client_id;
const redirectUri = req.query.redirect_uri;
if (!isAllowedRedirectUri(clientId, redirectUri)) {
return res.status(400).send('invalid_redirect_uri');
}
// TODO: state, PKCE(code_challenge), scope 등 검증 후 로그인/동의 진행
res.send('ok');
});
app.listen(3000);
핵심은 startsWith 같은 “부분 일치”를 절대 쓰지 않는 것입니다.
구현 예시 2: Spring Authorization Server에서 redirect_uri 정책 잡기
스프링 기반이라면 (직접 AS를 운영하는 경우) RegisteredClient에 redirectUri를 명시하고, 기본 검증을 최대한 그대로 활용하는 것이 안전합니다.
RegisteredClient registeredClient = RegisteredClient.withId(UUID.randomUUID().toString())
.clientId("client123")
.clientAuthenticationMethod(ClientAuthenticationMethod.NONE) // public client 예시
.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
.redirectUri("https://app.example.com/oauth/callback")
.redirectUri("https://app.example.com/oauth/callback/google")
.scope(OidcScopes.OPENID)
.scope("profile")
.clientSettings(ClientSettings.builder()
.requireProofKey(true) // PKCE 강제
.requireAuthorizationConsent(true)
.build())
.build();
여기서도 운영 팁은 동일합니다.
- redirect는 가능한 한 “고정된 경로”만
- 여러 소셜을 붙이더라도 콜백을 하나로 통일하고, 내부에서 provider를 구분하는 방식이 검증면에서 유리
state를 “리다이렉트 전달용”으로 쓸 때의 안전한 패턴
redirect_uri를 고정하면, 로그인 후 원래 가려던 페이지(예: /settings)를 어디에 담을지가 문제입니다. 이때 state를 쓰되, 무결성 보호가 필수입니다.
- 단순히
state=/settings처럼 넣으면 변조 가능 - 서버가
state를 서명하거나, 서버 세션에 저장하고 랜덤 키만state로 내려야 합니다.
서명 기반 예시(개념, Node.js):
import crypto from 'crypto';
const STATE_SECRET = process.env.STATE_SECRET;
function signState(payloadJson) {
const payload = Buffer.from(payloadJson).toString('base64url');
const sig = crypto.createHmac('sha256', STATE_SECRET).update(payload).digest('base64url');
return `${payload}.${sig}`;
}
function verifyState(state) {
const parts = state.split('.');
if (parts.length !== 2) return null;
const [payload, sig] = parts;
const expected = crypto.createHmac('sha256', STATE_SECRET).update(payload).digest('base64url');
if (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) return null;
return JSON.parse(Buffer.from(payload, 'base64url').toString('utf8'));
}
이렇게 하면 “원래 이동할 경로”를 state에 넣더라도 변조를 막을 수 있습니다.
PKCE까지 포함한 체크리스트(실전용)
redirect_uri만 맞춰도 사고의 상당수를 줄이지만, 실제 운영에서는 아래를 같이 묶어서 점검해야 합니다.
인가 요청(Authorization Request)
redirect_uri는 등록된 값과 완전 일치state필수, 재사용 방지(원타임)- OIDC면
nonce필수(특히 implicit/hybrid는 더 엄격) code_challenge_method는S256만 허용 권장
토큰 교환(Token Request)
- PKCE 필수(
code_verifier없으면 거절) redirect_uri를 토큰 교환에도 포함시키고, 인가 요청의 값과 일치 검증(서버 구현에 따라 필수)- 코드 재사용 방지(1회용)
클라이언트(앱) 콜백 핸들러
- 콜백 엔드포인트에서 오픈 리다이렉트 금지
state검증 실패 시 즉시 중단code/state를 로그에 남기지 않기(특히 접근 로그, APM)
운영에서 자주 겪는 “이상 증상”과 원인 힌트
특정 환경에서만 로그인 후 엉뚱한 페이지로 이동한다
- 앱이
state를 신뢰하고 그대로 리다이렉트하는 오픈 리다이렉트 가능성 - 프록시가
X-Forwarded-Proto를 잘못 설정해http로 생성되는 경우
- 앱이
간헐적으로만 콜백이 실패한다
redirect_uri가 환경별로 미세하게 다름(슬래시 유무, 포트, 트레일링 슬래시)- 로드밸런서/게이트웨이에서 URL 정규화가 달라짐
인프라 계층에서 502/504나 헬스체크로 인증 플로우가 흔들리는 케이스도 있으니, 네트워크/프록시 이슈가 의심되면 AWS ALB 502·504 원인 - NLB·타임아웃·헬스체크처럼 계층별로 분리해서 보는 게 좋습니다.
결론: “PKCE 우회”라는 말의 대부분은 redirect_uri 검증 실패다
- PKCE는 인가 코드 탈취에 강력하지만,
redirect_uri검증이 느슨하면 공격자에게 코드가 전달되는 길이 열립니다. - 실전에서 가장 안전한 선택은
redirect_uri를 사전 등록 + 완전 일치로 검증하는 것입니다. - “로그인 후 원래 페이지로 돌아가기” 같은 가변 요구사항은
redirect_uri로 해결하지 말고, 서명된state또는 서버 세션으로 해결하세요.
이 원칙만 지켜도 OAuth/OIDC 사고의 큰 비중을 차지하는 리다이렉트 계열 취약점(오픈 리다이렉트, 서브도메인 변종, 정규화 우회)을 상당 부분 제거할 수 있습니다.