~ 4 min read
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‑compatiblefetch
+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 thegallery
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.
- Request:
- Profile image download URL:
GET /api/user/profile/image
→ returns pre‑signed GET URL (default 3600s).
Flow: Profile Image Upload (Stable Key, Overwrite)
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)
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
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
optionsaws: { signQuery: true, expires: 86400, allHeaders: true }
to includeContent-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
unlessimage
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 requiredx-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
), useaws4fetch
. - 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.