FastAPI에서 401 vs 403

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) 자주 겪는 함정

  1. WWW-Authenticate 누락된 401
    → 일부 클라이언트/브라우저는 재인증 플로우를 트리거하지 못함.
  2. 프리플라이트 차단
    → 인증 의존성에 걸려 OPTIONS가 401/403 → 브라우저 콘솔에 CORS 에러만 보임. CORS 미들웨어/라우팅 순서 재점검.
  3. 스코프 검사를 401로 처리
    → 사용자 입장에선 “로그아웃 된 건가?” 혼동. 스코프 부족은 403이 맞음.
  4. 민감 정보가 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/프리플라이트, 예외 핸들러, 스코프 검사 흐름을 초기에 표준화하면 운영 비용이 크게 준다.

코멘트

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다