Backend Development
Hands-onIntermediate 40 min· Production-ready APIs · 13 of 20

File uploads and object storage

Accept file uploads safely in FastAPI with type, size and content checks, then learn why production apps use object storage like S3 and presigned URLs so files go straight from the browser to storage.

Brew & Bean wants a photo for every menu item. That means your API must accept files from users (or the owner), check them, store them somewhere safe, and serve them back. File handling is a classic place for bugs and security holes, and it also raises a design question: should the files live on your server's disk at all?

What you'll build: first an upload endpoint in FastAPI that checks type and size and saves the file under a safe name; then the production approach, object storage (like Amazon S3) with presigned URLs, so files go straight from the browser to storage without passing through your server.

Prerequisites: Python 3.10+, basic FastAPI, curl. The S3 part is read-and-understand unless you have an AWS or other S3-compatible account; the local part runs on any laptop.

How a file upload works

A browser or app sends a file in an HTTP request using the multipart/form-data format: the body is split into parts, one per field, and a file part carries the filename, a content type and the raw bytes. FastAPI needs the python-multipart package to read this format, and gives you an UploadFile object. Unlike JSON, a file can be large, so you must limit its size, and unlike JSON fields, the declared type and filename are written by the sender, so they must not be trusted.

Upload to local disk (the learning version)

Setup

  1. 1Create a folder files-demo, make and activate a virtual environment.
  2. 2Install: pip install fastapi uvicorn python-multipart.
  3. 3Save the code below as main.py and run uvicorn main:app --reload.
  4. 4Upload a small PNG or JPEG using the curl command after the code.
Terminal
pip install fastapi uvicorn python-multipart
main.py
import re
import uuid
from pathlib import Path

from fastapi import FastAPI, File, HTTPException, UploadFile
from fastapi.responses import FileResponse

UPLOAD_DIR = Path("uploads")
UPLOAD_DIR.mkdir(exist_ok=True)

MAX_BYTES = 2 * 1024 * 1024  # 2 MB
ALLOWED_TYPES = {"image/jpeg": ".jpg", "image/png": ".png", "image/webp": ".webp"}

app = FastAPI(title="Brew & Bean photo uploads")


def looks_like_image(ext: str, head: bytes) -> bool:
    """Check the first bytes of the file, because the declared type can be faked."""
    if ext == ".jpg":
        return head.startswith(b"\xff\xd8\xff")
    if ext == ".png":
        return head.startswith(b"\x89PNG\r\n\x1a\n")
    if ext == ".webp":
        return head[:4] == b"RIFF" and head[8:12] == b"WEBP"
    return False


@app.post("/menu/{item_id}/photo", status_code=201)
async def upload_photo(item_id: str, file: UploadFile = File(...)):
    if not re.fullmatch(r"[a-z0-9-]{1,40}", item_id):
        raise HTTPException(status_code=400, detail="Bad item id")

    ext = ALLOWED_TYPES.get(file.content_type)
    if ext is None:
        raise HTTPException(status_code=415, detail="Only JPEG, PNG or WebP images are allowed")

    data = await file.read(MAX_BYTES + 1)  # never read more than the limit plus one byte
    if len(data) > MAX_BYTES:
        raise HTTPException(status_code=413, detail="File is larger than 2 MB")
    if not looks_like_image(ext, data[:12]):
        raise HTTPException(status_code=400, detail="File content does not match its type")

    # Our own name: never use the filename the user sent
    stored_name = f"{item_id}-{uuid.uuid4().hex}{ext}"
    (UPLOAD_DIR / stored_name).write_bytes(data)
    return {"filename": stored_name, "size": len(data), "url": f"/photos/{stored_name}"}


@app.get("/photos/{stored_name}")
def get_photo(stored_name: str):
    # Block ../ tricks: the name must be a plain file name
    if Path(stored_name).name != stored_name:
        raise HTTPException(status_code=400, detail="Bad file name")
    path = UPLOAD_DIR / stored_name
    if not path.is_file():
        raise HTTPException(status_code=404, detail="Photo not found")
    return FileResponse(path)

Each check has a reason; the comments mark the security-relevant ones.

What each check protects against

Why the code looks like this

CheckProtects against
Allowed content types (whitelist)Users uploading .html, .exe or scripts that could run when someone opens them
Size limit read while reading (file.read(MAX_BYTES + 1))Someone sending a 5 GB file to fill your memory or disk
Magic bytes checkA file labelled image/png that is really something else. The declared type is just text the sender chose
Your own generated filename (UUID)Overwriting other files, names with ../, strange characters, and guessable URLs
Plain-name check when servingPath traversal: asking for ../../.env to read files outside the upload folder
Validating item_idThe id becomes part of the file name, so it is user input too
Terminal
curl -X POST http://127.0.0.1:8000/menu/espresso/photo -F "file=@espresso.png;type=image/png"
curl -X POST http://127.0.0.1:8000/menu/espresso/photo -F "file=@notes.txt;type=text/plain" 

-F sends multipart form data. Replace espresso.png with any small PNG on your computer. Use curl.exe in PowerShell.

First request succeeds; the second is rejected (HTTP 415)
{"filename":"espresso-232635c3b2a8441d965be9e57f4cde77.png","size":108,"url":"/photos/espresso-232635c3b2a8441d965be9e57f4cde77.png"}

{"detail":"Only JPEG, PNG or WebP images are allowed"}

Open the returned URL, prefixed with http://127.0.0.1:8000, in your browser to see the image.

This is a minimum, not a guarantee

Magic-byte checks catch honest mistakes and simple tricks, but they are not a full defence. For images you will show to other users, also consider re-encoding them (for example with the Pillow library) which strips unexpected content. Never serve user uploads from a place where your server could execute them, and never allow SVG or HTML uploads unless you know how to sanitise them.

Why not keep files on the server's disk?

Saving to a local folder is fine for learning. In production it breaks down:

- Many servers: if you run two copies of your API, a file uploaded to server A does not exist on server B. - Redeploys: many platforms use temporary disks. A new deploy or restart can erase your uploads. - Cost and load: your API spends time and bandwidth moving large files that storage services handle better. - Backups and scale: disks fill up, and you must back them up yourself.

The standard answer is object storage: a service that stores files ("objects") in "buckets" and serves them over HTTP. Amazon S3 is the best known. Others speak the same S3 API: Cloudflare R2, Google Cloud Storage (its own API plus S3 compatibility), Azure Blob Storage (its own API), and MinIO, which you can run yourself. Pricing and free tiers differ and change, so check each provider's page before you choose.

Options for storing files

WhereGood forWeakness
Local diskLearning, one server, temporary filesLost on redeploy, not shared between servers
Database column (BLOB)Tiny files, simplicityBloats the database and backups; slow for big files
Object storage (S3 and similar)Production uploads of any sizeAnother service to set up, secure and pay for

Presigned URLs: upload without passing through your server

Instead of the browser sending the 5 MB photo to your API, which then forwards it to S3, your API does only the small, important job: it checks who is asking and then creates a presigned URL. This is a temporary link signed with your secret credentials that allows exactly one thing (for example "PUT an object with this key and this content type within 5 minutes"). The browser then uploads straight to storage.

Presigned upload flow

  1. 1The browser asks your API: I want to upload a PNG for espresso (POST /menu/espresso/photo-upload-url).
  2. 2Your API checks the user is allowed and the type is acceptable, picks a unique key, and creates a presigned PUT URL.
  3. 3The API returns the URL and the key.
  4. 4The browser sends the file with an HTTP PUT to that URL, with the same Content-Type.
  5. 5Storage accepts it because the signature is valid. Your server never touched the bytes.
  6. 6The browser tells your API it is done (or your API is notified), and your database saves the key so you can show the photo later.
Terminal
pip install fastapi uvicorn boto3
s3_main.py
import os
import re
import uuid

import boto3
from botocore.config import Config
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel

BUCKET = os.environ.get("S3_BUCKET", "brewbean-photos")

# For real AWS S3, leave S3_ENDPOINT unset and put credentials in the standard
# AWS environment variables or ~/.aws. For local MinIO set S3_ENDPOINT=http://localhost:9000.
s3 = boto3.client(
    "s3",
    endpoint_url=os.environ.get("S3_ENDPOINT"),
    region_name=os.environ.get("AWS_REGION", "ap-south-1"),
    config=Config(signature_version="s3v4"),
)

ALLOWED_TYPES = {"image/jpeg": ".jpg", "image/png": ".png", "image/webp": ".webp"}

app = FastAPI(title="Brew & Bean presigned uploads")


class UploadRequest(BaseModel):
    content_type: str


@app.post("/menu/{item_id}/photo-upload-url")
def photo_upload_url(item_id: str, body: UploadRequest):
    if not re.fullmatch(r"[a-z0-9-]{1,40}", item_id):
        raise HTTPException(status_code=400, detail="Bad item id")
    ext = ALLOWED_TYPES.get(body.content_type)
    if ext is None:
        raise HTTPException(status_code=415, detail="Only JPEG, PNG or WebP images are allowed")

    key = f"menu/{item_id}/{uuid.uuid4().hex}{ext}"
    url = s3.generate_presigned_url(
        "put_object",
        Params={"Bucket": BUCKET, "Key": key, "ContentType": body.content_type},
        ExpiresIn=300,  # the link works for 5 minutes only
    )
    return {"upload_url": url, "key": key, "method": "PUT", "content_type": body.content_type}

generate_presigned_url works offline: it only signs a URL using your credentials. The bucket name and region must match a real bucket for the upload to succeed.

Frontend snippet
// In the browser, after getting upload_url from your API
async function uploadPhoto(file, uploadUrl) {
  const response = await fetch(uploadUrl, {
    method: "PUT",
    headers: { "Content-Type": file.type },
    body: file,
  });
  if (!response.ok) throw new Error("Upload failed: " + response.status);
}

The Content-Type header must match the one used when the URL was signed, or storage rejects the request. The bucket must also allow your website's origin in its CORS settings.

Secrets and public buckets

Cloud storage costs money and mistakes are expensive. Never make a bucket public unless every file in it is meant to be public, never commit access keys to Git, give your API's credentials permission only for the one bucket it needs, and set a billing alert on your cloud account. Presigned URLs also carry the permission: anyone who has the link can use it until it expires, so keep the expiry short.

Serving files back

For public images, such as menu photos, you can serve them from the bucket or, better, put a CDN (a network of servers close to users, such as Amazon CloudFront or Cloudflare) in front, which caches them near your customers and reduces load on your storage. For private files (invoices, ID documents), keep the bucket private and have your API create a short-lived presigned GET URL after checking that the user is allowed to see that file. Store only the object key in your database, never a permanent public URL, so you can change how you serve files later.

Practise locally

To practise S3 without an AWS account, run MinIO locally with Docker: docker run -p 9000:9000 -p 9001:9001 quay.io/minio/minio server /data --console-address ":9001", then open http://localhost:9001, create a bucket, and set S3_ENDPOINT=http://localhost:9000 plus the access key and secret shown in the MinIO documentation for your version.

How this comes up in interviews

Typical questions: "How would you design image uploads for a social app?" (presigned URLs, object storage, CDN, thumbnails made by a background job, see *Background jobs and queues*), "How do you stop someone uploading a huge or malicious file?" (size limit, type whitelist, magic bytes, re-encode, random names), "Why not store files in the database?" and "What is path traversal?".

Prompt · Review my upload endpoint
Review this file upload endpoint for security and reliability problems. Consider file size, type checks, filenames, path traversal, storage location, and behaviour with many servers. For each problem, explain the attack or failure in one sentence and show a small fix.

[paste your upload code]

What to expect: The review should mention size limits, whitelisting types, not trusting the filename, magic bytes, and moving to object storage. Test any fix it proposes with a malicious sample file of your own.

Try it: Reject tiny and empty files, and limit per item

Change upload_photo so that it rejects files smaller than 100 bytes with status 400. Then add a rule that an item may have at most 3 photos: count files in uploads/ that start with <item_id>- and return 409 when the limit is reached.

Try it: Try to attack your own endpoint

Using curl, try: a 3 MB image, a text file renamed to .png with type=image/png, and a GET request for /photos/..%2Fmain.py. Confirm each is rejected and explain which check stopped it.

File upload checklist

  1. Whitelist of allowed content types, plus magic-byte check
  2. Hard size limit, enforced while reading
  3. Stored under a generated name, never the user's filename
  4. Served names checked against path traversal
  5. Production files in object storage, not on the app server's disk
  6. Bucket private by default; presigned URLs short-lived
  7. Only the object key stored in the database

Key takeaways

  1. Uploads arrive as multipart/form-data; the declared filename and type are written by the sender, so never trust them.
  2. Whitelist types, limit size while reading, check magic bytes and generate your own file names.
  3. Local disk breaks with several servers and redeploys; use object storage such as S3, R2 or MinIO in production.
  4. A presigned URL lets the browser upload straight to storage for a short time, so large files never pass through your API.
  5. Keep buckets private by default, store keys in the database, and serve public images through a CDN.

Check yourself

Finishing the quiz marks the lesson as done.

1. Why should you generate your own filename instead of using the one the user uploaded?

2. A file arrives with content type image/png, but its first bytes are plain text. What does this show?

3. What is the main benefit of a presigned upload URL?

4. Why is saving uploads on the app server's local disk a problem in production?

5. What should you store in your database to refer to an uploaded photo?

Saved on this device. Create a free account to keep it on every device.

Practise this with Sam

A real mock interview, out loud, with an honest scorecard. Free to start.

Start free