# File uploads

> Accept files safely: small uploads through the API, large ones straight to S3-compatible storage with presigned URLs, validation, private downloads and progress in React.

Full-Stack Developer roadmap · Connect them · 18 min read · https://levelcode.org/roadmaps/full-stack/file-uploads

Avatars, receipts, CSV imports, videos: sooner or later every product accepts files. Uploads look simple (an `<input type="file">` and a POST) but touch everything that's hard in a backend: large request bodies, slow connections, storage that must outlive your servers, untrusted content that could be malware or a script in disguise, and access control on the way back out. This lesson covers both standard approaches, uploading through your API and uploading directly to object storage, then validation, serving files safely, and the React side.

## Where files live

Not on your server's disk. Containers are replaced on every deploy, and with several instances, a file saved on one isn't visible to the others. Files belong in **object storage**: Amazon S3, or an S3-compatible service such as Cloudflare R2, Google Cloud Storage, or MinIO for local development. You store objects under **keys** (like `avatars/u_42/3f9c….webp`) in a **bucket**, and you keep the metadata (owner, original name, size, type, status) in your database.

```ts
// apps/api/src/storage.ts
import { S3Client } from '@aws-sdk/client-s3'
import { config } from './config.ts'

export const s3 = new S3Client({
  region: config.S3_REGION,
  endpoint: config.S3_ENDPOINT,        // set for MinIO/R2; leave undefined for AWS
  forcePathStyle: !!config.S3_ENDPOINT, // MinIO needs path-style URLs
})
export const BUCKET = config.S3_BUCKET
```

For local development, run MinIO in a container (`docker run -p 9000:9000 minio/minio server /data`) and point `S3_ENDPOINT` at it.

## Approach 1: upload through the API

For small files (avatars, documents up to a few MB), the simplest route is a multipart form POST to your API, which validates the file and writes it to storage:

```ts
// apps/api/src/uploads/routes.ts
import { PutObjectCommand } from '@aws-sdk/client-s3'
import { Hono } from 'hono'
import { bodyLimit } from 'hono/body-limit'
import { BUCKET, s3 } from '../storage.ts'

const MAX_BYTES = 5 * 1024 * 1024

export const avatarRoutes = new Hono().post(
  '/avatar',
  bodyLimit({ maxSize: MAX_BYTES + 64 * 1024, onError: (c) => c.json({ error: { code: 'too_large', message: 'Max 5 MB' } }, 413) }),
  async (c) => {
    const body = await c.req.parseBody()
    const file = body['file']
    if (!(file instanceof File)) return c.json({ error: { code: 'no_file', message: 'Attach a file named "file"' } }, 400)

    const bytes = new Uint8Array(await file.arrayBuffer())
    const type = sniffImageType(bytes)
    if (!type) return c.json({ error: { code: 'bad_type', message: 'Upload a PNG, JPEG or WebP image' } }, 415)

    const key = `avatars/${crypto.randomUUID()}.${type.ext}`
    await s3.send(new PutObjectCommand({ Bucket: BUCKET, Key: key, Body: bytes, ContentType: type.mime }))
    return c.json({ key }, 201)
  },
)
```

The `bodyLimit` middleware rejects oversized requests while they're still arriving, before your handler reads them into memory. The file is read into memory here, which is fine at 5 MB but not at 500 MB; that's what the second approach is for.

### Never trust the file's name or declared type

Both the filename and the `Content-Type` come from the client and can say anything. A file named `cat.png` with type `image/png` can be an HTML page with a script in it. Check the **content** instead, starting with the "magic bytes" at the start of the file:

```ts
// apps/api/src/uploads/sniff.ts
const SIGNATURES = [
  { mime: 'image/png', ext: 'png', bytes: [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a] },
  { mime: 'image/jpeg', ext: 'jpg', bytes: [0xff, 0xd8, 0xff] },
] as const

export function sniffImageType(data: Uint8Array): { mime: string; ext: string } | undefined {
  for (const sig of SIGNATURES) if (sig.bytes.every((b, i) => data[i] === b)) return { mime: sig.mime, ext: sig.ext }
  // WebP: "RIFF" .... "WEBP"
  const ascii = (from: number, to: number) => String.fromCharCode(...data.subarray(from, to))
  if (ascii(0, 4) === 'RIFF' && ascii(8, 12) === 'WEBP') return { mime: 'image/webp', ext: 'webp' }
  return undefined
}
```

Generate the storage key yourself (a random ID), never from the user's filename, which avoids path tricks and collisions. Keep the original name only as metadata, for display. For stronger guarantees with images, re-encode them (with an image library such as `sharp`, in a background job): the output is a clean image regardless of what the input contained.

## Approach 2: direct uploads with presigned URLs

Routing a 2 GB video through your API ties up a server for minutes, and every byte crosses your network twice. Instead, let the browser upload **directly to object storage**, using a **presigned URL**: a URL your API signs with its credentials that allows one specific operation (upload this key, of this type) for a few minutes.

```sequence
title: A direct upload
participants: Browser, API, Object storage
Browser -> API: POST /api/uploads {name, size, type}
API -> API: Check auth, size, type; create upload row (pending)
API --> Browser: {uploadId, url} presigned for 5 minutes
Browser -> Object storage: PUT url (the file bytes)
> Your servers never handle the file data.
Object storage --> Browser: 200 OK
Browser -> API: POST /api/uploads/:id/complete
API -> Object storage: HEAD key (verify it exists, size, type)
API --> Browser: 200 {upload: ready}
```

```ts
// apps/api/src/uploads/presign.ts
import { HeadObjectCommand, PutObjectCommand } from '@aws-sdk/client-s3'
import { getSignedUrl } from '@aws-sdk/s3-request-presigner'
import { BUCKET, s3 } from '../storage.ts'

const ALLOWED = new Map([['application/pdf', 'pdf'], ['image/png', 'png'], ['image/jpeg', 'jpg']])
const MAX_BYTES = 50 * 1024 * 1024

export async function startUpload(userId: number, input: { name: string; size: number; type: string }) {
  const ext = ALLOWED.get(input.type)
  if (!ext) throw new Error('unsupported file type')
  if (input.size <= 0 || input.size > MAX_BYTES) throw new Error('file too large')

  const key = `uploads/pending/${userId}/${crypto.randomUUID()}.${ext}`
  const url = await getSignedUrl(
    s3,
    new PutObjectCommand({ Bucket: BUCKET, Key: key, ContentType: input.type, ContentLength: input.size }),
    { expiresIn: 300, signableHeaders: new Set(['content-type']) }, // also sign the type, not just the length
  )
  // ...insert into uploads (id, user_id, key, original_name, size, type, status='pending')...
  return { key, url }
}

export async function verifyUpload(key: string, expected: { size: number; type: string }) {
  const head = await s3.send(new HeadObjectCommand({ Bucket: BUCKET, Key: key }))
  return head.ContentLength === expected.size && head.ContentType === expected.type
}
```

The presigner signs `Content-Length` by default, but not `Content-Type`; `signableHeaders` adds it, and you can confirm both appear in the URL's `X-Amz-SignedHeaders` parameter. Signing them into the URL means the storage service rejects a PUT whose headers don't match, so a client can't use a URL issued for a 1 MB PDF to upload 5 GB of something else. On `complete`, the API checks the object really exists with the expected size, then marks the upload ready (and can move it out of the `pending/` prefix). A bucket **lifecycle rule** that deletes objects under `uploads/pending/` after a day cleans up uploads that were started and abandoned.

Two more details:

- The bucket needs a **CORS configuration** allowing `PUT` from your site's origin, because the browser is now talking to the storage service's origin directly.
- For multi-gigabyte files, use **multipart uploads** (presign each part; the browser uploads parts in parallel and retries failed ones), which S3-compatible services all support.

## Serving files back safely

Keep the bucket **private**. When a user should see a file, check they're allowed to, then redirect to a short-lived presigned **GET** URL:

```ts
import { GetObjectCommand } from '@aws-sdk/client-s3'

app.get('/api/files/:id', requireUser, async (c) => {
  const file = await findFileForUser(Number(c.req.param('id')), c.get('user').id) // ownership check
  if (!file) return c.json({ error: { code: 'not_found' } }, 404)
  const url = await getSignedUrl(
    s3,
    new GetObjectCommand({
      Bucket: BUCKET,
      Key: file.key,
      ResponseContentDisposition: `attachment; filename="${file.safeName}"`,
    }),
    { expiresIn: 60 },
  )
  return c.redirect(url, 302)
})
```

> [!WARNING]
> User-uploaded files must never be served as active content from your own origin. An uploaded `.html` or `.svg` file served inline from `example.com` runs its scripts with your site's cookies and permissions: stored XSS. Serve uploads from a separate domain (the storage or CDN domain), force downloads with `Content-Disposition: attachment` for anything that isn't a vetted image, and send `X-Content-Type-Options: nosniff`.

Public, non-sensitive files such as product images can live under a public prefix behind a CDN instead, with long cache lifetimes because their keys never change.

## Processing and scanning

Work that happens after an upload, such as making thumbnails, extracting text from PDFs, transcoding video or scanning for malware, belongs in a **background job**, not in the request: it's slow and it can fail and be retried. Mark the upload `processing`, enqueue a job with its ID, and update the status when it's done. The UI shows "Processing…" until then. Organisations that accept files from the public usually scan every upload with an antivirus engine before anyone else can download it.

## The React side

Show a preview before uploading with an object URL, and report progress. `fetch` can't report upload progress in browsers yet, so use `XMLHttpRequest` for the PUT:

```ts
// apps/web/src/uploads/put-with-progress.ts
export function putWithProgress(url: string, file: File, onProgress: (fraction: number) => void): Promise<void> {
  return new Promise((resolve, reject) => {
    const xhr = new XMLHttpRequest()
    xhr.open('PUT', url)
    xhr.setRequestHeader('Content-Type', file.type)
    xhr.upload.onprogress = (e) => e.lengthComputable && onProgress(e.loaded / e.total)
    xhr.onload = () => (xhr.status >= 200 && xhr.status < 300 ? resolve() : reject(new Error(`upload failed: ${xhr.status}`)))
    xhr.onerror = () => reject(new Error('network error during upload'))
    xhr.send(file)
  })
}
```

```tsx
function DocumentUpload() {
  const [progress, setProgress] = useState<number | null>(null)
  const [error, setError] = useState<string | null>(null)

  async function upload(file: File) {
    setError(null)
    try {
      const { uploadId, url } = await unwrap(await api.api.uploads.$post({ json: { name: file.name, size: file.size, type: file.type } }))
      await putWithProgress(url, file, setProgress)
      await unwrap(await api.api.uploads[':id'].complete.$post({ param: { id: String(uploadId) } }))
    } catch (e) {
      setError((e as Error).message)
    } finally {
      setProgress(null)
    }
  }

  return (
    <div>
      <input type="file" accept="application/pdf,image/png,image/jpeg" onChange={(e) => e.target.files?.[0] && upload(e.target.files[0])} />
      {progress !== null && <progress value={progress} max={1} />}
      {error && <p role="alert">{error}</p>}
    </div>
  )
}
```

The `accept` attribute only filters the file picker; it's a convenience, not validation. The API's checks are what count.

## Practice

Add profile pictures to the `shop` app:

1. `POST /api/me/avatar` accepts a multipart upload (max 2 MB), sniffs the type (PNG, JPEG or WebP only), stores it under a random key, and saves the key on the user. Test it with `curl -F file=@photo.jpg`, and with a text file renamed to `.png` (it must be rejected).
2. `GET /api/users/:id/avatar` redirects to a presigned GET URL valid for 5 minutes, or returns 404.
3. In React, show a preview with `URL.createObjectURL` before uploading, and revoke the URL afterwards.
4. Explain in a sentence or two why the avatar endpoint doesn't need presigned uploads, and what you'd change if users could upload 200 MB videos.

**Solution**

```ts
// apps/api/src/users/avatar.ts
import { GetObjectCommand, PutObjectCommand } from '@aws-sdk/client-s3'
import { getSignedUrl } from '@aws-sdk/s3-request-presigner'
import { Hono } from 'hono'
import { bodyLimit } from 'hono/body-limit'
import { requireUser, type AuthEnv } from '../auth/middleware.ts'
import { pool } from '../db.ts'
import { BUCKET, s3 } from '../storage.ts'
import { sniffImageType } from '../uploads/sniff.ts'

const MAX = 2 * 1024 * 1024

export const avatarRoutes = new Hono<AuthEnv>()
  .post(
    '/me/avatar',
    requireUser,
    bodyLimit({ maxSize: MAX + 64 * 1024, onError: (c) => c.json({ error: { code: 'too_large', message: 'Max 2 MB' } }, 413) }),
    async (c) => {
      const file = (await c.req.parseBody())['file']
      if (!(file instanceof File)) return c.json({ error: { code: 'no_file', message: 'Attach a file' } }, 400)
      const bytes = new Uint8Array(await file.arrayBuffer())
      const type = sniffImageType(bytes)
      if (!type) return c.json({ error: { code: 'bad_type', message: 'PNG, JPEG or WebP only' } }, 415)

      const key = `avatars/${c.get('user').id}/${crypto.randomUUID()}.${type.ext}`
      await s3.send(new PutObjectCommand({ Bucket: BUCKET, Key: key, Body: bytes, ContentType: type.mime }))
      await pool.query('UPDATE users SET avatar_key = $1 WHERE id = $2', [key, c.get('user').id])
      return c.json({ key }, 201)
    },
  )
  .get('/users/:id/avatar', async (c) => {
    const { rows } = await pool.query<{ avatar_key: string | null }>('SELECT avatar_key FROM users WHERE id = $1', [
      Number(c.req.param('id')),
    ])
    const key = rows[0]?.avatar_key
    if (!key) return c.json({ error: { code: 'not_found' } }, 404)
    const url = await getSignedUrl(s3, new GetObjectCommand({ Bucket: BUCKET, Key: key }), { expiresIn: 300 })
    return c.redirect(url, 302)
  })
```

```tsx
// apps/web/src/account/AvatarPicker.tsx
import { useEffect, useState } from 'react'

export function AvatarPicker({ onUploaded }: { onUploaded: () => void }) {
  const [file, setFile] = useState<File | null>(null)
  const [preview, setPreview] = useState<string | null>(null)

  useEffect(() => {
    if (!file) return
    const url = URL.createObjectURL(file)
    setPreview(url)
    return () => URL.revokeObjectURL(url) // free the memory when the file changes or the component unmounts
  }, [file])

  async function save() {
    if (!file) return
    const form = new FormData()
    form.append('file', file)
    const res = await fetch('/api/me/avatar', { method: 'POST', body: form })
    if (res.ok) onUploaded()
  }

  return (
    <div>
      <input type="file" accept="image/png,image/jpeg,image/webp" onChange={(e) => setFile(e.target.files?.[0] ?? null)} />
      {preview && <img src={preview} alt="Preview of your new profile picture" width={96} height={96} />}
      <button onClick={save} disabled={!file}>Save</button>
    </div>
  )
}
```

The migration adds `avatar_key text` to `users`. Don't set `Content-Type` yourself when sending `FormData`: the browser adds `multipart/form-data` with the correct boundary. A renamed text file fails `sniffImageType` and gets a 415, because the check reads the bytes, not the name.

For 2 MB avatars, a request through the API costs little and keeps validation simple: the server sees the bytes before anything is stored. For 200 MB videos, switch to presigned direct uploads (with `ContentLength` signed into the URL, or multipart uploads), verify the object with `HEAD` on completion, then sniff and transcode it in a background job, since the API never sees the bytes during upload.

## Key takeaways

- Store files in object storage under keys you generate, with metadata and ownership in your database.
- Small files can go through the API with a body limit; large files go directly to storage with short-lived presigned URLs that fix the content type and length.
- Never trust filenames or declared types: sniff the content, re-encode images, and generate your own keys.
- Keep buckets private, authorise every download and redirect to a short-lived presigned GET, and never serve uploads as active content from your own origin.
- Process and scan uploads in background jobs, and show progress in the browser with `XMLHttpRequest`.

## Quiz

1. A user uploads evil.png with Content-Type image/png, but the bytes are an HTML page. What catches it?

   A. The accept attribute on the file input
   B. Checking the file extension
   C. Trusting the declared Content-Type
   D. Sniffing the file's magic bytes on the server

   **Answer: D.** The name, extension, declared type and accept attribute are all controlled by the client. Only inspecting the actual bytes tells you what the file is.

2. Why use presigned URLs for large uploads?

   A. The browser uploads directly to object storage, so your API servers never stream the large file
   B. They make files public
   C. They compress the file
   D. They're required for files over 1 MB

   **Answer: A.** A presigned URL authorises one specific upload for a short time. Your API only signs the request and verifies the result, instead of proxying gigabytes.

3. Why is serving an uploaded SVG inline from your main domain dangerous?

   A. SVGs are too large
   B. Browsers can't display SVGs
   C. An SVG can contain scripts that run with your site's origin, cookies and permissions (stored XSS)
   D. It breaks caching

   **Answer: C.** Active content served from your origin runs as your site. Serve uploads from a separate domain, force downloads, and send nosniff.

4. What does signing ContentLength and ContentType into a presigned PUT URL achieve?

   A. Faster uploads
   B. The storage service rejects uploads whose size or type doesn't match what the API approved
   C. It encrypts the file
   D. It makes the URL valid forever

   **Answer: B.** Signed headers become part of the signature, so a URL issued for a small PDF can't be reused to upload something larger or different.
