Implementing Secure File Uploads in FastAPI: Practical Patterns for UploadFile, Size Limits, Virus Scanning, S3-Compatible Storage, and Presigned URLs
Summary (The Big Picture First)
- For file uploads in FastAPI, using
UploadFileis memory-efficient and forms a solid practical foundation. - The keys to safety are: size limits, validating MIME/content (not just extensions), separating storage destinations, sanitizing filenames, authorization checks, and logging/auditing.
- In production, it’s more stable if the API server does not continuously serve file bodies. A common pattern is to store files in S3-compatible storage and have the API focus on issuing presigned URLs.
- Heavy processing such as image thumbnail generation or PDF conversion should be handed off to background jobs after upload to keep responses fast.
- For testing, a minimal set covering success, oversize, invalid MIME, unauthorized, and expired presigned URL reduces incidents.
Who Benefits From This
- Solo developers / learners: You want profile images or attachments, but aren’t sure how far you should go with security measures.
- Small teams: Admin attachments and CSV uploads are increasing, and concerns like size limits and extension spoofing are emerging.
- SaaS teams: In multi-instance operations, file delivery becomes heavy; you want storage separation, presigned URLs, and solid audit logs.
Accessibility Notes
- Headings are granular and steps are numbered, making information easy to find.
- Technical terms are briefly explained on first use, and consistent wording is used to reduce confusion.
- Code is split into small blocks, with minimal comments.
- The target level is roughly AA.
1. Common Failure Patterns: Know the “Dangerous Shapes” First
File uploads are easy to make “work,” but many pitfalls can cause real-world incidents:
- Accepting files with no size limit, exhausting memory or disk
- Relying on extensions only and allowing files whose contents differ (e.g., an executable disguised as
.jpg) - Saving user-provided filenames as-is, causing path traversal, mojibake, or unintended overwrites
- Letting the API server also handle file delivery, choking CPU/network during peaks
- Vague authorization (who can view which file), leading to exposure of other users’ attachments
The goal of this article is to build a practical “template” that avoids these pitfalls one by one.
2. Minimal Upload: Why Use UploadFile
In FastAPI, UploadFile is the easiest and safest default. You can accept bytes, but large files can pressure memory.
# app/routers/uploads.py
from fastapi import APIRouter, UploadFile, File
router = APIRouter(prefix="/uploads", tags=["uploads"])
@router.post("")
async def upload(file: UploadFile = File(...)):
return {"filename": file.filename, "content_type": file.content_type}
Two important notes:
- Don’t trust
file.filename(it’s OK as a display reference, but don’t use it as a stored name) - Don’t fully trust
file.content_typeeither (clients can spoof it)
To be safe, validation and a storage strategy matter.
3. Size Limits: Defense in Two Layers (App + Proxy)
Size limits are most effective when you reject oversized requests at the “entrance,” before they reach your app.
3.1 Limiting at a Reverse Proxy (Nginx, etc.)
If you use Nginx in production, set client_max_body_size first. This prevents huge requests from reaching the app.
3.2 Limiting in the App (Stop While Reading)
Proxy settings can vary by environment, so enforcing limits in the app is a good backup. Read the UploadFile stream in chunks; once it exceeds a threshold, return an error.
# app/services/upload_validator.py
from fastapi import HTTPException, status, UploadFile
MAX_BYTES = 10 * 1024 * 1024 # 10MB
async def enforce_size_limit(file: UploadFile) -> bytes:
total = 0
chunks: list[bytes] = []
while True:
chunk = await file.read(1024 * 1024) # 1MB at a time
if not chunk:
break
total += len(chunk)
if total > MAX_BYTES:
raise HTTPException(
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
detail="file too large",
)
chunks.append(chunk)
# This example joins at the end to show the concept.
# In production, streaming writes to disk/storage is recommended.
return b"".join(chunks)
This example is for illustrating the concept. In production, a streaming-save design (next section) is safer.
4. Filename Sanitization: Generate the Stored Name Server-Side
Do not use a user-provided filename as the stored name. Generate a unique ID on the server.
- Display name: user’s filename (store in DB if needed)
- Actual stored name: safe key generated by UUID, etc.
For example, use an object key like uploads/{user_id}/{uuid}.{ext}.
# app/services/upload_naming.py
import uuid
from pathlib import Path
ALLOWED_EXT = {"png", "jpg", "jpeg", "pdf"}
def safe_object_key(user_id: int, original_filename: str) -> str:
ext = Path(original_filename).suffix.lower().lstrip(".")
if ext not in ALLOWED_EXT:
ext = "bin"
uid = uuid.uuid4().hex
return f"uploads/{user_id}/{uid}.{ext}"
The key point: treat extensions as “hints” and validate the actual content later.
5. Type Validation: Check the Actual Content, Not Just Extensions
As a security baseline, don’t decide allow/deny solely by extension.
5.1 Minimum MIME Check
Checking content_type is useful as a first gate, but it can be spoofed. Use it as “the first checkpoint.”
# app/services/content_type.py
from fastapi import HTTPException, status, UploadFile
ALLOWED_CT = {
"image/png",
"image/jpeg",
"application/pdf",
}
def enforce_content_type(file: UploadFile) -> None:
if file.content_type not in ALLOWED_CT:
raise HTTPException(
status_code=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE,
detail="unsupported content type",
)
5.2 Simple Content Inspection (Lightweight)
Ideally you’d verify file signatures (magic numbers) using robust libraries, but choices vary by environment. Here’s a minimal “roll-your-own” example:
# app/services/magic_check.py
from fastapi import HTTPException, status
def looks_like_png(head: bytes) -> bool:
return head.startswith(b"\x89PNG\r\n\x1a\n")
def looks_like_jpeg(head: bytes) -> bool:
return head.startswith(b"\xff\xd8\xff")
def looks_like_pdf(head: bytes) -> bool:
return head.startswith(b"%PDF")
def enforce_magic(head: bytes, content_type: str) -> None:
ok = False
if content_type == "image/png":
ok = looks_like_png(head)
elif content_type == "image/jpeg":
ok = looks_like_jpeg(head)
elif content_type == "application/pdf":
ok = looks_like_pdf(head)
if not ok:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="file content does not match content-type",
)
In production, many teams use more robust detection libraries. Regardless, the posture “don’t trust extensions alone” is the main point.
6. Storage Strategy: Choosing Between Local Storage and S3-Compatible Storage
6.1 Local Storage (Learning / Small Scale)
If you run a single server and files are small, local storage can work. But with multiple instances, sharing becomes difficult.
# app/services/local_storage.py
from pathlib import Path
from fastapi import UploadFile
BASE = Path("./data")
async def save_to_local(path: str, file: UploadFile) -> None:
full = BASE / path
full.parent.mkdir(parents=True, exist_ok=True)
with full.open("wb") as f:
while True:
chunk = await file.read(1024 * 1024)
if not chunk:
break
f.write(chunk)
6.2 S3-Compatible Storage (The Production Standard)
In production, separating storage from the API server improves stability. Store in S3-compatible storage, and let the API focus on “accepting uploads” and “issuing access URLs.”
There are two typical approaches:
- Upload via API: API receives the file and forwards it to storage
- Direct upload: API issues a presigned URL; the browser uploads directly to storage via PUT (lighter load)
As operations scale, #2 tends to be stronger. The next section introduces presigned URL patterns.
7. Presigned URLs: Remove the API Server from the Delivery Path
A presigned URL is a “time-limited, restricted access URL.”
- Upload URL (PUT)
- Download URL (GET)
Separating these makes authorization clearer.
7.1 High-Level Design
POST /files/presign-upload: returns the upload destination URL and keyGET /files/{file_id}/download: returns a download URL- Store in DB:
file_id,owner_id,object_key,content_type,size,original_name, etc.
Example FastAPI response model (shape only):
from pydantic import BaseModel
class PresignUploadResponse(BaseModel):
file_id: str
object_key: str
upload_url: str
expires_in: int
7.2 Security Checklist
- Keep URL expiry short (a few minutes to ~15 minutes)
- When issuing download URLs, always check ownership/permissions
- If possible, include conditions like
Content-Typeand size (storage-side constraints)
SDK details differ across clouds, so focusing on API design and check items is often the best first step.
8. Virus Scanning and Sanitization: A Practical Middle Ground
If you accept files, the chance of receiving a malicious file is never zero.
- The more publicly exposed your service is, the more valuable virus scanning/sandboxing becomes.
- But trying to do everything perfectly from day one can be heavy—phased rollout is realistic.
A staged plan:
- Size/type limits + safe stored names (mandatory)
- For images, re-encode (don’t serve uploads “as-is”)
- Run scanning (e.g., ClamAV) asynchronously
- Use a quarantine bucket; only move “OK” files into the main bucket
The key trick: don’t publish immediately after upload. For public attachments, a quarantine flow materially increases safety.
9. Post-Processing (Thumbnails, etc.): Hand Off to Background Jobs
Thumbnail generation, PDF previews, OCR, and similar tasks are better kept out of synchronous responses.
Example flow:
- Accept upload → store
status=processingin DB - Enqueue
file_id - Worker processes → update to
status=ready, store derived file keys - Frontend switches UI based on
status
This fits well with patterns like Celery/Redis.
10. API Implementation Example: The Minimal Safe Upload
Here’s a minimal local-save example that combines the pieces so far. In production, swap this for a storage implementation.
# app/routers/uploads.py
from fastapi import APIRouter, UploadFile, File, Depends
from app.services.content_type import enforce_content_type
from app.services.magic_check import enforce_magic
from app.services.upload_naming import safe_object_key
from app.services.local_storage import save_to_local
router = APIRouter(prefix="/uploads", tags=["uploads"])
def get_current_user_id() -> int:
# In reality, derive from JWT, etc.
return 1
@router.post("")
async def upload_file(
file: UploadFile = File(...),
user_id: int = Depends(get_current_user_id),
):
enforce_content_type(file)
head = await file.read(16)
enforce_magic(head, file.content_type)
# Since we read the head, we must write it during save
object_key = safe_object_key(user_id, file.filename)
# Write the head, then stream the rest
# local_storage.save_to_local reads from file.read(), so it continues from the current pointer
# We separately write the head here
from pathlib import Path
base = Path("./data")
full = base / object_key
full.parent.mkdir(parents=True, exist_ok=True)
with full.open("wb") as f:
f.write(head)
while True:
chunk = await file.read(1024 * 1024)
if not chunk:
break
f.write(chunk)
return {
"file_id": object_key, # Ideally return a DB-generated ID
"content_type": file.content_type,
}
What this example already protects:
- First-gate
content_typevalidation - Simple signature (magic number) checks
- Server-generated stored names
- Streaming writes
Add size limits, DB ownership, authorized downloads, and presigned URLs to reach a production shape.
11. Download Design: Authorization Matters Most
Downloads are often more sensitive than uploads—“who can see it” is critical.
- Look up
owner_idfrom DB byfile_id - Check current user matches
owner_idor is an admin - For S3-compatible storage, issue a presigned GET URL and return it
- For local storage, return
FileResponse—but note increased API server load
If “anyone who knows the file ID can download,” incidents are likely. Protect this with tests.
12. Minimal Test Set: Five Tests That Prevent Incidents
The most effective initial tests:
- Success: allowed type + small size → 200/201
- Oversize: exceeds limit → 413
- Invalid MIME: disallowed content-type → 415
- Spoofing: allowed content-type but magic mismatch → 400
- Authorization: cannot download someone else’s
file_id→ 403/404
Few tests can still give strong protection if the intent is clear.
13. Adoption Roadmap (Small Steps Are Fine)
- Build minimal uploads using
UploadFile - Add size limits, safe stored names, and content-type restrictions
- Add content inspection (simple is fine; ensure spoofing doesn’t pass)
- Manage file metadata + ownership in DB and build authorized downloads
- Move to S3-compatible storage and split upload/download via presigned URLs
- Move image/PDF processing to background jobs (quarantine, thumbnails)
- Add audit logs, alerts, and metrics for capacity/failure rates
References
- FastAPI
- Starlette (FastAPI’s foundation)
- AWS (helpful for design thinking)
- Security perspective (useful as checklists)
Conclusion
- FastAPI file uploads work well around
UploadFile, improving memory efficiency and extensibility. - In practice, the essentials are: size limits, content inspection, safe stored naming, and owner-based authorization.
- In production, avoid making the API server a file-body distributor; separate storage and use presigned URLs for stable operations.
- Offload heavy post-processing (thumbnails/scanning) to background jobs so the API stays light and operations stay smooth.
Good candidates for follow-up articles are “multi-tenant design (tenant boundaries and authorization)” and “audit log design (recording who did what).” If you want, I can continue on those themes.
