Skip to content
Chapter 68Lesson 2

Standing up R2 — buckets, scoped tokens, and CORS

Provision Cloudflare R2 object storage for the SaaS, creating an environment-scoped bucket, a least-privilege S3 token, the lib/r2.ts client, and the CORS rule that lets browsers upload directly.

The previous lesson, Defending the no, settled the question of whether: a trigger condition fired, since users are about to start handing the app files, so R2 is on the table for the right reasons. Now you open the Cloudflare dashboard to stand it up, and the first thing you see is a wall of toggles: location hints, lifecycle rules, public access, custom domains, CORS, token grades. None of it is labeled “start here.” The senior question is not how do I turn these on; it’s which of these are trust boundaries I have to get right, and which are noise I can walk past?

Answering that triage is the whole lesson. By the end you’ll have created the bucket, scoped a credential down to exactly what the app does, written one module, lib/r2.ts, that points the AWS SDK at R2, and configured CORS so a browser can upload to the bucket directly. Four artifacts, nothing more. For each one you’ll be able to name the specific failure it prevents, which is the part that matters. The two most expensive mistakes here are an over-powered credential in app code and a wildcard in your CORS rule, and both stay invisible until someone exploits them, so this lesson is about being able to spot them in a code review. The next two lessons sign URLs against this surface, and the upload flow gets built end to end in the chapter after.

The four artifacts, and what each one protects

Section titled “The four artifacts, and what each one protects”

Before any clicking or code, hold the whole surface in your head at once. There are exactly four things you stand up in this lesson, and the small number is the point: this is the minimum surface that gets bytes flowing safely. Each artifact pairs with a configuration value your app reads, and each one exists to prevent a specific failure:

  • The bucket, one per environment, so a staging upload can never land in production.
  • The scoped token, one per environment, locked to that environment’s single bucket with read-and-write-only, so a leaked credential can’t create or delete buckets and can’t touch any other bucket.
  • The CORS rule, naming an explicit origin, method, and header, so the browser can upload directly while a leaked upload URL can’t be replayed from some attacker’s page.
  • lib/r2.ts, the configured client, so the whole app reaches R2 through one instance, the same way it reaches Postgres through one db.
Artifact
Config value
Protects against
Bucket
namespace, one per environment
R2_BUCKET_NAME
Staging bytes in prod
Scoped token
this bucket, read + write only
R2_ACCESS_KEY_ID R2_SECRET_ACCESS_KEY
A leaked key deleting every bucket
CORS rule
your origin, GET + PUT, content-type
no env var — lives on the bucket
A leaked URL replayed from anywhere
lib/r2.ts
one configured S3 client
R2_ACCOUNT_ID
A new client per request
Four artifacts, four config values, four failures prevented — and nothing else to configure. The two orange arrows are the trust boundaries this lesson dwells on.

That closed set is itself the reassurance: the dashboard offers dozens of knobs, but the app needs four artifacts and four values. Everything below drills into one artifact at a time, and a short closing section names the knobs you’ll deliberately walk past.

A bucket is a namespace inside your R2 account. You name it once, at creation, and from then on it’s the container every object lives in. Creating one is a short click-path in the dashboard.

  1. In the Cloudflare dashboard, open R2 Object Storage and enable it on your account if you haven’t already. R2 has its own activation, separate from the rest of Cloudflare.

  2. Click Create bucket.

  3. Name it. The name carries the environment: acme-saas-prod for production, acme-saas-staging for staging, and a shared acme-saas-dev for local development.

  4. Pick a location, leave every other option at its default, and create it.

The one decision that matters at creation is in step 3, and the dashboard won’t make it for you: buckets do not auto-partition by environment. There is no “this is the staging copy” flag. If you create a single bucket and point both your staging deploy and your production deploy at it, they share storage, so the day a teammate runs a test upload against staging, that object lands in the same namespace your paying customers’ files live in. To prevent that, you create one bucket per environment, explicitly, and you put the environment in the name. Then a misconfigured R2_BUCKET_NAME fails loudly: the SDK gets a “no such bucket” error against acme-saas-prod from your laptop, instead of silently writing development bytes into a production tenant’s file list. The name is your tripwire.

The other knob in that flow, the location, is the kind you name once and walk past.

One more property is worth stating plainly now, because it shapes everything in the next two lessons: R2 buckets are private by default. There is no public URL. Every read of an object requires either a signed URL the server hands out or a Cloudflare Worker route in front of the bucket. That default is exactly what you want for tenant files, so the course keeps everything private, and the next lesson covers signed reads as the mechanism.

So far there is no code; everything is dashboard configuration. The first artifact that touches your codebase is the credential, and it’s the one most worth slowing down on.

Scoped tokens: the credential blast radius

Section titled “Scoped tokens: the credential blast radius”

When you create an R2 token, the dashboard asks you two questions that look like form fields but are actually security decisions: which buckets can this token touch, and what can it do to them. Get these wrong and you’ve handed out a key with far more reach than the app will ever use, and the cost of that only shows up when the key leaks.

The term for how much damage one leaked credential can do is its blast radius , and shrinking it is the entire job here. R2 gives you two levers. On the scope side, a token can be account-level, reaching every bucket in the account including the power to create and delete buckets, or bucket-scoped to one or more specific buckets. On the permission side, the dashboard offers three grades:

  • Admin Read & Write: read and write objects, and create, configure, and delete buckets.
  • Object Read & Write: read and write objects in the scoped bucket, with no bucket creation or deletion.
  • Object Read only: read objects, nothing else.

Here is the senior default, stated as a rule you can apply without re-deriving it each time: one token per environment, scoped to that environment’s single bucket, granted Object Read & Write. Never Admin, never “all buckets.” Each narrowing in that rule maps to a blast radius it cuts off:

  • Why not Admin grade. Your application code reads and writes objects: it uploads a file, it fetches one back. It never creates or deletes a bucket; that’s a one-time setup act you do by hand in the dashboard. So Admin grade buys the app nothing and costs it everything, because an Admin key that leaks from your logs or an error trace can delete every bucket in the account. Object Read & Write is the ceiling because it’s exactly what the app does.
  • Why not “all buckets.” A token scoped to every bucket reaches production data once it leaks, no matter where it leaked from. Scope it to the one bucket for this environment and a compromised staging key can’t touch the production bucket; it doesn’t even know that bucket exists.
  • Why one token per environment. A single token shared across staging and production collapses their blast radii into one: leak it anywhere and both are exposed. Separate tokens keep them isolated, and they enforce the rule that matters most, that production credentials never touch localhost. Your dev machine gets the dev bucket’s token; production’s token lives only in the production deployment’s secrets.

When you finish creating the token, R2 shows you an Access Key ID and a Secret Access Key. That pair, plus the account’s S3 endpoint, is what your app authenticates with. These become R2_ACCESS_KEY_ID and R2_SECRET_ACCESS_KEY.

Now apply the rule rather than just reading it. The following exercise drops you into the choices the token dialog actually presents. For each one, decide whether it’s something you’d put behind your running application (production-safe) or something that never belongs in app code, because its blast radius is bigger than the work the app does.

Sort each credential or scope choice by whether it belongs behind your running app. Drag each item into the bucket it belongs to, then press Check.

App code (production-safe) Least access the app actually uses
Never in app code Blast radius bigger than the job
Object Read & Write, scoped to one bucket
Admin Read & Write
All buckets, Object Read & Write
One token reused for staging and production
A separate token per environment
Object Read only, for a service that only fetches files

The Object Read only item is the one to think twice about: it’s safe precisely because it’s even narrower than the default. A part of the system that only ever reads, say a thumbnail renderer, should get a token that can’t write at all. Least access isn’t a single setting; it’s the smallest grant that does the job, and the job is what decides.

Here’s the part you’ve effectively written twice already. Reaching R2 from your app has the same shape as reaching Postgres through db or Resend through the sendEmail wrapper: a single client, constructed once at module scope, reading its configuration from validated env, guarded by import 'server-only' so it can never slip into the browser bundle. You are not learning a new discipline; you’re applying a discipline you already own to a third client. The only genuinely new material is three lines of R2-specific configuration.

What makes those three lines possible is that R2 speaks the S3 API. That design decision from Cloudflare pays off everywhere: instead of a bespoke R2 SDK, you use the AWS SDK, with @aws-sdk/client-s3 for the client and its commands and @aws-sdk/s3-request-presigner for signing URLs. The next lesson leans on that second package, so you install it now but don’t call it yet. The same SDK that talks to Amazon S3 talks to R2 unchanged once you point it at the right endpoint. That is also the structural off-ramp the previous lesson promised: because this is the S3 API, moving to S3, Backblaze B2, or a self-hosted MinIO later is an endpoint-and-credentials swap, not a rewrite.

Three configuration values do the pointing. region: 'auto', because R2 ignores the region but the SDK’s request signer requires some value, and omitting it throws the moment you try to sign. endpoint, the account-scoped S3 URL https://<account-id>.r2.cloudflarestorage.com. And credentials, the access key pair from env. Here’s the module in full.

import 'server-only';
import { S3Client } from '@aws-sdk/client-s3';
import { env } from '@/env';
// One configured S3 client for R2, constructed once at module scope and reused
// across every request — the same singleton discipline as `db` and the Resend
// client. Helpers in later lessons compose this client; they do not wrap it
// behind a generic storage interface (Architectural Principle #5).
const endpoint = `https://${env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com`;
export const r2 = new S3Client({
region: 'auto',
endpoint,
credentials: {
accessKeyId: env.R2_ACCESS_KEY_ID,
secretAccessKey: env.R2_SECRET_ACCESS_KEY,
},
});

import 'server-only' is the poison pill, identical to db and the Resend wrapper. This module holds the secret access key, so if it’s ever imported into a Client Component the build fails loudly instead of shipping the secret to the browser. The guard belongs here, in the module that holds the secret.

import 'server-only';
import { S3Client } from '@aws-sdk/client-s3';
import { env } from '@/env';
// One configured S3 client for R2, constructed once at module scope and reused
// across every request — the same singleton discipline as `db` and the Resend
// client. Helpers in later lessons compose this client; they do not wrap it
// behind a generic storage interface (Architectural Principle #5).
const endpoint = `https://${env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com`;
export const r2 = new S3Client({
region: 'auto',
endpoint,
credentials: {
accessKeyId: env.R2_ACCESS_KEY_ID,
secretAccessKey: env.R2_SECRET_ACCESS_KEY,
},
});

The imports and the env read. S3Client comes from @aws-sdk/client-s3, the same package that talks to Amazon S3. Configuration is read from the typed env, never process.env, so a missing R2_ACCOUNT_ID already failed the build. That’s the same boundary from Chapter 41’s type-safe env vars.

import 'server-only';
import { S3Client } from '@aws-sdk/client-s3';
import { env } from '@/env';
// One configured S3 client for R2, constructed once at module scope and reused
// across every request — the same singleton discipline as `db` and the Resend
// client. Helpers in later lessons compose this client; they do not wrap it
// behind a generic storage interface (Architectural Principle #5).
const endpoint = `https://${env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com`;
export const r2 = new S3Client({
region: 'auto',
endpoint,
credentials: {
accessKeyId: env.R2_ACCESS_KEY_ID,
secretAccessKey: env.R2_SECRET_ACCESS_KEY,
},
});

The endpoint is derived in this module from R2_ACCOUNT_ID, not stored as its own env var. The account id is the only piece that varies; the rest of the URL is fixed. One value goes in env, and the full endpoint is computed once here.

import 'server-only';
import { S3Client } from '@aws-sdk/client-s3';
import { env } from '@/env';
// One configured S3 client for R2, constructed once at module scope and reused
// across every request — the same singleton discipline as `db` and the Resend
// client. Helpers in later lessons compose this client; they do not wrap it
// behind a generic storage interface (Architectural Principle #5).
const endpoint = `https://${env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com`;
export const r2 = new S3Client({
region: 'auto',
endpoint,
credentials: {
accessKeyId: env.R2_ACCESS_KEY_ID,
secretAccessKey: env.R2_SECRET_ACCESS_KEY,
},
});

The line to flag hard. R2 ignores the region, but the AWS signer requires a value. Omit this line and the SDK throws at signing time rather than at construction, so the error surfaces on the first real call. This is the single most common first-run R2 mistake.

import 'server-only';
import { S3Client } from '@aws-sdk/client-s3';
import { env } from '@/env';
// One configured S3 client for R2, constructed once at module scope and reused
// across every request — the same singleton discipline as `db` and the Resend
// client. Helpers in later lessons compose this client; they do not wrap it
// behind a generic storage interface (Architectural Principle #5).
const endpoint = `https://${env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com`;
export const r2 = new S3Client({
region: 'auto',
endpoint,
credentials: {
accessKeyId: env.R2_ACCESS_KEY_ID,
secretAccessKey: env.R2_SECRET_ACCESS_KEY,
},
});

The account-scoped endpoint points the SDK at R2 instead of AWS. Note that the bucket is not in this URL: it’s named per operation (next lesson), so one client serves every bucket the token can reach.

import 'server-only';
import { S3Client } from '@aws-sdk/client-s3';
import { env } from '@/env';
// One configured S3 client for R2, constructed once at module scope and reused
// across every request — the same singleton discipline as `db` and the Resend
// client. Helpers in later lessons compose this client; they do not wrap it
// behind a generic storage interface (Architectural Principle #5).
const endpoint = `https://${env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com`;
export const r2 = new S3Client({
region: 'auto',
endpoint,
credentials: {
accessKeyId: env.R2_ACCESS_KEY_ID,
secretAccessKey: env.R2_SECRET_ACCESS_KEY,
},
});

credentials come from env, and the client is constructed once at module scope and exported. Constructing it per request would churn connections for no reason; this is the same one-client-per-process rule as db and Resend.

import 'server-only';
import { S3Client } from '@aws-sdk/client-s3';
import { env } from '@/env';
// One configured S3 client for R2, constructed once at module scope and reused
// across every request — the same singleton discipline as `db` and the Resend
// client. Helpers in later lessons compose this client; they do not wrap it
// behind a generic storage interface (Architectural Principle #5).
const endpoint = `https://${env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com`;
export const r2 = new S3Client({
region: 'auto',
endpoint,
credentials: {
accessKeyId: env.R2_ACCESS_KEY_ID,
secretAccessKey: env.R2_SECRET_ACCESS_KEY,
},
});

The closing stance: this module exports the configured client and stops there. The signing helpers in later lessons will compose r2; they will not hide it behind a generic StorageProvider interface. It’s the same do-not-wrap decision as the email wrapper: a convenience layer, never an abstraction layer.

1 / 1

The four env entries this module reads slot into the same @t3-oss/env-nextjs boundary you’ve extended before: the server block for the schema, the runtimeEnv map for the wiring. The following snippet shows only the added lines.

src/env.ts
export const env = createEnv({
server: {
// ...existing entries
R2_ACCOUNT_ID: z.string().min(1),
R2_ACCESS_KEY_ID: z.string().min(1),
R2_SECRET_ACCESS_KEY: z.string().min(1),
R2_BUCKET_NAME: z.string().min(1),
},
client: {
// ...unchanged
},
runtimeEnv: {
// ...existing entries
R2_ACCOUNT_ID: process.env.R2_ACCOUNT_ID,
R2_ACCESS_KEY_ID: process.env.R2_ACCESS_KEY_ID,
R2_SECRET_ACCESS_KEY: process.env.R2_SECRET_ACCESS_KEY,
R2_BUCKET_NAME: process.env.R2_BUCKET_NAME,
},
});

All four are in the server block, with no NEXT_PUBLIC_ prefix, because every one of them, the account id, the keys, and the bucket name, is something the browser must never see. The secret access key is the obvious one, but the rule is blanket: server-only credentials and the addresses they unlock stay server-side. That’s the same server-client split the env boundary has enforced since you first installed it, so there is nothing new to decide.

This is the concept most people get wrong, so it’s worth building the mental model carefully before touching the configuration. Recall the architectural rule from the previous lesson: the Next.js function is never a byte pipe. It signs a URL, and the browser then sends the file’s bytes straight to R2. That direct browser-to-R2 request is what makes CORS relevant, and it’s where the confusion lives.

When a page on app.example.com makes a request to a different origin, such as <your-account>.r2.cloudflarestorage.com, the browser governs that request with CORS , Cross-Origin Resource Sharing. The rule is simple to state and constantly misunderstood: the browser will only let a page talk to another origin if that origin explicitly opts in. Without a matching CORS rule on the bucket, the browser blocks the upload before it’s even sent, and it does this even when the signed URL is perfectly valid. That’s the counter-intuitive part worth saying out loud:

CORS is enforced by the browser, configured on the bucket, and has nothing to do with whether your signature is valid.

This is exactly where people burn an afternoon. The signature is right and the credentials are right, but the upload still fails with a CORS error or a silent block, because the bucket never told the browser it was allowed. The fix is never in the signing code; it’s a rule on the bucket.

For a request like an upload, the browser doesn’t just fire and hope. It first sends a small preflight request to ask permission, and only proceeds if the bucket answers yes. The following diagram traces that handshake.

1
Browser asks
Browser → R2 bucket
OPTIONS /org/…/files/… preflight
  • Origin app.example.com
  • wants method PUT
  • wants header content-type
Before the real upload, the browser asks permission.
2
Bucket answers
R2 checks its CORS rule
match → allowed
Origin, method, and header are all on the rule.
no match → blocked
The upload never leaves the browser.
The bucket’s rule is the whole answer — the signature is never consulted.
3
Browser uploads
Browser → R2 bucket
PUT /org/…/files/… + file bytes
Only if step 2 said yes.
Satisfied, the browser sends the real PUT.
Next.js function not in this conversation — it already signed the URL and stepped out
The browser asks, the bucket answers, the function isn’t in the room. A valid signature still gets blocked if the bucket’s CORS rule doesn’t admit the origin, method, and header the browser names.

Notice the direction: the browser asks, the bucket answers, and the function isn’t in the room. The bucket’s CORS rule is the script for that one answer. It declares which origins may talk to the bucket, which methods they may use, and which request headers it will accept. Here’s the rule the project ships:

r2-cors.json (committed to the repo)
[
{
"AllowedOrigins": ["http://localhost:3000"],
"AllowedMethods": ["GET", "PUT"],
"AllowedHeaders": ["content-type"],
"MaxAgeSeconds": 3600
}
]

Read each field as what it admits:

  • AllowedOrigins: which web pages may talk to the bucket at all. In development this is http://localhost:3000; in production it’s your deployed URL. You already have this value in env as NEXT_PUBLIC_APP_URL, so there’s no new variable to mint, since the CORS rule reuses it.
  • AllowedMethods: PUT to upload and GET for any direct browser read. Those are the two verbs the browser performs against R2, and nothing else is admitted.
  • AllowedHeaders: content-type, the one header the signed upload pins (the next lesson is where that pin gets set). The browser names this header in the preflight, so the rule has to admit it or the upload is blocked.
  • MaxAgeSeconds: how long the browser may cache this preflight answer, here one hour. Without it the browser re-asks before every upload; with it, the browser asks once an hour and reuses the answer.

You apply this rule per bucket, either through the dashboard’s CORS section or with a PutBucketCors API call, and the repo commits the JSON, so the production and development rules are reviewable in version control like any other config.

Now the part to internalize, because it’s the most common production hole in this whole surface. Compare the rule above against the one people reach for when they just want the upload to work. The following two tabs show the wrong config and the right one.

[
{
"AllowedOrigins": ["*"],
"AllowedMethods": ["GET", "PUT"],
"AllowedHeaders": ["*"],
"MaxAgeSeconds": 3600
}
]

Any origin can drive any upload URL that ever leaks. When AllowedOrigins is *, the bucket has agreed to talk to every page on the internet, so a signed PUT URL that shows up in a browser network tab, an error log, or a shared trace can be replayed from an attacker’s own page.

There’s a second reason the wildcard is wrong, and it’s specific to R2: a wildcard AllowedHeaders: ['*'] does not reliably admit the content-type header your signed upload sends. The working, recommended value is the explicit ['content-type']. So * fails you twice over: it’s a security hole, and it often silently breaks the very upload you were trying to make work. On R2, list the headers you actually use rather than reaching for the wildcard.

The preflight is automatic: you never write it, the browser sends it. Your only job is to make sure the bucket’s rule answers it correctly, which means naming your origin, your two methods, and your one header explicitly.

Keying objects by tenant: org/{orgId}/files/{fileId}

Section titled “Keying objects by tenant: org/{orgId}/files/{fileId}”

One configuration-time decision remains, and it isn’t a dashboard toggle; it’s a naming convention. The path inside the bucket that addresses one object is its object key , and in a multi-tenant SaaS that key carries tenancy. The course’s pattern:

  • Directoryacme-saas-prod the bucket, one namespace per environment
    • Directoryorg/ tenancy lives in this prefix
      • Directory8f3a…/ one organization
        • Directoryfiles/
          • 0192….pdf
        • Directoryexports/
          • 0192….csv
      • Directoryb21c…/ another organization
        • Directoryfiles/
          • 0192….png

The object key is just a path inside the bucket, and the leading org/{orgId}/ segment is what isolates one organization’s files from another’s.

That shape buys two structural wins, and each one rhymes with something you already know:

  1. Tenancy isolation by prefix. Every object an organization owns lives under org/${organizationId}/. A client can’t fabricate a key pointing outside its own prefix, because the server constructs the key, never the client. This is the object-storage version of the multi-tenancy discipline from earlier in the course: the tenant filter goes in the server’s where clause and is never trusted from the request. Same rule, different storage system.
  2. Prefix-scoped operations. Because tenancy is a path prefix, bucket-level tooling can scope to it: dashboard diagnostics, lifecycle rules, and retention policies can all target org/${orgId}/. And the scheme extends, since later lessons store export outputs under org/${organizationId}/exports/..., so the same prefix system that isolates uploads also isolates generated files. It’s a system, not a one-off.

Hold onto the rule this section plants, because the lessons ahead build on it: the object key is always constructed on the server, from validated inputs, never accepted verbatim from the client. That’s the tenancy boundary for object storage. A later lesson builds the construction, taking a file’s row UUID and a sanitized extension to produce ${fileId}, and the one after wires the export prefix. Here you’ve only established the shape; the rule is the thing to carry forward.

The thesis of this lesson is minimum surface, and triage cuts both ways: knowing which knobs to recognize and walk past matters as much as knowing which four artifacts to stand up, so you don’t gold-plate a setup the app doesn’t need yet. Three of them you’ll see in the dashboard and deliberately leave alone for now:

  • Lifecycle rules: prefix-scoped auto-deletion, like “delete anything under tmp/ after seven days.” The default is none, and that’s correct here. A later lesson uses one to clean up export outputs and a local-dev prefix, so recognize the surface now and it’ll be familiar when it lands.
  • Local development strategy: point your dev environment at the real shared acme-saas-dev bucket, namespacing your own work under a local/${developer}/ prefix. A bucket per developer is overkill, and a 24-hour lifecycle rule on the local/ prefix handles the cleanup. Running MinIO in Docker as a local stub is worth it only if R2 access is genuinely gated for your team; otherwise the real shared bucket is the small-team default.
  • Observability: the R2 dashboard surfaces per-bucket operation counts (Class A: writes and lists; Class B: reads and heads) and storage bytes. Glance at them weekly, the same way you’d glance at Vercel function invocations. A sudden spike usually means one of two things: a signed URL leaked, or a client is polling something it shouldn’t. You watch this; you don’t build it.

That closes the triage. The bucket exists, the token is scoped to exactly what the app does, lib/r2.ts points the SDK at R2, CORS lets the browser upload without opening a hole, and the object-key convention is set. The next lesson signs URLs against this surface: short-lived upload and download links that let the browser move bytes directly while the function only ever signs.