Why file uploads matter in production
File uploads are a deceptive feature. They look simple—a form, a button, a file sent to your server—but they're one of the most common security and reliability weak points we see in production systems. Whether you're building an e-commerce platform where users upload product images, a CRM storing customer documents, or a SaaS tool accepting CSV imports, handling uploads poorly can expose your infrastructure to denial-of-service attacks, disk exhaustion, and data breaches. This guide covers file uploads in FastAPI and Django: a step-by-step guide to accepting files safely, with practical patterns we've used across trading platforms, ride-hailing backends, and travel applications.
Limit size and validate type
The first line of defense is rejecting files that don't meet your requirements. Most attacks start with an oversized payload or an unexpected file type. You need to enforce limits both at the framework level and in your business logic.
In FastAPI, use the UploadFile class with explicit size validation:
from fastapi import FastAPI, UploadFile, File, HTTPException
from fastapi.responses import JSONResponse
app = FastAPI()
ALLOWED_TYPES = {"image/jpeg", "image/png", "application/pdf"}
MAX_FILE_SIZE = 5 * 1024 * 1024 # 5 MB
@app.post("/upload")
async def upload_file(file: UploadFile = File(...)):
# Check MIME type
if file.content_type not in ALLOWED_TYPES:
raise HTTPException(
status_code=400,
detail=f"File type {file.content_type} not allowed"
)
# Read and check size
contents = await file.read()
if len(contents) > MAX_FILE_SIZE:
raise HTTPException(
status_code=413,
detail=f"File exceeds {MAX_FILE_SIZE} bytes"
)
# Process file
return {"filename": file.filename, "size": len(contents)}
In Django, handle uploads in your view with similar guards:
from django.http import JsonResponse
from django.views.decorators.http import require_http_methods
from django.core.exceptions import ValidationError
ALLOWED_TYPES = {"image/jpeg", "image/png", "application/pdf"}
MAX_FILE_SIZE = 5 * 1024 * 1024
@require_http_methods(["POST"])
def upload_file(request):
if "file" not in request.FILES:
return JsonResponse({"error": "No file provided"}, status=400)
uploaded_file = request.FILES["file"]
# Check MIME type
if uploaded_file.content_type not in ALLOWED_TYPES:
return JsonResponse(
{"error": f"File type {uploaded_file.content_type} not allowed"},
status=400
)
# Check size
if uploaded_file.size > MAX_FILE_SIZE:
return JsonResponse(
{"error": f"File exceeds {MAX_FILE_SIZE} bytes"},
status=413
)
# Process file
return JsonResponse({"filename": uploaded_file.name, "size": uploaded_file.size})
Note: MIME type checking alone isn't cryptographic security. A determined attacker can spoof Content-Type headers. For sensitive file types (executables, archives), add magic number validation or use a library like python-magic to inspect file signatures.
Store in S3, not on the app server
Storing files on your application server's filesystem is a path to operational pain. Your server disk fills up, backups become massive, horizontal scaling becomes impossible, and disaster recovery becomes a nightmare. We've seen this mistake crash production databases and force emergency scaling events at 2 AM.
Use object storage—AWS S3, Google Cloud Storage, or DigitalOcean Spaces. It's designed for this, scales infinitely, and integrates cleanly with your web servers.
In FastAPI with boto3:
import boto3
from botocore.exceptions import ClientError
s3_client = boto3.client(
"s3",
aws_access_key_id="YOUR_KEY",
aws_secret_access_key="YOUR_SECRET",
region_name="us-east-1"
)
BUCKET_NAME = "your-bucket"
@app.post("/upload")
async def upload_file(file: UploadFile = File(...)):
if file.content_type not in ALLOWED_TYPES:
raise HTTPException(status_code=400, detail="Invalid file type")
contents = await file.read()
if len(contents) > MAX_FILE_SIZE:
raise HTTPException(status_code=413, detail="File too large")
try:
s3_client.put_object(
Bucket=BUCKET_NAME,
Key=f"uploads/{file.filename}",
Body=contents,
ContentType=file.content_type
)
except ClientError as e:
raise HTTPException(status_code=500, detail="Upload failed")
return {"filename": file.filename, "url": f"s3://{BUCKET_NAME}/uploads/{file.filename}"}
In Django, use django-storages for cleaner abstraction:
from storages.backends.s3boto3 import S3Boto3Storage
class MediaStorage(S3Boto3Storage):
location = "media"
file_overwrite = False
# In your models.py
from django.db import models
class Document(models.Model):
file = models.FileField(storage=MediaStorage())
uploaded_at = models.DateTimeField(auto_now_add=True)
Then configure in settings.py:
AWS_STORAGE_BUCKET_NAME = "your-bucket"
AWS_S3_REGION_NAME = "us-east-1"
AWS_S3_CUSTOM_DOMAIN = f"{AWS_STORAGE_BUCKET_NAME}.s3.amazonaws.com"
Your application now delegates storage concerns to a managed service. Your disk stays clean, your deployment remains stateless, and your team can sleep.
Generate unique names
Never trust the original filename. Attackers can exploit path traversal (uploading ../../../etc/passwd), filename collisions can overwrite legitimate files, and filesystem limitations vary by OS. Generate unique identifiers server-side.
Use UUIDs or a combination of timestamp and random hash:
import uuid
from pathlib import Path
def generate_safe_filename(original_filename: str) -> str:
# Extract extension only
ext = Path(original_filename).suffix
# Generate UUID-based name
unique_name = f"{uuid.uuid4()}{ext}"
return unique_name
@app.post("/upload")
async def upload_file(file: UploadFile = File(...)):
if file.content_type not in ALLOWED_TYPES:
raise HTTPException(status_code=400, detail="Invalid file type")
contents = await file.read()
if len(contents) > MAX_FILE_SIZE:
raise HTTPException(status_code=413, detail="File too large")
safe_filename = generate_safe_filename(file.filename)
try:
s3_client.put_object(
Bucket=BUCKET_NAME,
Key=f"uploads/{safe_filename}",
Body=contents,
ContentType=file.content_type,
Metadata={"original_name": file.filename}
)
except ClientError as e:
raise HTTPException(status_code=500, detail="Upload failed")
return {
"id": safe_filename,
"original_filename": file.filename,
"url": f"https://{AWS_S3_CUSTOM_DOMAIN}/uploads/{safe_filename}"
}
Store the mapping between the user-friendly name and the safe name in your database. This gives you audit trails, garbage collection capabilities, and protection against accidental overwrites.
Use pre-signed URLs for large files
For files larger than a few megabytes, don't stream the entire upload through your application server. Your server becomes a bottleneck; you consume RAM and connection resources unnecessarily. Instead, generate a pre-signed URL that allows direct browser-to-S3 uploads.
In FastAPI:
from datetime import timedelta
@app.post("/upload-url")
async def get_upload_url(filename: str, content_type: str):
if content_type not in ALLOWED_TYPES:
raise HTTPException(status_code=400, detail="Invalid content type")
safe_filename = generate_safe_filename(filename)
try:
presigned_url = s3_client.generate_presigned_url(
"put_object",
Params={
"Bucket": BUCKET_NAME,
"Key": f"uploads/{safe_filename}",
"ContentType": content_type
},
ExpiresIn=3600 # URL valid for 1 hour
)
except ClientError as e:
raise HTTPException(status_code=500, detail="Failed to generate URL")
return {
"url": presigned_url,
"filename": safe_filename,
"expires_in": 3600
}
Your frontend then uploads directly to S3 using the pre-signed URL. This pattern is crucial for platforms handling high-volume uploads or large media files. We've used it on travel booking systems uploading hotel photos and ride-hailing platforms handling driver document verification.
Add a webhook or S3 event notification to trigger processing once the file lands in your bucket, keeping your upload path fast and your server free to handle other requests.
Common mistakes
- Checking only Content-Type without validating file content: Attackers can lie about MIME types. Use magic number validation for truly sensitive files.
- Storing uploads on the app server and syncing to S3 later: This adds complexity and failure modes. Upload directly to S3 from day one.
- Not setting expiration on pre-signed URLs: A leaked URL could be exploited indefinitely. Always use short expirations (1–2 hours).
- Storing files in a predictable directory structure: Avoid timestamped or sequential paths. Use UUIDs to prevent enumeration attacks.
- Forgetting to set appropriate S3 bucket policies: Ensure your bucket is not publicly readable unless intentional. Use CloudFront with OAI for public access patterns.
Key takeaways
- Validate file size and MIME type at the framework level; reject early and loudly.
- Store files in S3 or equivalent object storage, never on your app server's filesystem.
- Generate unique, unpredictable filenames server-side; store the mapping in your database.
- Use pre-signed URLs for large files to offload bandwidth and avoid server memory pressure.
- Set appropriate bucket policies, enable versioning for critical files, and monitor upload patterns for abuse.






Comments
Loading comments…