File Storage (R2)
Quickback provides built-in file storage using Cloudflare R2.
Two modes
defineFileStorage("cloudflare-r2") has two modes:
- Presign-only (default) — emits just the
ctx.storagesigner (presigned PUT/GET URLs straight to R2). No new D1, no/storage/v1/*endpoints, no files worker. You compute keys and store file references in your own table. Use this when you already have a media table and only need to sign large or confidential transfers — see Presigned uploads. - Managed (
managed: true) — adds the turnkey subsystem on top: a FILES_DB metadata database (buckets + objects), the/storage/v1/*upload/download/ manage endpoints, and a files worker for serving. Use this for listing photos, avatars, and other files that should be public-if-you-have-the-link.
// Presign-only (default) — ctx.storage against a bucket, nothing else
defineFileStorage("cloudflare-r2", { bucketName: "my-app-media" })
// Managed subsystem — adds FILES_DB + /storage/v1/* + files worker
defineFileStorage("cloudflare-r2", { managed: true, bucketName: "my-app-files" })New projects can omit bucketName — it defaults to <project>-media. Existing
projects set bucketName to a bucket they already have. A browser deploy
creates the bucket; it cannot mint the R2 API-token secrets that presigning
needs.
The sections below document the managed subsystem. For signed PUT/GET only, jump to Presigned uploads.
Read models
Most files should be semi-private: anyone who has the exact URL can GET
them; anyone who does not, cannot. That is a public bucket
(readScope: "public") plus a UUID in the object path. Signed URLs are the
smaller case — confidential files that must stay unreadable even if the URL
leaks.
| Read model | Who can GET | Use for |
|---|---|---|
Public bucket (readScope: "public") | Anyone with the exact URL | Listing photos, avatars, public media, shareable attachments |
Session-gated (organization / user) | Signed-in caller who passes RBAC | Org-internal docs the CMS should still gate |
Signed GET (storage.signGetUrl) | Holder of a short-lived URL | IDs, payroll, medical, anything that must expire |
Public is not a directory listing. The files worker serves /public/... with
no auth. Put a UUID in the key so /public/org/listings/hero.jpg is not
guessable:
public/{orgId}/listings/{uuid}-hero.jpgDo not enable Cloudflare's r2.dev public-bucket domain for this. That
publishes every object in the bucket. Quickback's readScope: "public" keeps
the R2 bucket private and only the public/ prefix anonymous — so a later
private object is not world-readable.
A Cloudflare-public bucket (PUT …/domains/managed { enabled: true }) is OK
only when that bucket will only ever hold public objects. PUTs stay
authenticated either way. Presign still needs
R2_ACCOUNT_ID / R2_ACCESS_KEY_ID / R2_SECRET_ACCESS_KEY; through-Worker
uploads (POST /object) do not.
If the file already lives on the internet, store a URL instead of R2 —
photoUrl: q.url(). No bucket, no secrets, no files worker. See
Using R2.
Architecture
┌─────────────────────────────────────────────────────────────────────┐
│ Your Application │
│ │
│ Upload/Manage Files Serve Files │
│ ───────────────────── ────────────── │
│ api.yourdomain.com files.yourdomain.com │
│ /storage/v1/* /* │
│ │
│ ┌─────────────────────┐ ┌─────────────────────┐ │
│ │ API Worker │ │ Files Worker │ │
│ │ │ │ │ │
│ │ POST /bucket │ │ GET /public/* │ │
│ │ GET /bucket │ │ → No auth │ │
│ │ POST /object/* │ │ │ │
│ │ DELETE /object/* │ │ GET /* │ │
│ │ │ │ → Session + RBAC │ │
│ └──────────┬──────────┘ └──────────┬──────────┘ │
│ │ │ │
│ └───────────┬───────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────┐ │
│ │ R2 Bucket │ │
│ │ quickback-files │ │
│ └─────────────────────┘ │
└─────────────────────────────────────────────────────────────────────┘Enabling File Storage
Add the fileStorage provider to your quickback.config.ts:
export default {
name: 'my-app',
providers: {
runtime: { name: 'cloudflare' },
database: { name: 'cloudflare-d1' },
auth: { name: 'better-auth' },
fileStorage: {
name: 'cloudflare-r2',
config: {
managed: true,
binding: 'R2_BUCKET',
bucketName: 'my-app-files',
filesBinding: 'FILES_DB',
maxFileSize: 10 * 1024 * 1024, // 10MB
allowedTypes: ['image/jpeg', 'image/png', 'image/webp'],
},
},
},
};managed: true is what turns on everything on this page — the /storage/v1/*
routes, the files worker, and the FILES_DB metadata database. Without it R2
file storage is presign-only: the compiler signs requests against the
bucket by name and emits no bucket binding into wrangler.toml, so
binding and filesBinding are ignored and env.R2_BUCKET is undefined
at runtime (the compile warns when you set them anyway).
To read the bucket directly from your own code in presign-only mode, declare
it yourself under bindings.r2Buckets.
API Endpoints
Storage API (api.yourdomain.com/storage/v1)
| Method | Endpoint | Description |
|---|---|---|
| POST | /bucket | Create a bucket |
| GET | /bucket | List buckets |
| GET | /bucket/:name | Get bucket info |
| DELETE | /bucket/:name | Delete bucket (must be empty) |
| POST | /object/:bucket/*path | Upload file (bytes through the Worker) |
| POST | /presign/:bucket/*path | Presigned upload URL (bytes direct to R2) |
| POST | /confirm/:bucket/*path | Confirm a presigned upload (reconcile size + etag) |
| GET | /object/:bucket/*path | Download file |
| HEAD | /object/:bucket/*path | Get file metadata |
| DELETE | /object/:bucket/*path | Delete file (soft delete) |
| GET | /object | List objects |
| POST | /url | Get file URL for serving |
For anything larger than a small image — video, audio, big PDFs — upload with a presigned PUT. The bytes go straight to R2, so you skip the Worker's request-body cap. Serving those files can still be a public URL; presign is the upload path, not the default read model. See Presigned uploads.
Files Worker (files.yourdomain.com)
| Method | Path | Auth Required |
|---|---|---|
| GET/HEAD | /public/* | No |
| GET/HEAD | /* | Yes (session + RBAC) |
Buckets
Buckets organize files and define access control policies.
Creating a Bucket
POST /storage/v1/bucket
{
"name": "listings",
"readScope": "public",
"writeScope": "organization",
"writeRoles": ["admin", "member"]
}readScope: "public" is the default for listing photos and avatars. Switch to
organization or user only when a signed-in session must gate the GET.
Scope Options
| Scope | Read Behavior | Write Behavior |
|---|---|---|
public | Anyone with the exact URL (no auth) | N/A |
organization | Org members only | Org members only |
user | Owner only | Owner only |
writeScope: "user" is enforced per object, not per organization. Any
member of the org may upload to such a bucket, so the object key is the only
thing separating one member's files from another's — and keys are built from a
client-supplied path. Before any write, Quickback checks whether the target key
already has an owner: writing to a key owned by another user returns 403
ACCESS_OWNERSHIP_REQUIRED.
This applies to presigned uploads too. A signed PUT URL is a write
capability, so ownership is checked before the URL is issued, not when the
bytes land.
The read policy covers metadata, not just bytes. readScope and
readRoles are applied on every route that can reveal an object — downloads,
GET /object listings, and POST /url lookups alike. Being a member of the
owning organization is not sufficient on its own.
- Listing (
GET /object) returns only objects in buckets you may read. The filter is applied in the query, solimitandoffsetpage over your readable set — you never receive a short page because rows were removed after the fact. Naming a bucket you cannot read returns403ACCESS_ROLE_REQUIRED; with nobucketfilter, unreadable buckets are simply absent. - URL lookup (
POST /url) returns404when the bucket policy denies you — the same response as an object that does not exist. This is deliberate: a403would confirm that the id or key you supplied resolves to a real object, letting a caller enumerate objects they cannot read.
For readScope: "user" buckets, both routes additionally require that you own
the object.
Role-Based Access
You can restrict operations to specific roles:
{
"readRoles": ["admin", "member"],
"writeRoles": ["admin", "editor"],
"deleteRoles": ["admin"]
}An empty array [] means no role restriction (all authenticated users).
Uploading Files
POST /storage/v1/object/avatars/profile.jpg
Content-Type: image/jpeg
Content-Length: 12345
<binary data>Response:
{
"id": "obj_123",
"key": "org_abc/avatars/profile.jpg",
"bucket": "avatars",
"name": "profile.jpg",
"size": 12345,
"mimeType": "image/jpeg",
"readScope": "public"
}Presigned uploads
Streaming bytes through the Worker (POST /object) is fine for listing photos
and other small files. It needs no R2 API token — the bucket binding is enough.
Use a presigned PUT for large media (video, audio, big PDFs) or when the
object itself is confidential. The Worker runs the access checks and hands back
a short-lived URL the client uploads to directly. The bytes never touch your
Worker. Presigned GET (storage.signGetUrl) is the matching read model for
those confidential files — not for listing photos.
# 1. Ask the API to sign an upload URL (auth + bucket + role checks run here)
curl -X POST https://api.example.com/storage/v1/presign/videos/clip.mp4 \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{ "contentType": "video/mp4", "size": 73400320 }'
# Response:
# {
# "id": "obj_abc",
# "key": "org_abc/videos/clip.mp4",
# "uploadUrl": "https://<account>.r2.cloudflarestorage.com/...&X-Amz-Signature=...",
# "method": "PUT",
# "headers": { "Content-Type": "video/mp4" },
# "expiresAt": "2026-06-02T12:10:00.000Z"
# }
# 2. Upload the bytes straight to R2 (replay the returned headers verbatim)
curl -X PUT "<uploadUrl>" -H "Content-Type: video/mp4" --data-binary @clip.mp4
# 3. Confirm — reconciles the recorded size + etag against what actually landed
curl -X POST https://api.example.com/storage/v1/confirm/videos/clip.mp4 \
-H "Authorization: Bearer <token>"If contentType is supplied at step 1 it is bound into the signature — the
client MUST send exactly that Content-Type header on the PUT, or R2 rejects
it. Omit it to let the client send any type.
Required secrets
Presigning uses R2's S3-compatible API, which needs an R2 API token — the Workers bucket binding alone cannot presign. Create a token in the Cloudflare dashboard (R2 → Manage API Tokens) and set:
wrangler secret put R2_ACCOUNT_ID # your Cloudflare account id
wrangler secret put R2_ACCESS_KEY_ID # R2 API token access key id
wrangler secret put R2_SECRET_ACCESS_KEY # R2 API token secret
# Optional: wrangler secret put R2_S3_ENDPOINT # override the derived endpointSigning from your own action
The same signer is exposed as ctx.storage inside any
defineAction — so you can author an upload endpoint that
runs your own access rules (org, team, relationship/scoped roles) and stores the
key in your own table, instead of the generic buckets/objects metadata:
import { z } from "zod";
import { defineAction } from "../.quickback/define-action";
import { images } from "../images";
export default defineAction({
description: 'Issue a presigned R2 upload URL for a media file on an event.',
path: '/event/:eventId/media/sign-upload',
method: 'POST',
input: z.object({ filename: z.string(), contentType: z.string() }),
access: { roles: ['admin', 'member', 'scope:event:attendee'] },
async execute({ input, ctx, db, storage }) {
// Tenant-scoped key — secure by construction
const key = `org/${ctx.activeOrgId}/event/${input.eventId}/${crypto.randomUUID()}-${input.filename}`;
const upload = await storage.signPutUrl(key, {
contentType: input.contentType,
expiresIn: 600,
});
await db.insert(images).values({ key, eventId: input.eventId /* … */ });
return { uploadUrl: upload.url, key, headers: upload.headers };
},
});storage exposes:
| Method | Returns | Use |
|---|---|---|
signPutUrl(key, { contentType?, expiresIn? }) | { url, method, headers, key, expiresAt } | Client uploads directly to R2 |
signGetUrl(key, { expiresIn?, downloadFilename? }) | string | Client downloads directly from R2 |
This is the supported replacement for streaming a raw binary body through an action — the JSON body parser and its ~1 MiB cap stay on for every other route.
Serving Files
Public (semi-private) files — the usual case
Files in buckets with readScope: "public" are stored with a public/ prefix
and served with no authentication. Anyone who has the exact URL can open
the file. Put a UUID in the path so the URL is not guessable:
https://files.yourdomain.com/public/org_abc/listings/3f2a…-hero.jpgStore that URL on the listing row. Send it to the public listing page. This is the right model for property photos, avatars, and shareable media.
Session-gated private files
Private files require a valid Better Auth session cookie:
https://files.yourdomain.com/org_abc/documents/report.pdfThe files worker:
- Validates the session token
- Re-checks the caller's current membership in the object's organization
- Verifies role permissions (if
readRolesconfigured) - Serves the file or returns 403
Step 2 is a live lookup, not a read of the session row. A session outlives
membership changes, so its stored active organization only counts while a
current member row backs it. Remove a member and their org-scoped private-file
access stops on the next request — including on the JWT fast path, where the
signed orgId and role claims are re-derived from the database rather than
trusted as minted.
Generated Files
When file storage is configured, the compiler generates:
| File | Purpose |
|---|---|
src/storage/routes.ts | Storage API routes (upload, presign, confirm, download) |
src/storage/presign.ts | R2 presigned-URL signer (createPresigner, backs ctx.storage) |
src/files/schema.ts | Files database schema (buckets, objects) |
cloudflare-workers/files/index.ts | Files worker for serving |
cloudflare-workers/files/wrangler.toml | Files worker config |
The compiler also adds aws4fetch to your
package.json (used by the presigner) and the R2 presign secrets to your
generated CloudflareBindings type. Package-mode compiles (Start browser
deploys) resolve it from the compiler image's /deps/node_modules, not from
an npm install of that generated package.json.
Deployment
After compiling:
-
Create the R2 bucket:
wrangler r2 bucket create my-app-files -
Create the files database:
wrangler d1 create my-app-files -
Run migrations:
wrangler d1 migrations apply my-app-files --local wrangler d1 migrations apply my-app-files --remote -
Deploy the API:
wrangler deploy -
Deploy the files worker:
cd cloudflare-workers/files wrangler deploy -
Set the R2 presign secrets only if you use signed PUT/GET. Public-bucket uploads through
POST /objectdo not need them. See Presigned uploads:wrangler secret put R2_ACCOUNT_ID wrangler secret put R2_ACCESS_KEY_ID wrangler secret put R2_SECRET_ACCESS_KEY
Configuration Reference
| Option | Type | Default | Description |
|---|---|---|---|
managed | boolean | false | Opt into the FILES_DB subsystem + /storage/v1/* + files worker. Off = presign-only. |
bucketName | string | <project>-media | R2 bucket the signer targets (override per-call with signPutUrl(key, { bucket })) |
presign | object | - | Env-var name overrides: { accountIdEnv, accessKeyIdEnv, secretAccessKeyEnv, endpointEnv } |
binding | string | R2_BUCKET | R2 bucket binding name (managed mode) |
filesBinding | string | FILES_DB | Files metadata D1 binding (managed mode) |
maxFileSize | number | string | 10MB | Max upload size — bytes or "100mb" (managed mode) |
allowedTypes | string[] | Images only | Allowed MIME types (managed mode) |
publicDomain | string | - | Custom domain for files worker (managed mode) |
How the size limit is enforced
maxFileSize (and a bucket's fileSizeLimit) is enforced against bytes that
actually arrive, not against what the client claims:
- Through-Worker uploads (
POST /object/...) are read through a byte counter that aborts the moment the limit is crossed, so nothing over-limit reaches R2.Content-Lengthis still checked first — it cheaply turns away honest oversized clients — but it is optional and client-supplied, so it is never the enforcement point. - Presigned uploads go straight from the client to R2, and the signature
binds the content type, not the length. The
sizeyou declare at presign time is therefore advisory.POST /confirm/...HEADs the object, compares the real size against the bucket limit, and deletes an over-limit object rather than recording it — otherwise the declared-size check could be sidestepped by simply never calling confirm.
Both paths answer 413 with the actual and permitted byte counts.
Security Model
- Upload security: Enforced by the API worker (auth, org, role). A public read scope never implies a public write.
- Serve security: Public prefix = URL is the capability. Session-gated paths re-check membership on every GET. Signed GET expires.
- Soft deletes: Files are marked deleted in metadata but retained in R2
- Tenant isolation: All files are prefixed with organization ID