SYSTEMS · STORAGE ARCHITECTURE · No. 016   Cloudflare R2

Cloudflare R2 Object Storage: Zero Egress, Signed Access, and Workers Bindings

R2 is S3-compatible storage with no egress fees. That one constraint eliminates a common budget leak. The rest of the architecture follows from how you choose to read and write objects: through a Workers binding, through the S3 API with presigned URLs, or via a public custom domain. Each path has distinct cost, security, and latency profiles.

By Barnabas Waweru  ·  August 21, 2026  ·  13 min read
ansi · wordmark · r2 storage
 ██████╗██╗      ██████╗ ██╗   ██╗██████╗ ███████╗██╗      █████╗ ██████╗ ███████╗
██╔════╝██║     ██╔═══██╗██║   ██║██╔══██╗██╔════╝██║     ██╔══██╗██╔══██╗██╔════╝
██║     ██║     ██║   ██║██║   ██║██║  ██║█████╗  ██║     ███████║██████╔╝█████╗  
██║     ██║     ██║   ██║██║   ██║██║  ██║██╔══╝  ██║     ██╔══██║██╔══██╗██╔══╝  
╚██████╗███████╗╚██████╔╝╚██████╔╝██████╔╝██║     ███████╗██║  ██║██║  ██║███████╗
 ╚═════╝╚══════╝ ╚═════╝  ╚═════╝ ╚═════╝ ╚═╝     ╚══════╝╚═╝  ╚═╝╚═╝  ╚═╝╚══════╝

██████╗ ██████╗   ███████╗████████╗ ██████╗ ██████╗  █████╗  ██████╗ ███████╗
██╔══██╗╚════██╗  ██╔════╝╚══██╔══╝██╔═══██╗██╔══██╗██╔══██╗██╔════╝ ██╔════╝
██████╔╝ █████╔╝  ███████╗   ██║   ██║   ██║██████╔╝███████║██║  ███╗█████╗  
██╔══██╗██╔═══╝   ╚════██║   ██║   ██║   ██║██╔══██╗██╔══██║██║   ██║██╔══╝  
██║  ██║███████╗  ███████║   ██║   ╚██████╔╝██║  ██║██║  ██║╚██████╔╝███████╗
╚═╝  ╚═╝╚══════╝  ╚══════╝   ╚═╝    ╚═════╝ ╚═╝  ╚═╝╚═╝  ╚═╝ ╚═════╝ ╚══════╝

What R2 Actually Is

R2 is a globally distributed object store that speaks the AWS S3 API. You configure it with AWS SDK v3, use the same PutObject / GetObject calls, and presign URLs with the same @aws-sdk/s3-request-presigner package. The difference is the endpoint: https://<ACCOUNT_ID>.r2.cloudflarestorage.com instead of an AWS regional endpoint. Region is always "auto". R2 ignores the value but the SDK requires a non-empty string.

The architectural bet R2 makes is this: S3 compatibility lets existing tooling work immediately, zero egress lets you serve large files without a separate CDN budget, and the Workers binding lets objects flow through Cloudflare's network without an S3 client at all. Those three together cover most media-storage use cases without touching AWS.

S3-Compatible API

  • AWS SDK v3 works with a single endpoint swap
  • Presigned URLs via SigV4 signing
  • Multipart uploads for files over 5 GB
  • ListObjectsV2, HeadObject, CopyObject all supported
  • Region: always pass "auto"

Workers Binding

  • For code running inside a Worker only
  • No credentials needed inside the Worker
  • Access via env.MY_BUCKET.get(key)
  • Zero latency to storage from the same Worker
  • Cannot use from Next.js server routes (not a Worker context)

Public Bucket / Custom Domain

  • Expose objects directly on a domain you control
  • Unlocks Cloudflare WAF, Cache, and Access
  • r2.dev subdomain available for dev only
  • No access controls on r2.dev; WAF requires custom domain
  • Smart Tiered Cache sits above the bucket automatically
┌──────────────────────────────────────────────────────────────────┐ │ Access Patterns │ ├──────────────────────────────────────────────────────────────────┤ │ │ │ Next.js Route Handler ──[S3 API + creds]──► R2 Bucket │ │ (server-side, Node) PutObject/GetObject │ │ │ presign → browser │ │ │ │ │ │ Cloudflare Worker ──[env.BUCKET binding]──► R2 Bucket │ │ (edge runtime) no credentials needed │ │ │ │ │ │ Browser ──[presigned PUT URL]───► R2 Bucket │ │ (direct upload) time-boxed, signed │ │ │ │ │ │ Public internet ──[custom domain]────────► R2 Bucket │ │ (CDN-served objects) WAF + Cache rules │ │ │ ▼ │ │ Storage sits at Cloudflare edge │ │ Zero egress to Internet │ └──────────────────────────────────────────────────────────────────┘

The Pricing Model

R2 charges on three axes: storage (GB-month), Class A operations (mutations), and Class B operations (reads). Egress is free on both storage classes. That is the defining number compared to S3, where egress from us-east-1 to the internet costs $0.09/GB after the first 100 GB.

Metric Standard Storage Infrequent Access Free Tier
Storage $0.015 / GB-month $0.010 / GB-month 10 GB-month / month
Class A Operations $4.50 / million $9.00 / million 1M requests / month
Class B Operations $0.36 / million $0.90 / million 10M requests / month
Data Retrieval None $0.01 / GB N/A (Standard only)
Egress Free Free Free
Deletes Free Free Free

Class A vs Class B Operations

Class A operations mutate state. PutObject, CopyObject, ListBuckets, ListObjects, CreateMultipartUpload, UploadPart, PutBucketCors all fall here. Class A costs $4.50/million on Standard storage. If your app does 10 million user uploads per month, that is $45 in operation costs before storage.

Class B operations read existing state. GetObject, HeadObject, HeadBucket, GetBucketCors. Class B is $0.36/million. 100 million downloads costs $36.

DeleteObject and AbortMultipartUpload are free. Never hesitate to delete.

Billing Rounding

Cloudflare rounds up to the next billing unit. 1,000,001 Class A operations bills as 2,000,000. 1.1 GB-month of storage bills as 2 GB-month. Storage is calculated from the peak per day averaged over 30 days, not total bytes written. Infrequent Access has a 30-day minimum storage duration: deleting an object on day 5 still charges you for 30 days of storage. Use Standard storage for anything with a short lifecycle.

Infrequent Access Storage Class

Lower per-GB cost ($0.01 vs $0.015) but higher operation costs and a $0.01/GB data retrieval fee. The free tier applies to Standard storage only. Infrequent Access is suitable for backups, archives, and large model artifacts that get read infrequently. For user media that gets downloaded on every page load, Standard storage is almost always cheaper at scale.

Presigned URL Architecture

Presigned URLs let you grant time-limited access to a single object without exposing credentials. The URL encodes the authorization using SigV4. Anyone with the URL can perform the specified operation until it expires. Maximum expiry is 7 days (604,800 seconds). Minimum is 1 second.

There are two common flows: presigned PUT for direct browser uploads, and presigned GET for private download links. Both are generated server-side, with no communication to R2 at signing time. Only your credentials and an SigV4 implementation are needed.

Presigned PUT: Browser Uploads Bypass Your Server

01

Client requests upload URL

Browser sends file metadata (name, size, MIME type) to your server route.

02

Server generates presigned PUT

Server validates user session, mints a 2-minute PUT URL with the matching ContentType. Key is namespaced by userId.

03

Client uploads directly to R2

Browser fetches PUT to the presigned URL. File goes directly from browser to R2. Your server never handles the bytes.

04

Server records the key

Client tells server the upload completed. Server stores the R2 key in the database for later retrieval.

// Server route: generate presigned PUT URL import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3"; import { getSignedUrl } from "@aws-sdk/s3-request-presigner"; const r2 = new S3Client({ region: "auto", endpoint: `https://${process.env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com`, credentials: { accessKeyId: process.env.R2_ACCESS_KEY_ID!, secretAccessKey: process.env.R2_SECRET_ACCESS_KEY!, }, }); const key = `${userId}/${crypto.randomUUID()}-${sanitizedFilename}`; const url = await getSignedUrl( r2, new PutObjectCommand({ Bucket: process.env.R2_BUCKET!, Key: key, ContentType: validatedContentType, // must match exactly on upload }), { expiresIn: 120 }, // seconds, not milliseconds );
ContentType Must Match Exactly

The presigned URL is signed over the Content-Type header value. If the browser's fetch call sends a different Content-Type than was passed to PutObjectCommand, R2 returns SignatureDoesNotMatch. Always pass ContentType to the command, validate it server-side against an allowlist before signing, and pass the identical value in the browser fetch call.

Presigned GET: Private Download Links

Server-Gated Download Pattern

The bucket stays private. To serve a file, the user hits a server route that verifies their session, checks they own the object (key starts with their userId or exists in the DB with their user_id), then mints a short-lived GET URL. The client is redirected to R2. No proxying.

Keep expiry short: 5 minutes (300 seconds) is enough for a redirect. Re-mint on demand. Do not cache presigned GET URLs longer than their TTL. A leaked URL expires naturally; a cached-forever URL does not.

// Server: presigned GET behind session check import { GetObjectCommand } from "@aws-sdk/client-s3"; import { getSignedUrl } from "@aws-sdk/s3-request-presigner"; export async function GET(req: Request) { const session = await auth(); if (!session) return Response.json({ error: "unauthorized" }, { status: 401 }); const key = getKeyFromRequest(req); // ownership check before signing if (!key.startsWith(session.userId + "/")) { return Response.json({ error: "forbidden" }, { status: 403 }); } const url = await getSignedUrl( r2, new GetObjectCommand({ Bucket: R2_BUCKET, Key: key }), { expiresIn: 300 }, ); return Response.redirect(url, 302); }

CORS for Browser Uploads

Browser-to-R2 presigned PUTs require a CORS rule on the bucket. Without it, the browser's preflight OPTIONS request is blocked by the same-origin policy before it reaches R2. Set this once in the Cloudflare Dashboard (R2 bucket, Settings, CORS) or via the S3 API.

// R2 CORS configuration (JSON format for Dashboard or API) [ { "AllowedOrigins": ["https://your-app.vercel.app"], "AllowedMethods": ["PUT"], "AllowedHeaders": ["Content-Type"], "MaxAgeSeconds": 3600 } ]

Tight CORS Rules in Production

Use specific origins in AllowedOrigins. Do not use "*" in production. For Vercel preview deployments, you either need to add preview URL patterns or generate URLs only from your server (which has the credentials), then let the browser PUT to R2 from the preview domain. R2 checks the Origin header on preflight. If it does not match any allowed origin, the preflight fails.

Only allow PUT in AllowedMethods for an upload bucket. There is no reason for a browser to GET or DELETE directly from R2 if you have a server-gated download flow.

Workers Binding Pattern

When your code runs inside a Cloudflare Worker, you can bind an R2 bucket directly without an S3 client or credentials. The binding is declared in wrangler.toml and injected as env.MY_BUCKET. This is the lower-latency, zero-credential path. It only works in a Worker context. Next.js server routes, Netlify Functions, and Node.js scripts are not Worker contexts; they use the S3 API.

# wrangler.toml [[r2_buckets]] binding = "MY_BUCKET" bucket_name = "my-media-bucket" --- // Worker handler export default { async fetch(req: Request, env: Env) { const object = await env.MY_BUCKET.get("path/to/file.png"); if (!object) return new Response("Not Found", { status: 404 }); return new Response(object.body, { headers: { "Content-Type": object.httpMetadata?.contentType ?? "application/octet-stream" }, }); }, };

When to Use Workers Binding

  • Serving images through a Worker with transform logic
  • Access-controlled media behind a token gate in the Worker
  • Streaming large objects without buffering in memory
  • Generating presigned URLs from a Worker that does not need a server

When to Use S3 API

  • Next.js App Router server routes and Server Actions
  • Netlify Functions or any Node.js serverless environment
  • Scripts that run outside Cloudflare's runtime
  • Migration tools, batch processors, admin scripts

Public vs Private Buckets

Buckets are private by default. Public access is opt-in. Two exposure paths exist. Choose based on what you need the Cloudflare network to do between the client and storage.

Custom Domain (Production)

Connect a zone you control. R2 sits behind Cloudflare's network. This unlocks WAF custom rules, Smart Tiered Cache, Cloudflare Access for authenticated buckets, and Bot Management. Cache Everything page rules let you cache all file types, not just the defaults. Use this for public assets: static site files, product images, podcast audio, ML artifacts.

Smart Tiered Cache places a single upper-tier data center near your R2 bucket and serves subsequent requests from cache. First-request latency hits R2; subsequent requests hit edge cache.

r2.dev Subdomain (Development Only)

Cloudflare provides a *.r2.dev subdomain for quick testing. No WAF, no cache, no access controls. Do not put user data here. Do not use in production. It exists to let you verify objects are accessible before wiring up a custom domain. Disable it before adding any production data to the bucket.

Private Bucket with Presigned URLs

For user uploads (profile photos, KYC documents, receipts), keep the bucket private. Serve every file through a server route that checks the session and mints a presigned GET. The bucket never has public access enabled. R2 never serves a file without a valid signature. Access expires when the URL does.

Pattern Use Case Cloudflare Features Access Control
Custom domain Public static assets, CDN WAF, Cache, Access, Bot Management Cloudflare Access or WAF Token Auth
r2.dev Local dev / testing only None None (fully open)
Presigned GET Private user media N/A (goes direct to R2) Server session check + SigV4
Workers binding Gated delivery via Worker Worker logic runs first Worker-level auth (any logic)

Key Namespace and Token Scoping

R2 has no ACLs on individual objects. Access control happens at the bucket level (public/private) and at the API token level. Get both right from the start.

Key Namespacing

Namespace all user-uploaded objects by userId: userId/uuid-filename.ext. Before minting any presigned URL, verify the requested key starts with the session user's ID. This prevents one user from reading or overwriting another user's files with a guessed key.

R2 does not enforce this. Your server does. The bucket is a flat namespace. Prefixes are just key conventions.

API Token Scoping

Create per-bucket, least-privilege tokens in the R2 Dashboard. A download-only service gets a read-only token. An upload route gets read-write on one bucket only. R2 encrypts objects at rest automatically. Token scoping limits the blast radius of a leak.

Store tokens in a secrets manager (Hazina, AWS Secrets Manager, Infisical). Never commit them. Never log them in request traces.

Temporary Credentials

For callers that use a standard S3 client and need multiple operations in one scoped session, R2 supports temporary credentials. These scope to a bucket, a set of permitted operations, and optionally specific key prefixes. Different from presigned URLs: presigned URLs grant access to one object for one operation. Temporary credentials grant an S3-client session.

S3 Compatibility Gaps

R2 is not full S3. Common operations work. Some do not. Check this list before migrating a workload that relies on edge-case S3 features.

Not Supported on R2

  • Object ACLs (access control is bucket-level only)
  • SSE-KMS (server-side encryption uses Cloudflare-managed keys only)
  • Glacier / Glacier Deep Archive storage class
  • Object Lock and Compliance mode
  • S3 Transfer Acceleration
  • Requester Pays buckets
  • Listing bucket contents at a public domain root (public bucket has no root listing)

Supported (with notes)

  • Multipart uploads: supported; abort is free
  • Lifecycle rules: supported for expiration and storage class transitions
  • CORS: supported via Dashboard or API
  • Event notifications: supported via Workers or R2 event triggers (not SNS)
  • Versioning: not currently supported in the same way as S3 (check current docs)
  • Presigned URLs: supported, max 7-day expiry
  • Temporary credentials: supported via /api/tokens/temporary
Migration Reality

If your existing S3 code only uses PutObject, GetObject, DeleteObject, ListObjectsV2, HeadObject, and presigned URLs, R2 is a drop-in. Change the endpoint and credentials; everything else stays. If you rely on ACLs, KMS-managed encryption, or object versioning, those require a migration plan.

Cost Reality Check

Cost Reality Check

The comparison that matters is R2 vs S3 for a media-heavy app. Assume 1 TB stored, 100 GB egressed per day (3 TB/month), 5 million user uploads/month (Class A), 50 million downloads/month (Class B).

  • R2 cost: Storage: 1,000 GB × $0.015 = $15. Class A: 5M × $4.50/M = $22.50. Class B: 50M × $0.36/M = $18. Egress: $0. Total: ~$55.50/month.
  • S3 (us-east-1) equivalent: Storage: ~$23. Class A (PUT): ~$25. Class B (GET): ~$20. Egress: 3,000 GB × $0.09 = $270. Total: ~$338/month.
  • The egress line alone makes up 80% of the S3 bill at this scale. R2's zero-egress policy saves ~$270/month on this workload. At 10 TB/month egress, the savings are proportionally larger.

Free tier (10 GB storage, 1M Class A, 10M Class B) covers a small app with no cost. The free tier applies only to Standard storage. Do not use Infrequent Access expecting free-tier coverage.

Operation count is the variable to watch. ListObjects calls are Class A. If you paginate a large bucket frequently (crawlers, admin dashboards), each page is a Class A operation at $4.50/million. Design list-heavy flows to run infrequently or cache results.

Key Takeaways

// Summary Principles

01

Egress is free on R2 for both Standard and Infrequent Access. For any workload that reads data out to the internet frequently, that single number changes the cost model compared to S3. Check your current S3 egress bill before dismissing the migration.

02

Region must be "auto" in the S3 client config. R2 ignores it but the AWS SDK throws without it. This is a one-line gotcha that breaks every first R2 integration.

03

Presigned PUT expiry is in seconds, not milliseconds. 120 seconds is enough for a browser upload. ContentType in the command must match exactly what the browser sends. A mismatch returns SignatureDoesNotMatch; there is no fallback.

04

Workers binding is the zero-credential path for code running inside a Worker. Next.js server routes are not Workers. Use the S3 API there, with credentials from a secrets manager. Never hardcode credentials or use NEXT_PUBLIC_ prefixed names.

05

Keep buckets private by default. Use presigned GET behind a server session check for user media. Use custom domain with Cloudflare WAF for public assets. r2.dev is a development convenience, not a production delivery mechanism.

06

Class A operations ($4.50/million) include ListObjects. Frequent listing of large buckets is expensive. Cache list results. Run admin/crawl tooling infrequently. DeleteObject is free; clean up orphaned objects without cost pressure.

07

R2 is not full S3. Object ACLs, SSE-KMS, and Object Lock are not supported. For the majority of app storage (user uploads, media, exports), those gaps do not matter. For regulated workloads that need KMS-managed encryption or immutable object lock, plan accordingly.

Sources