On this page
OAuth 2.0 구현 패턴
백엔드 서비스에서 OAuth 2.0 플로우를 구현하기 위한 실용적 패턴
백엔드 커넥터 서비스에 Slack을 통합해야 했어요. OAuth 2.0 플로우는 이론적으로 간단해 보였어요 — 리다이렉트하고, 인가하고, 코드를 교환하고, 토큰을 저장하면 되니까요. 실제로는 불투명한 에러 메시지, CSRF 엣지 케이스, redirect URI 불일치를 디버깅하는 데 며칠을 보냈어요. 제가 최종적으로 정리한 패턴과 도중에 만난 함정들을 공유할게요.
왜 중요한가
백엔드가 서드파티 서비스에서 사용자를 대신해 작업해야 할 때 — Slack 메시지 보내기, Google Calendar 이벤트 읽기, GitHub 저장소 접근 등 — OAuth 2.0이 필요해요. 잘못 구현하면 토큰 유출, callback에 대한 CSRF 공격, 그리고 디버깅이 어려운 깨진 인증 플로우가 발생해요. Provider의 에러 응답이 의도적으로 모호하기 때문이에요.
어려웠던 점들
네 가지 문제가 RFC가 묘사하는 것보다 구현을 어렵게 만들었어요.
State 파라미터 CSRF 공격은 조용함. State 파라미터를 빠뜨려도 눈에 보이는 에러가 없어요. 플로우가 잘 동작해요. 취약점은 공격을 당할 때만 드러나서, 모든 기능 테스트를 통과하는 보안 취약 코드를 배포하기 쉬워요.
토큰 교환 에러가 모호함. oauth.v2.access가 {"ok": false, "error": "invalid_grant"}를 반환하면, 코드가 만료된 건지(10분 유효), redirect URI가 정확히 일치하지 않는 건지, 코드가 이미 사용된 건지 알 수 없어요. 추가 정보가 제공되지 않아요.
Redirect URI가 정확히 일치해야 함. Provider에 등록된 것과 요청에 보내는 것 사이에 후행 슬래시 하나만 달라도, 어떤 URI를 기대했는지 알려주지 않는 “redirect_uri_mismatch” 에러가 발생해요.
인메모리 state 저장소가 다중 레플리카에서 실패. 단순한 dict 기반 state 저장소는 개발 환경에서 잘 동작하지만, 다중 서버 레플리카가 있는 프로덕션에서는 조용히 실패해요. Callback이 state를 생성한 것과 다른 인스턴스에 도달할 수 있거든요.
Google이 RFC 9207 iss 파라미터를 문서 없이 추가. Google이 RFC 9207(Authorization Server Issuer Identification)에 따라 OAuth callback URI에 iss=https://accounts.google.com을 추가하기 시작했는데, 공식 문서도, .well-known/openid-configuration discovery document도, 공지도 업데이트하지 않았어요. iss claim은 ID token 검증에만 문서화되어 있고, callback URL 자체에 대해서는 문서가 없어요. 단계적 롤아웃으로 보이는데 — 일부 사용자에게는 포함되고 일부에게는 포함되지 않아서, 개발자 입장에서는 비결정적 버그처럼 보여요.
forbidNonWhitelisted ValidationPipe가 알 수 없는 OAuth callback 파라미터를 거부. NestJS의 forbidNonWhitelisted: true는 closed-world 가정(DTO에 명시적으로 선언되지 않은 모든 것을 거부)을 강제해요. 반면 OAuth 2.0은 forward-compatibility를 전제로 설계되었어요 — 클라이언트는 인식하지 못하는 응답 파라미터를 무시해야 해요(RFC 6749). 엄격한 DTO 검증과 OAuth를 함께 사용하면, upstream provider가 프로토콜을 변경할 때마다 DTO를 업데이트하는 유지보수 비용을 감수해야 해요. Google callback의 authuser, hd, prompt 필드도 같은 패턴의 문서화되지 않은 추가였어요.
Authorization Code Flow
Authorization Code flow는 서버사이드 애플리케이션의 표준이에요. 순서는 이래요:
B[Process] --> C[Output] `} /> ``` NOTE: Curly braces in mermaid code will be interpreted as Svelte expressions. Either escape them or avoid using braces in labels. REFERENCES: - MDsveX + Mermaid issue: https://github.com/pngwn/MDsveX/issues/737 - MDsveX plugin discussion: https://github.com/pngwn/MDsveX/discussions/354 - Svelte Mermaid approach: https://jamesjoy.site/posts/2023-06-26-svelte-mermaidjs -->State 파라미터로 CSRF 보호
항상 암호학적으로 안전한 state 파라미터를 생성하고 callback 처리 전에 검증하세요:
import secrets
# State 검증용 저장소 (다중 레플리카 프로덕션에서는 Redis 사용)
_oauth_states: dict[str, str] = {}
async def authorize():
# 암호학적으로 안전한 state 생성
state = secrets.token_urlsafe(32)
_oauth_states[state] = "slack"
params = {
"client_id": settings.SLACK_CLIENT_ID,
"scope": settings.SLACK_SCOPES,
"redirect_uri": settings.SLACK_REDIRECT_URI,
"state": state, # CSRF 보호
}
return {"authorize_url": f"https://slack.com/oauth/v2/authorize?{urlencode(params)}"}
async def callback(code: str, state: str):
# state를 먼저 검증
if state not in _oauth_states:
raise HTTPException(400, "Invalid state token")
del _oauth_states[state] # 일회용
# ... 코드를 토큰으로 교환 State 파라미터는 공격자가 피해자를 속여 공격자의 계정으로 인가하게 만드는 것을 방지해요. 이것 없이는 CSRF 공격으로 피해자의 세션을 공격자의 Slack workspace에 연결할 수 있어요. 인메모리 dict는 단일 인스턴스 개발용으로 동작하지만, 다중 레플리카 프로덕션에서는 Redis나 데이터베이스를 사용하세요.
토큰 교환
Authorization code를 access token으로 교환해요. 이건 서버사이드에서 이루어지므로 client secret이 브라우저에 노출되지 않아요:
async def exchange_code_for_token(code: str) -> dict:
token_url = "https://slack.com/api/oauth.v2.access"
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.post(
token_url,
data={
"client_id": settings.SLACK_CLIENT_ID,
"client_secret": settings.SLACK_CLIENT_SECRET,
"code": code,
"redirect_uri": settings.SLACK_REDIRECT_URI,
},
)
result = response.json()
if not result.get("ok"):
raise OAuthError(result.get("error", "unknown"))
return {
"access_token": result["access_token"],
"team_id": result["team"]["id"],
"scope": result["scope"],
} 토큰 교환의 redirect_uri는 인가 시 전송한 것과 정확히 일치해야 해요 — 후행 슬래시, 프로토콜, 포트까지 포함해서요.
안전한 토큰 저장
토큰은 데이터베이스나 코드가 아닌 시크릿 매니저에 저장하세요:
# Vault에 저장
def store_oauth_token(source_type: str, token_data: dict) -> bool:
client = VaultClient(vault_addr, k8s_role)
return client.write_secret(f"connectors/{source_type}", token_data)
# 요청별로 조회
def get_oauth_token(source_type: str) -> dict:
client = VaultClient(vault_addr, k8s_role)
return client.read_secret(f"connectors/{source_type}") 데이터베이스의 토큰은 SQL 인젝션 하나면 유출돼요. Vault 같은 시크릿 매니저는 저장 시 암호화, 감사 로깅, 자동 로테이션을 제공해요.
에러 처리
Provider 에러를 적절한 HTTP 응답으로 매핑해서 프론트엔드가 유용한 메시지를 보여줄 수 있게 하세요:
| OAuth 에러 | HTTP 상태 | 사용자 메시지 |
|---|---|---|
| invalid_client | 500 | 설정 오류 |
| invalid_grant | 400 | 인가가 만료됨, 다시 시도해 주세요 |
| access_denied | 403 | 접근이 거부됨 |
| invalid_scope | 400 | 잘못된 권한이 요청됨 |
| server_error | 503 | Provider가 일시적으로 사용 불가 |
설정 분리
민감한 설정과 비민감한 설정을 다른 곳에 보관하세요:
| 설정 | 저장소 | 민감도 |
|---|---|---|
| Client ID | ConfigMap/env | 낮음 |
| Client Secret | K8s Secret/Vault | 높음 |
| Redirect URI | ConfigMap/env | 낮음 |
| Scopes | ConfigMap/env | 낮음 |
| Access Token | Vault만 | 높음 |
베스트 프랙티스 체크리스트
- 토큰을 로그에 남기지 않기 — 로그에서 마스킹, 전체 값 출력 금지
- HTTPS 사용 — OAuth는 redirect URI에 TLS를 요구
- State 검증 — callback 처리 전에 state 확인
- 최소 scope — 필요한 것만 요청
- 토큰 로테이션 — provider가 지원하면 refresh flow 구현
- 안전한 저장소 — 데이터베이스가 아닌 Vault 등 사용
- 감사 추적 — OAuth 이벤트(연결, 해제, 에러) 로깅
- Callback DTO를 확장 가능하게 유지 — Provider는 예고 없이 파라미터를 추가해요(RFC 9207
iss,authuser,hd,prompt). 엄격한 DTO 검증(forbidNonWhitelisted)을 사용한다면, provider가 프로토콜을 변경할 때마다 DTO를 업데이트하는 유지보수 비용을 감수해야 해요
RFC 9207 — Authorization Server Issuer Identification
RFC 9207(2022년 3월 발행)은 다중 IdP OAuth 시나리오에서의 mix-up 공격을 다뤄요. 공격자가 클라이언트를 속여 authorization code를 잘못된 authorization server로 보내게 만들 수 있어요. iss 파라미터는 클라이언트가 어떤 서버가 응답을 발행했는지 검증할 수 있게 해줘요.
동작 방식
Authorization server가 callback redirect에 code와 state와 함께 iss를 추가해요:
https://your-app.com/callback?code=abc123&state=xyz&iss=https%3A%2F%2Faccounts.google.com Discovery Document 광고
RFC에 따르면, 서버는 .well-known/openid-configuration을 통해 지원 여부를 광고해요:
{
"authorization_response_iss_parameter_supported": true
} 2026-02-23 기준, Google은 일부 사용자에게 iss 파라미터를 보내면서도 discovery document에 이 필드를 포함하지 않고 있어요 — 단계적 롤아웃으로 보여요.
Closed-World vs Open-World 검증
이것은 두 가지 유효한 보안 철학 사이의 근본적인 긴장을 만들어요:
| 철학 | 접근법 | 트레이드오프 |
|---|---|---|
| Open-world (RFC 6749) | 알 수 없는 파라미터 무시 | Forward-compatible하지만, 악의적 주입을 놓칠 수 있음 |
Closed-world (NestJS forbidNonWhitelisted) | 알 수 없는 파라미터 거부 | Secure-by-default이지만, provider가 파라미터를 추가하면 깨짐 |
엄격한 DTO 검증과 OAuth를 함께 사용하면, upstream provider가 변경할 때마다 DTO를 업데이트하는 유지보수 비용을 감수해야 해요. Google callback의 authuser, hd, prompt, 그리고 이제 iss 필드 모두 스펙 변경이나 공지 없이 추가된 파라미터예요.
Google의 단계적 롤아웃 패턴
Google 규모의 시스템은 트래픽 분할(계정 해시, 리전, Workspace 조직 단위)을 사용해 변경사항을 점진적으로 롤아웃해요. 이것이 의미하는 바는:
- 같은 엔드포인트가 다른 사용자에게 동시에 다르게 동작해요
- 개발자가 자신의 계정에서 문제를 재현하지 못할 수 있어요
- 개발자 관점에서 버그가 비결정적이에요
- QA가 아니라 모니터링과 에러 로그(예: Sentry)가 첫 번째 신호예요
후속 조치: Issuer 검증
RFC 9207에 따르면, iss 파라미터를 지원하는 클라이언트는 예상하는 issuer와 일치하는지 검증해야 해요. Google의 경우:
if (dto.iss && dto.iss !== "https://accounts.google.com") {
throw new UnauthorizedException("Unexpected OAuth issuer");
} 이것은 mix-up 공격에 대한 심층 방어를 추가하지만, 핫픽스(검증 통과)와는 별도의 변경사항(동작 로직)으로 다뤄야 해요.
이 방식이 동작하는 이유
이 패턴들은 OAuth 구현의 세 가지 주요 실패 모드를 해결하기 때문에 동작해요: CSRF(state 파라미터), 토큰 유출(Vault 저장 + 로깅 금지), 설정 드리프트(민감/비민감 설정 분리). 각 패턴은 제가 실제로 만난 버그나 취약점에 대한 직접적인 대응이에요.
실무 팁
서드파티 API(Slack, Google, GitHub)와 통합하는 서버사이드 애플리케이션에서 위임된 사용자 인가가 필요할 때, 각 고객이 자체 계정을 연결하는 멀티테넌트 SaaS, 사용자를 대신해 동작하는 백엔드 커넥터 서비스에 이 패턴들을 사용하세요.
내부 서비스 간 통신에는 OAuth 2.0을 사용하지 마세요 (API 키나 mTLS를 대신 사용). 클라이언트와 서버를 모두 제어하는 단순한 API 키 시나리오에도 해당하지 않아요. 백엔드가 없는 클라이언트 전용 앱에도 적합하지 않아요 (대신 PKCE를 사용한 Authorization Code를 사용하세요). Webhook에도 해당하지 않아요 — OAuth 토큰 대신 webhook 서명을 검증하세요.