title: "FastAPI에서 401 vs 403 — 인증/인가 설계와 표준 헤더 실무"
slug: fastapi-401-vs-403-authz-practical
series: "URL·TLS·SNI·필터링·DPI 실전 가이드"
author: EX Corp. Tech Team
summary: "401은 '누구인지 증명(인증) 실패/부재', 403은 '누군진 알지만 권한(인가) 없음'. RFC에 맞춘 WWW-Authenticate 헤더, OAuth2/JWT·Basic·API Key 예시, CORS/프리플라이트·스코프·예외처리까지 한 번에."
tags: [FastAPI, OAuth2, JWT, 401, 403, WWW-Authenticate, CORS, Security]
reading_time: "10~12분"
cover_image_suggestion: "요청 흐름 다이어그램: Client→Auth(401)→Authorize(403) 갈림길"
한눈에 핵심
- 401 Unauthorized: 인증 실패/부재. 클라이언트가 자격증명(토큰/헤더)을 보내지 않았거나 유효하지 않음. →
WWW-Authenticate 헤더 포함 필수.
- 403 Forbidden: 인가 실패. 클라이언트는 인증되었지만 권한/스코프가 부족.
- 실무에서는 401→로그인/토큰 재발급 유도, 403→권한 요청/문의 유도가 자연스러운 UX.
1) 가장 작은 예시: Bearer 토큰 (OAuth2PasswordBearer)
from fastapi import FastAPI, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from typing import Optional
app = FastAPI()
oauth2 = OAuth2PasswordBearer(tokenUrl="token") # /token에서 토큰 발급한다고 가정
def verify_token(token: str = Depends(oauth2)) -> str:
# 여기에 실제 JWT 서명/만료/오디언스 검증 로직
if not token or token == "bad":
# 401은 WWW-Authenticate 헤더가 필수
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Not authenticated",
headers={"WWW-Authenticate": "Bearer"},
)
return token
@app.get("/me")
def me(_: str = Depends(verify_token)):
return {"user": "alice"}
WWW-Authenticate 규약(Tip)
- Bearer 토큰일 때는 최소
WWW-Authenticate: Bearer.
- 세밀한 오류는 RFC6750 스타일 권장:
WWW-Authenticate: Bearer realm="api", error="invalid_token", error_description="The access token is expired"
2) 403을 주는 타이밍: “인증 OK, 권한 부족”
from fastapi import Security
from fastapi.security import OAuth2, SecurityScopes
class DummyOAuth2(OAuth2):
def __init__(self):
super().__init__(flows={}) # 설명 생략
oauth2_scoped = DummyOAuth2()
def require_scopes(
security_scopes: SecurityScopes,
token: str = Depends(verify_token)
):
# 토큰의 scope 목록을 가정
token_scopes = {"read"} # 예시
needed = set(security_scopes.scopes)
if not needed.issubset(token_scopes):
raise HTTPException(
status_code=403,
detail=f"Not enough permissions. Required: {needed}",
)
@app.get("/admin", dependencies=[Security(require_scopes, scopes=["admin"])])
def admin_panel():
return {"ok": True}
- 위 예시는 인증(verify_token)은 통과, 스코프 검사에서 실패 → 403.
3) Basic 인증 / API Key 패턴
Basic
from fastapi.security import HTTPBasic, HTTPBasicCredentials
import secrets
basic = HTTPBasic()
@app.get("/basic")
def basic_area(creds: HTTPBasicCredentials = Depends(basic)):
if not (creds.username == "admin" and secrets.compare_digest(creds.password, "pass")):
raise HTTPException(
status_code=401,
detail="Invalid credentials",
headers={"WWW-Authenticate": 'Basic realm="secure"'},
)
return {"ok": True}
API Key (헤더/쿼리/쿠키)
from fastapi.security import APIKeyHeader
api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False)
def require_api_key(key: Optional[str] = Depends(api_key_header)):
if key != "SECRET123":
# Bearer/Basic와 달리 명시 스킴은 없지만 401 처리 자체는 동일
raise HTTPException(status_code=401, detail="Invalid API key")
return key
@app.get("/data")
def data(_: str = Depends(require_api_key)):
return {"items": [1,2,3]}
4) 401 vs 403 설계 체크리스트
| 상황 | 올바른 상태코드 | 이유/UX |
|---|
| 토큰 없음/형식 오류/서명 오류/만료 | 401 (+ WWW-Authenticate) | “먼저 로그인/재인증 하세요” |
권한 스코프 부족(예: admin 필요) | 403 | “당신은 사용자지만 접근 권한이 없습니다” |
| 리소스 존재를 숨기고 싶을 때 | 404 또는 403 | 보안 목표에 따라 선택(리소스 누설 방지) |
| 프리플라이트(OPTIONS) 막히는 경우 | 인증 건너뛰기 | CORS 프리플라이트는 인증 없이 허용 권장 |
5) CORS/프리플라이트 주의: OPTIONS는 인증 의존성에서 빼기
from fastapi.middleware.cors import CORSMiddleware
app.add_middleware(
CORSMiddleware,
allow_origins=["https://web.example"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# 인증 의존성이 모든 메서드/경로에 강제되면 OPTIONS가 401/403으로 막힐 수 있음.
# 라우팅/미들웨어 순서와 예외 처리를 점검하세요.
6) 공통 예외 핸들러(로깅/헤더 일관화)
from fastapi.responses import JSONResponse
from fastapi.requests import Request
@app.exception_handler(HTTPException)
async def http_exc_handler(request: Request, exc: HTTPException):
headers = getattr(exc, "headers", None) or {}
# 예: 인증 실패 공통 메시지·추적ID 추가
body = {"detail": exc.detail, "trace_id": request.headers.get("X-Trace-Id")}
return JSONResponse(status_code=exc.status_code, content=body, headers=headers)
7) 보안 UX 팁
- 401 응답 바디는 “로그인 필요/토큰 만료”를 짧게; 자세한 사유는 서버 로그에.
- 403 응답 바디는 필요한 권한/스코프명을 명시(내부 코드를 포함하면 지원 효율↑).
- OpenAPI UI(
/docs): 우상단 Authorize 버튼을 통해 개발자·QA가 바로 토큰 주입 테스트 가능.
- 리프레시 토큰: 액세스 토큰 만료 시 401 → 클라이언트가 토큰 재발급 플로우로 자연스럽게 이동.
8) 자주 겪는 함정
WWW-Authenticate 누락된 401
→ 일부 클라이언트/브라우저는 재인증 플로우를 트리거하지 못함.
- 프리플라이트 차단
→ 인증 의존성에 걸려 OPTIONS가 401/403 → 브라우저 콘솔에 CORS 에러만 보임. CORS 미들웨어/라우팅 순서 재점검.
- 스코프 검사를 401로 처리
→ 사용자 입장에선 “로그아웃 된 건가?” 혼동. 스코프 부족은 403이 맞음.
- 민감 정보가 detail에 노출
→ “서명이 유효하지 않은 토큰” 정도만. 내부 검증 사유는 로그에 남겨요.
9) 간단 테스트 (pytest + httpx)
# test_auth.py
import pytest
from fastapi.testclient import TestClient
from main import app # 위 예제 코드가 들어있는 모듈
client = TestClient(app)
def test_me_401():
r = client.get("/me")
assert r.status_code == 401
assert r.headers.get("www-authenticate") == "Bearer"
def test_me_200():
r = client.get("/me", headers={"Authorization": "Bearer good"})
assert r.status_code == 200
def test_admin_403():
r = client.get("/admin", headers={"Authorization": "Bearer good"})
assert r.status_code == 403
10) TL;DR
- 401: 인증 부재/실패.
WWW-Authenticate 포함. 로그인/재발급 유도.
- 403: 인증은 됐으나 권한 없음. 필요한 스코프/역할을 명확히.
- CORS/프리플라이트, 예외 핸들러, 스코프 검사 흐름을 초기에 표준화하면 운영 비용이 크게 준다.