FastAPI 보안 가이드: 배포 전 점검할 설정 실수 7가지
FastAPI 보안 점검에서 자주 나오는 7가지를 정리했습니다. /docs 노출, Depends 인증 누락, 응답 모델 미지정, CORS, uvicorn 프록시 헤더, 의존성 취약점을 확인 명령과 수정 코드로 다룹니다.
FastAPI 서버를 배포하기 전에 먼저 확인할 것은 7가지입니다. ① 의존성 취약점 ② 운영 환경의 /docs·/openapi.json 노출 ③ 일부 라우터에만 걸린 Depends 인증 ④ 인증은 있지만 객체 단위 인가가 빠진 엔드포인트 ⑤ 응답 모델이 없어 민감 필드가 그대로 나가는 응답 ⑥ CORS allow_origins와 allow_credentials 조합 ⑦ uvicorn의 프록시 헤더 신뢰 범위. 대부분 코드 몇 줄이나 실행 옵션 하나로 고칠 수 있습니다.
FastAPI는 개발 경험을 우선한 기본값을 갖고 있습니다. 문서 페이지는 자동으로 열리고, 인증은 개발자가 엔드포인트마다 직접 연결해야 하며, 반환한 객체는 따로 지정하지 않으면 그대로 직렬화됩니다. 개발할 때는 편하지만, 운영에 그대로 가져가면 프레임워크 구조 때문에 생기는 빈틈이 됩니다. 실제 점검에서 자주 보는 항목을 OWASP API Security Top 10(2023)의 분류와 함께 확인 방법, 고친 코드 순서로 정리했습니다.
예시는 모두 가상의 코드이고 도메인은 example.com을 씁니다.
1. 의존성: FastAPI만이 아니라 Starlette·업로드 파서까지 본다
FastAPI는 얇은 계층입니다. 요청 파싱, 미들웨어, 응답 처리는 Starlette가 맡고, 폼과 파일 업로드는 python-multipart 같은 별도 패키지가 처리합니다. 그래서 fastapi 버전만 보면 하위 패키지의 취약점을 놓치기 쉽습니다.
# PyPA가 관리하는 pip-audit로 잠금 파일 기준 점검
pip install pip-audit
pip-audit -r requirements.txt
# CI에서는 취약점이 발견되면 종료 코드 1로 빌드를 멈춘다
pip-audit -r requirements.txt --strict
점검 기준:
requirements.txt나 lock 파일에 하위 의존성까지 버전이 고정돼 있는가 (fastapi>=0.100처럼 범위만 적어 두면 빌드할 때마다 결과가 달라집니다)- 운영 컨테이너 이미지 안의 실제 설치 버전과 lock 파일이 일치하는가 (
pip freeze로 비교) - 발견된 항목이 우리 코드에서 실제로 쓰는 기능(파일 업로드, 폼 파싱 등)과 관련 있는지 따져 우선순위를 정했는가
pip-audit 결과를 해석하고 우선순위를 정하는 방법은 오픈소스 라이브러리 취약점 점검: npm audit 이후 8가지에서 다뤘습니다. 언어는 달라도 판단 기준은 같습니다.
2. /docs·/openapi.json이 운영에서 열려 있다
FastAPI는 기본적으로 /docs(Swagger UI), /redoc, /openapi.json을 엽니다. OpenAPI 스키마에는 모든 경로와 파라미터, 요청·응답 구조가 들어 있습니다. 클라이언트에서 쓰지 않는 관리자용·내부용 엔드포인트도 함께 공개됩니다. OWASP API9(Improper Inventory Management)가 말하는 "의도하지 않게 드러난 API 목록"이 이렇게 생깁니다.
# 확인: 200이 나오면 노출 상태
for p in /docs /redoc /openapi.json; do
curl -s -o /dev/null -w "%{http_code} $p\n" "https://api.example.com$p"
done
취약한 코드
app = FastAPI(title="Example API", debug=True)
고친 코드
import os
from fastapi import FastAPI
# 환경 변수가 없으면 운영으로 간주한다 (안전한 쪽을 기본값으로)
IS_PROD = os.getenv("APP_ENV", "production") == "production"
app = FastAPI(
title="Example API",
debug=False,
openapi_url=None if IS_PROD else "/openapi.json",
)
공식 레퍼런스에 따르면 openapi_url=None으로 두면 스키마가 제공되지 않고 /docs와 /redoc도 함께 꺼집니다. docs_url=None만 설정하면 Swagger UI 화면만 사라지고 /openapi.json은 남는다는 점을 주의하세요. 점검에서 가장 자주 보는 실수입니다.
문서를 숨기는 것은 접근 통제가 아닙니다. 숨겨진 엔드포인트도 아래 3번처럼 인증이 걸려 있어야 합니다. 운영에서 문서가 꼭 필요하다면 별도 라우트를 만들어 관리자 인증 의존성 뒤에 두는 방식을 씁니다.
3. Depends 인증이 라우터 일부에만 걸려 있다
FastAPI의 인증은 미들웨어가 아니라 경로별 의존성으로 붙는 경우가 많습니다. 그래서 새 라우터 파일을 추가하거나, 엔드포인트를 복사해 만들면서 Depends(get_current_user)를 빠뜨려도 아무 경고가 없습니다.
취약한 구조
# routers/reports.py
router = APIRouter(prefix="/reports")
@router.get("/")
async def list_reports(user: User = Depends(get_current_user)):
...
@router.get("/export") # 나중에 추가하면서 인증 누락
async def export_reports():
...
고친 구조: 라우터 단위로 기본 인증을 건다
# 인증이 필요한 라우터는 생성 시점에 의존성을 건다
router = APIRouter(
prefix="/reports",
dependencies=[Depends(get_current_user)],
)
# main.py — 공개 라우터와 보호 라우터를 파일 단위로 분리
app.include_router(public.router) # /health, /auth/login 등
app.include_router(reports.router)
app.include_router(
admin.router,
prefix="/admin",
dependencies=[Depends(require_admin)],
)
공식 문서에서도 APIRouter(dependencies=[...])를 "경로 그룹 전체에 인증을 요구하는 용도"로 소개합니다. 원칙은 기본은 보호, 공개는 예외입니다. 공개 엔드포인트만 별도 라우터에 모으면 누락이 눈에 띕니다.
누락 여부를 테스트로 고정하기
라우트 목록을 순회하면서 인증 의존성이 없는 경로를 찾아내는 테스트를 CI에 넣어 두면, 새 엔드포인트가 추가될 때도 계속 지켜집니다.
# tests/test_auth_coverage.py
from fastapi.routing import APIRoute
from app.main import app
from app.deps import get_current_user
PUBLIC_PATHS = {"/health", "/auth/login", "/auth/refresh"}
def _has_dep(dependant, target) -> bool:
return any(d.call is target or _has_dep(d, target) for d in dependant.dependencies)
def test_all_routes_require_auth():
missing = [
f"{sorted(r.methods)} {r.path}"
for r in app.routes
if isinstance(r, APIRoute)
and r.path not in PUBLIC_PATHS
and not _has_dep(r.dependant, get_current_user)
]
assert not missing, f"인증 누락: {missing}"
require_admin이 내부에서 get_current_user를 의존하도록 만들어 두면 이 테스트 하나로 관리자 라우트까지 검사됩니다.
토큰 검증 함수 자체도 확인하세요. JWT를 쓴다면 허용 알고리즘을 고정하고 만료 클레임을 필수로 둡니다.
payload = jwt.decode(
token, SECRET_KEY,
algorithms=["HS256"], # 헤더의 alg를 그대로 믿지 않는다
options={"require": ["exp", "sub"]},
)
4. 인증은 있는데 "누구의 데이터인지"를 확인하지 않는다
OWASP API Top 10에서 1위인 API1(Broken Object Level Authorization)은 FastAPI에서도 똑같이 생깁니다. get_current_user로 로그인 여부만 확인하고, 경로 파라미터의 ID가 그 사용자의 것인지는 확인하지 않는 경우입니다.
취약한 코드
@router.get("/orders/{order_id}")
async def get_order(order_id: int, user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)):
return await db.get(Order, order_id) # 다른 사람 주문도 조회됨
고친 코드: 소유권 검사를 의존성으로 만든다
async def get_owned_order(
order_id: int,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> Order:
order = await db.scalar(
select(Order).where(Order.id == order_id, Order.user_id == user.id)
)
if order is None:
raise HTTPException(status_code=404) # 존재 여부도 드러내지 않는다
return order
@router.get("/orders/{order_id}", response_model=OrderOut)
async def get_order(order: Order = Depends(get_owned_order)):
return order
조회·수정·삭제 엔드포인트가 모두 같은 의존성을 쓰게 하면, 수정 API에서만 검사가 빠지는 일이 줄어듭니다.
5. 응답 모델을 지정하지 않아 민감 필드가 나간다
반환 타입이나 response_model이 없으면 FastAPI는 반환한 객체를 그대로 직렬화합니다. ORM 객체나 dict를 그대로 반환하면 password_hash, is_admin, 내부 메모, 다른 테이블의 관계 데이터까지 응답에 들어갈 수 있습니다. OWASP API3(Broken Object Property Level Authorization)에 해당합니다.
취약한 코드
@router.get("/me")
async def me(user: User = Depends(get_current_user)):
return user.__dict__ # 해시, 권한 플래그, 내부 필드 전부 노출
고친 코드
from pydantic import BaseModel, ConfigDict, EmailStr
class UserOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
email: EmailStr
nickname: str
class UserUpdate(BaseModel):
model_config = ConfigDict(extra="forbid") # 정의되지 않은 필드는 거부
nickname: str | None = None
@router.get("/me", response_model=UserOut)
async def me(user: User = Depends(get_current_user)):
return user
공식 문서에 따르면 FastAPI는 출력 모델에 선언되지 않은 필드를 걸러냅니다. 판단 기준은 이렇습니다.
response_model_exclude={"password_hash"}같은 제외 목록보다 출력 전용 모델(허용 목록)이 안전합니다. 컬럼이 새로 생겨도 자동으로 노출되지 않습니다.- 응답 모델은 출력만 거릅니다. 접근 권한 자체는 4번에서 막아야 합니다.
- 입력 쪽도 같은 원리입니다. 수정 API가
role,is_admin같은 필드를 받아 그대로 반영하면 권한 상승으로 이어집니다. 원리와 방어는 Mass Assignment 취약점: 회원정보 수정으로 권한 상승되는 원리에 정리했습니다.
6. CORS: allow_origins와 allow_credentials 조합
자주 보는 설정은 이렇습니다.
# 취약한 설정
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
FastAPI 공식 문서는 allow_credentials=True일 때 allow_origins, allow_methods, allow_headers에 "*"를 쓸 수 없고 명시적으로 지정해야 한다고 설명합니다. 와일드카드와 자격 증명 허용을 함께 두는 설정은 의도부터 모순되고, 결국 누군가 "동작하게" 하려고 출처 검증을 느슨하게 바꾸게 됩니다. allow_origin_regex=r"https://.*example\.com"처럼 정규식을 넓게 잡아 https://evil-example.com까지 허용하는 경우도 같은 문제입니다.
# 고친 설정
ALLOWED_ORIGINS = ["https://app.example.com", "https://admin.example.com"]
app.add_middleware(
CORSMiddleware,
allow_origins=ALLOWED_ORIGINS,
allow_credentials=True,
allow_methods=["GET", "POST", "PATCH", "DELETE"],
allow_headers=["Authorization", "Content-Type"],
)
# 확인: 허용하지 않은 Origin에 Access-Control-Allow-Origin이 돌아오면 안 된다
curl -s -I -H "Origin: https://untrusted.example.net" \
https://api.example.com/me | grep -i access-control
토큰을 Authorization 헤더로만 주고받고 쿠키를 쓰지 않는다면 allow_credentials는 꺼 두는 것이 맞습니다. CORS는 브라우저의 읽기 제한일 뿐 서버 인증을 대신하지 않는다는 점도 함께 기억해 두세요.
7. uvicorn 프록시 헤더를 모든 IP에서 신뢰한다
Nginx나 로드밸런서 뒤에서 실제 클라이언트 IP와 https 스킴을 받으려고 --forwarded-allow-ips="*"를 넣는 경우가 많습니다. FastAPI 문서도 이 옵션을 소개하면서, 와일드카드는 애플리케이션이 프록시를 통해서만 접근 가능할 때만 써야 한다고 단서를 붙입니다.
문제는 uvicorn 포트가 외부에 직접 열려 있을 때입니다. 이때는 누구나 X-Forwarded-For를 임의로 넣어 request.client.host를 바꿀 수 있습니다. IP 기반 로그인 제한, 관리자 IP 허용 목록, 감사 로그의 접속 IP가 모두 믿을 수 없게 됩니다.
# 확인 1: uvicorn이 어디에 바인딩돼 있는가 (0.0.0.0:8000이면 외부 노출 가능성)
ss -ltnp | grep 8000
docker ps --format '{{.Names}} {{.Ports}}'
# 확인 2: 실행 옵션
ps aux | grep -E "uvicorn|gunicorn" | grep -o "forwarded-allow-ips[^ ]*"
취약한 실행
uvicorn app.main:app --host 0.0.0.0 --port 8000 --forwarded-allow-ips="*"
고친 실행
# 프록시가 같은 호스트에 있으면 루프백에만 바인딩
uvicorn app.main:app --host 127.0.0.1 --port 8000 \
--proxy-headers --forwarded-allow-ips="127.0.0.1"
# 프록시가 별도 서버라면 그 IP(또는 내부 대역)만 지정
uvicorn app.main:app --host 10.0.1.20 --port 8000 \
--proxy-headers --forwarded-allow-ips="10.0.0.5"
uvicorn은 --forwarded-allow-ips를 지정하지 않으면 FORWARDED_ALLOW_IPS 환경 변수를 보고, 그것도 없으면 127.0.0.1만 신뢰합니다. Gunicorn 워커로 띄우는 경우에는 어느 쪽 설정이 실제로 적용되는지 꼭 확인하세요. 버전과 구성에 따라 기대와 다르게 동작했다는 보고가 있습니다. 컨테이너 환경이라면 ports: "8000:8000"으로 앱 포트를 호스트에 공개하지 말고, 프록시 컨테이너만 외부 포트를 갖게 하는 구성이 가장 확실합니다.
배포 전 한 장 체크리스트
| 항목 | 확인 방법 | 통과 기준 |
|---|---|---|
| 의존성 | pip-audit -r requirements.txt | 조치 안 된 항목은 사유를 기록 |
| 문서 노출 | curl /openapi.json | 운영에서 404 |
| 인증 누락 | 라우트 커버리지 테스트 | 공개 목록 외 0건 |
| 객체 인가 | 다른 계정 ID로 조회 테스트 | 404 또는 403 |
| 응답 필드 | 모든 라우트에 response_model/반환 타입 | 해시·권한 필드 미노출 |
| CORS | 허용 외 Origin 요청 | ACAO 헤더 없음 |
| 프록시 헤더 | 바인딩 주소·실행 옵션 | 앱 포트 외부 비공개, 신뢰 IP 명시 |
자주 묻는 질문
운영에서 /docs를 끄면 프런트엔드 협업이 불편하지 않나요?
스테이징에서만 켜거나, 빌드 단계에서 app.openapi() 결과를 JSON 파일로 뽑아 내부 저장소나 문서 도구로 공유하면 됩니다. 운영 서버가 스키마를 직접 제공할 이유는 거의 없습니다.
인증을 Depends 대신 미들웨어로 거는 게 더 안전한가요?
미들웨어는 "기본 보호"를 만들기 쉽지만, 경로 예외 처리를 문자열로 비교하다가 빈틈이 생기기 쉽고 사용자 객체를 핸들러에 넘기기도 번거롭습니다. 보통은 라우터 단위 dependencies에 커버리지 테스트를 더하는 방식이 FastAPI 구조에 잘 맞습니다.
pip-audit에 나온 항목은 전부 즉시 올려야 하나요?
우리 코드가 해당 기능을 쓰는지, 외부 입력이 그 경로에 닿는지 보고 우선순위를 정합니다. 업로드·폼 파싱·요청 처리 계층(Starlette 등)의 항목은 대체로 외부 입력에 바로 닿으므로 먼저 처리하는 편이 좋습니다.
정리
- FastAPI의 빈틈은 대부분 "개발 편의 기본값"을 운영에 그대로 가져가서 생깁니다. 문서 노출, 경로별 인증, 응답 직렬화가 대표적입니다.
- 인증은 라우터 단위로 기본 적용하고, 커버리지 테스트로 누락을 막고, 소유권 검사는 의존성으로 재사용하세요.
- 응답은 출력 전용 모델로 허용 목록만 내보내고, CORS와 프록시 헤더는 신뢰할 출처와 IP를 명시적으로 적으세요.
- 직접 점검하기 어렵다면 무료 분석 신청으로 현재 구성을 함께 살펴볼 수 있습니다.
참고 자료
- FastAPI Reference - FastAPI class (openapi_url, docs_url)
- FastAPI - Behind a Proxy
- FastAPI - CORS (Cross-Origin Resource Sharing)
- FastAPI - Response Model
- FastAPI - Bigger Applications (APIRouter dependencies)
- OWASP API Security Top 10 - 2023
- pip-audit (PyPI)
- uvicorn main.py (forwarded-allow-ips 기본값)
- uvicorn issue #589 - Gunicorn 워커에서 forwarded-allow-ips
함께 읽으면 좋은 글
문자 인증 보안 체크리스트: SMS 펌핑·인증 우회 막는 10가지
문자 인증 보안 점검 항목 10가지. SMS 펌핑 비용 폭탄을 막는 발송 제한, 인증번호 시도 횟수·만료, 클라이언트 응답 조작 우회, 번호 불일치까지 취약한 코드와 고친 코드로 정리했다.
오픈 리다이렉트 취약점: OAuth 토큰 탈취 경로와 검증법
오픈 리다이렉트 취약점이 피싱을 넘어 OAuth 인가 코드·토큰 탈취로 이어지는 원리, startsWith·정규식 검증이 우회되는 패턴, returnUrl을 허용 목록으로 검증하는 코드와 점검 체크리스트를 정리했습니다.
오픈소스 라이브러리 취약점 점검: npm audit 이후 8가지
오픈소스 라이브러리 취약점 점검을 경고 개수 줄이기가 아니라 위험 판단으로 바꾸는 법. 도달 가능성 판단, lockfile 관리, 버려진·타이포스쿼팅 패키지 확인, 컨테이너 이미지 점검까지 8가지 체크리스트로 정리했습니다.