Home/Learn/Full-Stack Integration/File Upload, End to End

File Upload, End to End

Advanced
Across the Boundary

Small files can go through your API. Anything larger should not — the browser uploads directly to storage with a signed URL, and your server only signs and records.

Overview

Upload is the clearest example of a feature that looks identical on both sides and is entirely different in the middle. Routing bytes through your API means the platform request limit, memory pressure and doubled bandwidth for every file. The production pattern moves the transfer out of the path: your API issues a short-lived signed URL, the browser PUTs straight to S3 or Supabase, and then tells your API where the file landed. The security consequence is that the signing endpoint becomes the boundary — it decides who may upload, what type and how large.

Through the API

Fine for small files. Know the limits you are agreeing to.

Verify the bytes, stream, and know the caps
// Client — multipart, and no Content-Type header
const form = new FormData()
form.append('file', file)
await fetch('/api/avatar', { method: 'POST', body: form,
                             credentials: 'include' })
// Setting Content-Type manually breaks the multipart boundary.

# FastAPI
@router.post("/avatar")
async def upload(file: UploadFile, user = Depends(current_user)):
    if file.content_type not in {"image/jpeg", "image/png", "image/webp"}:
        raise AppError(422, "bad_type", "JPEG, PNG or WebP only")

    head = await file.read(8)                 # sniff the real bytes —
    await file.seek(0)                        # the client's content_type
    if not is_image_magic(head):              # is attacker-controlled
        raise AppError(422, "bad_type", "That is not an image")

    # stream to disk/storage, never file.read() the whole thing
    key = f"avatars/{user.id}/{uuid4()}.webp"
    await storage.upload_stream(key, file)
    return {"url": public_url(key)}

// The limits you are now inside: Vercel functions cap the request
// body (a few MB), Fly and nginx have their own, and the whole file
// occupies your server while it transfers.

Direct to Storage

Three steps, and the bytes never touch your API.

Sign, PUT, confirm — and sweep the orphans
# 1. Ask for a signed URL. THIS is the security boundary.
@router.post("/uploads/sign")
async def sign(body: SignIn, user = Depends(current_user)):
    if body.content_type not in ALLOWED:
        raise AppError(422, "bad_type", "Unsupported file type")
    if body.size > 10 * 1024 * 1024:
        raise AppError(422, "too_large", "Files must be under 10MB")

    key = f"uploads/{user.id}/{uuid4()}"       # the SERVER picks the key —
    url = storage.create_signed_upload_url(     # never let the client choose
        key, expires_in=300, content_type=body.content_type)
    return {"upload_url": url, "key": key}

// 2. Browser PUTs straight to storage
await fetch(upload_url, { method: 'PUT', body: file,
                          headers: { 'Content-Type': file.type } })

# 3. Confirm, and only now create the record
await api('/problems', { method: 'POST', body: { coverKey: key } })

# The server should verify the object exists and its real size before
# trusting the confirmation — the client could skip step 2 entirely.
head = await storage.head(key)
if head is None or head.size > MAX: raise AppError(422, "upload_failed", ...)

# Orphans are inevitable: signed, uploaded, never confirmed. Sweep
# unconfirmed keys older than a day.

Progress, Resume and Serving

The client-side details, and getting the file back out safely.

XHR progress, signed downloads, strip EXIF
// fetch cannot report upload progress. XHR still can.
const xhr = new XMLHttpRequest()
xhr.upload.onprogress = e => setPct(Math.round(e.loaded / e.total * 100))
xhr.open('PUT', uploadUrl); xhr.send(file)
// Offer cancellation: xhr.abort()

// Large files on a mobile connection need resumable uploads —
// tus, or S3 multipart. A 200MB file over patchy 4G will fail, and
// restarting from zero is not acceptable.

// Serving files back:
//   public assets   -> a CDN URL, cached hard
//   private files   -> a short-lived signed DOWNLOAD url, generated
//                      per request after an authorisation check
@router.get("/documents/{id}/url")
async def download(id: int, user = Depends(current_user)):
    doc = await db.get_document(id, user_id=user.id)   # ownership check
    if not doc: raise HTTPException(404)
    return {"url": storage.signed_download_url(doc.key, expires_in=60)}

// A permanent public URL for a private document is a leak that
// survives every later permission change.

// Also: strip EXIF from user photos (it carries GPS coordinates),
// and never serve user-uploaded HTML or SVG from your main origin —
// it executes as first-party script.

Key Points to Remember

  • 1Uploading through your API costs request-size limits, memory and doubled bandwidth; direct-to-storage avoids all three
  • 2The signing endpoint is the security boundary — it authenticates, constrains type and size, and picks the key
  • 3The client's declared content type is attacker-controlled; verify the actual bytes server-side
  • 4Verify the object exists after a confirmation, and sweep signed-but-never-confirmed orphans
  • 5Private files need short-lived signed download URLs issued after an ownership check, never a permanent public URL

Interview Questions

Sign in to ask Aria
1

Why upload directly to storage instead of through your own API?

Medium
2

What must the signing endpoint validate, and why can it not trust the client?

Hard
3

How do you serve a private file to only the user who owns it?

Medium

Ask Aria about File Upload, End to End

Your personal AI tutor — ask anything about this concept

Revision Status

Personal Notes

Sign in to save personal notes for this topic.

Discussion

Sign in to join the discussion.

Loading discussion…