# Pre‑Signed URL Upload Architecture (Cloudflare R2 + Hono Workers) — Liran Tal

The following is a reference implementation and architecture for secure direct browser uploads to Cloudflare R2 using pre-signed URLs generated by a Hono application on Cloudflare Workers.

Canonical: https://lirantal.com/blog/pre-signed-url-upload-architecture-cloudflare-r2-hono-workers

This write-up describes a reference architecture for secure, direct browser uploads and downloads to Cloudflare R2 using time‑limited pre‑signed URLs generated by a Hono application on Cloudflare Workers. It covers flows, contracts, security decisions, and pitfalls.

## Components

*   Hono API (Cloudflare Workers)
    *   Authentication: Better Auth
    *   Database: Postgres via Drizzle ORM
    *   Storage: Cloudflare R2 (S3‑compatible)
*   Frontend SPA (Nuxt)
    *   Performs direct PUT uploads and GET downloads via pre‑signed URLs

## Environment and Bindings

*   Worker binds the bucket as `env.GALLERY` (bucket name: `gallery`).
*   Secrets required: `CLOUDFLARE_ACCOUNT_ID`, `R2_ACCESS_KEY_ID`, `R2_SECRET_ACCESS_KEY`.
*   R2 signing uses the npm package `aws4fetch` (Workers‑compatible `fetch` + `SubtleCrypto`).

## Data Model (Profile Images)

The primary use-case is user profile images with the following considerations:

*   Each user can have at most one profile image.
*   Profile image URLs are stable (no UUID per upload).
*   Uploads overwrite the existing image (if any).
*   `user.image` stores the object key (stable, no extension) in the `gallery` bucket.
*   First assignment persists a random UUID (no extension). Subsequent uploads overwrite the same object to avoid object sprawl.

## CORS Policy (R2)

R2 requires explicit header names (wildcards are unreliable). Example rule:

```
{
  "rules": [
    {
      "allowed": {
        "methods": ["PUT", "GET", "POST", "DELETE"],
        "origins": ["*"],
        "headers": [
          "content-type",
          "x-amz-meta-original-filename",
          "x-amz-meta-uploaded-at",
          "x-amz-meta-uploaded-by"
        ]
      },
      "exposeHeaders": ["ETag"],
      "maxAgeSeconds": 3000
    }
  ]
}
```

## Request/Response Contracts (Summary)

*   Generic upload URL: `POST /api/uploads/pre-signed-url` (UUID per object)
*   Profile image upload URL (stable key): `POST /api/user/profile/image`
    *   Request: `{ contentType: string, fileSize: number, originalFilename?: string }`
    *   Response: `{ presignedUrl, key, contentType, fileSize, expiresIn: 86400, uploadedBy, uploadedAt, originalFilename }`
    *   Frontend must upload with headers:
        *   `Content-Type: <file.type>`
        *   `x-amz-meta-original-filename: <originalFilename>`
        *   `x-amz-meta-uploaded-by: <uploadedBy>`
        *   `x-amz-meta-uploaded-at: <uploadedAt>`
    *   Do not set `Content-Length` manually.
*   Profile image download URL: `GET /api/user/profile/image` → returns pre‑signed GET URL (default 3600s).

## Flow: Profile Image Upload (Stable Key, Overwrite)

```mermaid
sequenceDiagram
  autonumber
  participant F as Frontend (SPA)
  participant A as Hono API (Worker)
  participant DB as Database
  participant R2 as Cloudflare R2

  F->>A: POST /api/user/profile/image { contentType, fileSize, originalFilename? }
  A->>A: Authenticate (Better Auth)
  A->>DB: SELECT user by session.user.id
  DB-->>A: user row (image key?)
  alt first-time (no key)
    A->>DB: UPDATE user.image = randomUUID() (no extension)
    DB-->>A: updated row
  end
  A->>A: aws4fetch.sign( PUT, headers, signQuery, expires=86400, allHeaders=true )
  A-->>F: { presignedUrl, key, uploadedBy, uploadedAt, originalFilename, ... }

  F->>R2: PUT presignedUrl (headers: Content-Type, x-amz-meta-*)
  R2-->>F: 200 OK
```

## Flow: Generic Upload (UUID per Object)

```mermaid
flowchart TD
  A[Frontend SPA] -->|POST filename,contentType,fileSize| B[API /api/uploads/pre-signed-url]
  B --> C[Auth + Validate]
  C --> D[Generate UUID.ext]
  D --> E[aws4fetch.sign PUT signQuery]
  E -->|presignedUrl, metadata| A
  A -->|PUT to presignedUrl| R2[Cloudflare R2]
  R2 -->|200 OK| A
```

## Flow: Profile Image Download

```mermaid
sequenceDiagram
  autonumber
  participant F as Frontend (SPA)
  participant A as Hono API (Worker)
  participant DB as Database
  participant R2 as Cloudflare R2

  F->>A: GET /api/user/profile/image
  A->>A: Authenticate (Better Auth)
  A->>DB: SELECT user.image
  DB-->>A: key or null
  alt has image
    A->>A: aws4fetch.sign( GET, signQuery, expires=3600 )
    A-->>F: { hasImage: true, downloadUrl, filename, expiresIn }
  else no image
    A-->>F: { hasImage: false }
  end
```

## Security Model and Decisions

Covered as part of the flows above are the following security considerations:

*   Authentication enforced for all signing endpoints.
*   Authorization: profile image key is scoped to authenticated user; generic uploads still require session.
*   Upload pre‑signed URLs default to 24h (86400s); download URLs commonly 1h (3600s). Use shorter TTLs if needed.
*   Overwrite policy for profile images ensures a single object per user.

## Implementation Notes

*   Signing: `aws4fetch` options `aws: { signQuery: true, expires: 86400, allHeaders: true }` to include `Content-Type` in the signature.
*   R2 object URL: `https://<account-id>.r2.cloudflarestorage.com/<bucket>/<key>`.
*   Stable key is UUID without extension; determined/created on first call, then reused.
*   Database update for profile (name‑only) must not null `image` unless `image` field is explicitly provided.
*   Backend must return the exact metadata used for signing; frontend must reuse it when uploading.
*   Generate timestamps once on the backend and reuse in the response to avoid signature drift.

## Known Pitfalls and Mitigations

*   Wildcard CORS headers are unreliable on R2 → explicitly allow `content-type` and required `x-amz-meta-*` headers.
*   Missing `Content-Type` in signature → 403; ensure it’s included in the signed headers and in the upload.
*   Do not send `Content-Length` from the browser.
*   AWS SDK v3 is not Workers‑compatible (e.g., `DOMParser`), use `aws4fetch`.
*   Inconsistent expiration across flows; standardize.
*   Undefined metadata (e.g., `originalFilename`) leads to signature mismatch; always return and reuse exact values.

## Operational Considerations

Open security aspects remain for future consideration:

*   Logging: record pre‑signed generation events (do not log secrets).
*   Rate limiting (future): throttle pre‑signed URL issuance per user.
*   Orphaned objects (future): generic uploads may need cleanup or commit semantics.
*   Versioning (future): store multi‑version keys instead of overwrite if historical versions are required.

## More writing

[All writing →](/blog)

*   Sep 5, 2026

    ### [Self-Hosting Hermes Agent with Matrix E2EE on Proxmox](/blog/self-host-hermes-agent-matrix-e2ee-proxmox)

*   Aug 2, 2026

    ### [The Practical Second Brain: Building an LLM-Operated Markdown Knowledge Graph](/blog/practical-second-brain-llm-markdown-knowledge-graph)

*   Aug 1, 2026

    ### [Building a Second Brain with LLMs and AI Agents](/blog/building-second-brain-llms-ai-agents)


* * *

Liran Tal is the author of the book [Node.js Secure Coding: Defending Against Command Injection Vulnerabilities](https://www.nodejs-security.com/)

[Start Building Secure Node.js Applications ↗](https://www.nodejs-security.com/)

 [![](/_astro/nodejs-secure-coding-book-3d-style.DiTWpzFs_Z3c7s1.png)](https://www.nodejs-security.com/)
