[카테고리:] 미분류

  • 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/프리플라이트, 예외 핸들러, 스코프 검사 흐름을 초기에 표준화하면 운영 비용이 크게 준다.