Using R2
File URLs, public buckets, and signed uploads with Cloudflare R2
Most "files" on a listing, profile, or gallery are a URL. Host the bytes in R2 only when this app must own the upload.
Pick the first row that fits:
| What you need | What to build |
|---|---|
| Photo already on the web, or the user pastes a link | photoUrl: q.url() — no R2, no secrets |
| This app hosts the bytes; anyone with the link may view | Managed public bucket (readScope: "public") + UUID in the key |
| Large or confidential bytes; the URL itself must expire | Signed PUT/GET — needs an R2 API token |
Public-if-you-have-the-link is the usual read model (listing photos, avatars). Signed URLs are the smaller case. Details: R2 Setup → Read models.
By default defineFileStorage("cloudflare-r2") is presign-only: you get
the ctx.storage signer and nothing else. The /storage/v1/* endpoints
further down exist only in the managed subsystem
(managed: true).
Store a URL instead
A property listing, avatar, or hero image that is already a link does not need R2. Store the URL on the row. The CMS gets a URL input; the public listing page renders it. A browser deploy does not ask for R2 secrets.
import { q, defineTable } from "@quickback/compiler";
export const listings = q.table("listings", {
id: q.id(),
title: q.text().required(),
photoUrl: q.url(),
organizationId: q.scope("organization"),
...q.audit(),
...q.softDelete(),
});
export default defineTable(listings, {
read: { access: { roles: ["member+"] } },
crud: {
create: { access: { roles: ["member+"] } },
update: { access: { roles: ["admin+"] } },
delete: { access: { roles: ["admin+"] }, mode: "soft" },
},
guards: { createable: ["title", "photoUrl"], updatable: ["title", "photoUrl"] },
});q.url() rejects javascript: / data: URIs at the validation edge. Use
q.text() only when you must store a non-http value.
Upload into a public bucket (managed)
With managed: true, create a bucket with readScope: "public" and PUT bytes
through the Worker. No R2 API token. The files worker serves the object at
/public/... with no auth. Put a UUID in the path so the URL is not guessable.
# 1. Create the bucket once (write stays org-gated)
curl -X POST https://api.example.com/storage/v1/bucket \
-H "Authorization: Bearer <token>" -H "Content-Type: application/json" \
-d '{ "name": "listings", "readScope": "public", "writeScope": "organization", "writeRoles": ["admin", "member"] }'
# 2. Upload — bytes go through the Worker, binding only
curl -X POST https://api.example.com/storage/v1/object/listings/3f2a9c…-hero.jpg \
-H "Authorization: Bearer <token>" -H "Content-Type: image/jpeg" \
--data-binary @hero.jpg
# → { "key": "public/org_abc/listings/3f2a9c…-hero.jpg", "readScope": "public", … }
# 3. Store that URL on the listing (or POST /storage/v1/url to resolve it)Serve:
https://files.yourdomain.com/public/org_abc/listings/3f2a9c…-hero.jpgSigned upload (large or confidential files)
Declare the key column as q.storageKey(...) and let db.allocateUpload
write it. Bytes go directly to R2 — the Worker only signs. The client never
names the key, so it can't point a row at another tenant's object.
import { defineConfig, defineRuntime, defineDatabase, defineAuth, defineFileStorage } from "@quickback/compiler";
export default defineConfig({
name: "events-app",
template: "hono",
features: { organizations: true },
providers: {
runtime: defineRuntime("cloudflare"),
database: defineDatabase("cloudflare-d1"),
auth: defineAuth("better-auth", { plugins: { organization: true } }),
fileStorage: defineFileStorage("cloudflare-r2", { bucketName: "events-app-media" }),
},
bindings: {
r2Buckets: [{ binding: "MEDIA", bucketName: "events-app-media" }],
},
});// quickback/features/media/media.ts
import { feature, q } from "@quickback/compiler";
export default feature("media", {
columns: {
id: q.id(),
eventId: q.text().required(),
title: q.text({ maxLength: 200 }).required(),
r2Key: q.storageKey({
bucket: "MEDIA",
key: "media/{organizationId}/{eventId}/{uuid}.{ext}",
mime: ["image/*", "application/pdf"],
maxBytes: 10_000_000,
}),
organizationId: q.scope("organization"),
...q.audit(),
},
read: { access: { roles: ["member"] } },
update: { access: { roles: ["member"] } },
delete: { access: { roles: ["admin"] }, mode: "hard" },
guards: { updatable: ["title"] },
});
// quickback/features/media/actions/upload.ts
import { z } from "zod";
import { defineAction, media } from "../.quickback/define-action";
export default defineAction({
description: "Create a media row and sign its direct-to-R2 upload.",
path: "/media/upload",
method: "POST",
input: z.object({ eventId: z.string(), title: z.string(), ext: z.string(), mime: z.string(), size: z.number().int() }),
access: { roles: ["member"] },
async execute({ db, input }) {
const { row, putUrl, headers } = await db.allocateUpload(media, input);
return { id: row.id, putUrl, headers };
},
});# 1. Ask your action for a signed URL (your auth/roles run here)
curl -X POST https://api.example.com/api/v1/media/upload \
-H "Authorization: Bearer <token>" -H "Content-Type: application/json" \
-d '{ "eventId": "evt_1", "title": "Floor plan", "ext": "pdf", "mime": "application/pdf", "size": 48213 }'
# → { "id": "…", "putUrl": "https://<account>.r2.cloudflarestorage.com/events-app-media/media/org_abc/evt_1/<uuid>.pdf?…X-Amz-Signature=…", "headers": { "Content-Length": "48213", "Content-Type": "application/pdf" } }
# 2. Upload the bytes straight to R2 (replay the returned headers verbatim)
curl -X PUT "<putUrl>" -H "Content-Type: application/pdf" -H "Content-Length: 48213" --data-binary @plan.pdfdb.allocateUpload(table, { ...rowValues, ext, mime, size }, { id?, expiresIn? }):
- Checks
mimeagainst the column'smimelist (415 STORAGE_UPLOAD_TYPE_REFUSED),sizeagainstmaxBytes(413 STORAGE_UPLOAD_TOO_LARGE), andext(1–16 letters or digits, lower-cased). - Stamps the row exactly as the scoped
db.insertwould (organizationIdfrom the caller's active org), then renders the key from the template:{column}from the row, a fresh{uuid},{ext}from the call. A value that cannot fill a whole path segment (/,.., anything outsideA–Z a–z 0–9 _ -) is a400 STORAGE_KEY_SEGMENT_INVALID. - Signs a PUT with
ctx.storage.signPutUrlunderneath. Themimeandsizeare bound into the signature asContent-Type/Content-Length. When the project also declaresfileStorage.allowedTypes, the type must be on that list too. - Inserts the row (or, with
{ id }, re-keys that row and deletes its previous object) and returns{ row, key, putUrl, headers, expiresAt }.
If the table also has columns named mime, size, or ext, the upload's
values fill them.
Reads. db.signDownload(table, id, { expiresIn?, downloadFilename? }) loads
the row through the firewall and signs a GET for its key. It refuses with
403 STORAGE_KEY_OUTSIDE_PREFIX when the stored key is not under the row's
own prefix.
The prefix is the template up to {uuid} with the row's values
substituted: media/org_abc/evt_1/. Every column placeholder fills a whole
/-separated segment, so "starts with the row's prefix" can't match a
neighbouring tenant. Reads, deletes, and tenant purge all
refuse a key outside its row's prefix — a row whose key was tampered to name
another tenant's object never reaches that object.
Deletes. Deleting a row deletes its object:
- The generated hard
DELETE /:idandDELETE /batchdelete the objects of the rows they erased. - In an action,
db.delete(table)on a storage-key table deletes the objects of the rows it removed — from a full.returning(), or else by selecting the matching rows through the same scopeddbfirst. Insidedb.transaction(...)the objects are deleted after the outermost commit.db.batch([...])refuses a storage-key delete, because a batch skips this step. - Soft deletes keep the object, because the row still exists.
unsafeDb, schedules, and queue handlers bypass the scopeddb. After a hard delete there, calldeleteStorageObjects(env, table, deletedRows)fromsrc/lib/storage-keys. It runs the same prefix check.
What the compiler refuses (each is a compile error):
| Code | Cause |
|---|---|
STORAGE_KEY_GUARDED | The column is listed in guards.createable / updatable / immutable / protected. It is never client-writable, and no generated write body carries it. |
STORAGE_KEY_TENANT_PREFIX | A tenant column of the table's firewall (organizationId, ownerId, a scope column) is not a placeholder before {uuid}. |
STORAGE_KEY_TEMPLATE | No {uuid}, {uuid} not starting its own segment, a placeholder that isn't a column, a column after {uuid} (only {ext} may follow), .., or characters outside A–Z a–z 0–9 . _ - /. |
STORAGE_KEY_BUCKET | bucket is not a bindings.r2Buckets binding (or the managed fileStorage binding), or an environment maps that binding to a different bucket name. |
STORAGE_KEY_PRESIGN | No providers.fileStorage: defineFileStorage("cloudflare-r2", …) — the presigner signs the PUT. |
STORAGE_KEY_MIME / STORAGE_KEY_MAX_BYTES | A malformed MIME pattern, or maxBytes that isn't a positive integer or exceeds fileStorage.maxFileSize. |
STORAGE_KEY_REQUIRED_WITH_CREATE | The column is .required() and the table has a generic create route, which could never supply the key. |
STORAGE_KEY_CASCADE | The table declares onDelete: 'cascade'. A cascaded delete removes rows without deleting their objects. |
STORAGE_KEY_MULTIPLE / STORAGE_KEY_NO_RESOURCE | More than one storage key on a table, or a storage key on a table with no resource (no firewall to check the row against). |
In action code, writing the column directly (db.insert(...).values({ r2Key }),
db.update(...).set({ r2Key }), an upsert's set) throws
STORAGE_KEY_NOT_WRITABLE.
Lower level: storage.signPutUrl
When the key does not belong to a row (a one-off export, an import staging area), sign it yourself. You own the key's tenancy in that case.
const upload = await storage.signPutUrl(`exports/${ctx.activeOrgId}/${crypto.randomUUID()}.csv`, {
contentType: "text/csv",
contentLength: input.size, // required — bound into the signature
expiresIn: 600,
});storage.signPutUrl(key, { contentLength, contentType?, expiresIn?, bucket? })
returns { url, method, headers, key, expiresAt };
storage.signGetUrl(key, { expiresIn?, downloadFilename?, bucket? }) returns a
presigned GET URL. Pass bucket to target a bucket other than the configured
default.
The size and type are signed, and the declared limits are enforced.
contentLength is required: it is checked against fileStorage.maxFileSize
(the presign is refused above it) and bound into the signature as the
Content-Length header, so the URL cannot upload a different size than the
action approved. contentType is bound the same way, and when the project
declares fileStorage.allowedTypes it is required and must be on the list. A
PUT that does not replay the returned headers verbatim is rejected by R2. A
project that declares neither limit gets no ceiling and no allowlist — the
signature is still size-bound.
Presigning needs an R2 API token — set the R2_ACCOUNT_ID, R2_ACCESS_KEY_ID,
and R2_SECRET_ACCESS_KEY secrets. A browser deploy
refuses the project until those exist; it cannot mint them. Create the token in
the Cloudflare dashboard (R2 → Manage API Tokens, Object Read & Write) and set
the secrets with wrangler secret put / quickback deploy. See
R2 Setup → Presigned uploads.
Managed presign endpoints
With managed: true, the generated API also exposes turnkey presigned
endpoints backed by FILES_DB:
# 1. Request a presigned upload URL
curl -X POST https://api.example.com/storage/v1/presign/documents/report.pdf \
-H "Authorization: Bearer <token>" -H "Content-Type: application/json" \
-d '{ "contentType": "application/pdf", "size": 248000 }'
# → { "id": "obj_abc", "key": "...", "uploadUrl": "...", "headers": {...}, "expiresAt": "..." }
# 2. Upload directly to R2
curl -X PUT "<uploadUrl>" -H "Content-Type: application/pdf" --data-binary @report.pdf
# 3. Confirm (reconciles size + etag)
curl -X POST https://api.example.com/storage/v1/confirm/documents/report.pdf \
-H "Authorization: Bearer <token>"Download Flow
Public bucket / URL field — GET the stored URL. No session, no signature.
Presign-only, confidential files — mint a short-lived download URL with
storage.signGetUrl(key, { downloadFilename }) from your action.
Managed, session-gated buckets — the files worker re-checks membership:
# Download a file (auth + firewall enforced)
curl https://api.example.com/api/v1/files/file_abc123/download \
-H "Authorization: Bearer <token>"The download endpoint:
- Validates the user's session
- Checks firewall — the file must belong to the user's organization
- Checks access — the user must have the required role
- Streams the file from R2
Role-Based Access
File writes always respect the same security layers as your API:
- Firewall — Users can only upload files belonging to their organization
- Access — Role-based upload / delete permissions
File reads follow the bucket's readScope. public does not run those
checks on GET — the URL is the capability.
See Also
- R2 Setup — Read models, bucket creation, wrangler bindings
- Avatars — Avatar upload UI integration
- Deploy from your browser — R2 secrets block Start deploys