Cloudflare R2 (Private Media Storage) Integration Guide
Technology: cloudflare-r2 · Category: hosting · Last reviewed: 2026-08-23
Source: https://tech-stack.codeamanilabs.org/guide/cloudflare-r2
Insight:
R2 buckets are private by default — the whole job is to not break that. Never make the bucket public for sensitive media; instead serve it two safe ways: short-lived presigned URLs (S3 SigV4, minted server-side so your keys never reach the browser) or a Worker that authorizes every request (auth-key for writes, allow-list / session check for reads → 403 otherwise). Reserve
r2.devfor throwaway assets — it has no WAF, cache, or access controls; use a custom domain for anything real.
██████╗██╗ ██████╗ ██╗ ██╗██████╗ ███████╗██╗ █████╗ ██████╗ ███████╗ ██████╗ ██████╗
██╔════╝██║ ██╔═══██╗██║ ██║██╔══██╗██╔════╝██║ ██╔══██╗██╔══██╗██╔════╝ ██╔══██╗╚════██╗
██║ ██║ ██║ ██║██║ ██║██║ ██║█████╗ ██║ ███████║██████╔╝█████╗ ██████╔╝ █████╔╝
██║ ██║ ██║ ██║██║ ██║██║ ██║██╔══╝ ██║ ██╔══██║██╔══██╗██╔══╝ ██╔══██╗██╔═══╝
╚██████╗███████╗╚██████╔╝╚██████╔╝██████╔╝██║ ███████╗██║ ██║██║ ██║███████╗ ██║ ██║███████╗
╚═════╝╚══════╝ ╚═════╝ ╚═════╝ ╚═════╝ ╚═╝ ╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝╚══════╝ ╚═╝ ╚═╝╚══════╝
Cloudflare R2 (Private Media Storage) Integration Guide
Focus: Storing user-uploaded media (images, receipts, KYC docs, audio) in Cloudflare R2 so it stays private — private buckets, server-minted presigned URLs, Worker-gated access, and direct-to-R2 browser uploads that never expose your credentials.
Overview
Cloudflare R2 is S3-compatible object storage with zero egress fees — you pay to store and to operate, but not to serve bytes out. That makes it ideal for African-market media serving where bandwidth is the expensive part. R2 speaks the S3 API, so the AWS SDKs work unchanged against an R2 endpoint, and it also exposes a native Workers binding (env.MY_BUCKET.get/put/delete) for edge access.
The security headline: buckets are private by default. Nothing is reachable from the Internet until you explicitly attach a public custom domain or an r2.dev URL. For private media you keep it that way and hand out temporary, scoped access instead.
There are exactly two safe ways to let a user read or write a private object — pick per use case:
flowchart TD
A["User needs a private object"] --> B{"Read or write?"}
B -->|"one-off, time-boxed"| C["Presigned URL<br/>(S3 SigV4, expiresIn)"]
B -->|"every request needs a policy"| D["Worker in front of bucket<br/>(authorize then env.BUCKET.get)"]
C --> E["Client gets a capability URL,<br/>never your keys"]
D --> F["Worker checks auth/session,<br/>returns 403 or streams object"]
E --> G["Object expires from reach<br/>when the URL does"]
F --> G
Official Documentation
| Resource | URL |
|---|---|
| R2 docs home | https://developers.cloudflare.com/r2/ |
| Presigned URLs (S3) | https://developers.cloudflare.com/r2/api/s3/presigned-urls/ |
| Workers API usage | https://developers.cloudflare.com/r2/api/workers/workers-api-usage/ |
| Public buckets (when not to) | https://developers.cloudflare.com/r2/buckets/public-buckets/ |
| API tokens / S3 credentials | https://developers.cloudflare.com/r2/api/tokens/ |
The privacy model (read this first)
flowchart LR
P["Private bucket<br/>(default)"] -->|"NEVER for sensitive media"| Pub["Public: custom domain or r2.dev"]
P -->|"recommended"| Pre["Presigned URLs<br/>short TTL"]
P -->|"recommended"| Wk["Worker gate<br/>auth per request"]
Pub -->|"only safe with"| WAF["custom domain +<br/>WAF / Access rules"]
Five rules that keep media private:
- Leave the bucket private. Do not enable a public bucket for user data. Public = anyone with the URL, forever.
- Credentials are server-only. S3 access keys and the
AUTH_KEY_SECRETlive in server env / Wrangler secrets — never in client JS, never inNEXT_PUBLIC_*. - Hand out short-lived capability URLs. Presigned URLs expire (
expiresInseconds; hard max 7 days / 604,800s). Mint them on demand, scope them to one object + one operation, keep TTL small (minutes, not days). A presigned URL is reusable until it expires — it is not single-use — so short TTLs are your safety margin. r2.devis for throwaway assets only. It has no WAF, no cache, no access controls. Anything private or production-grade goes behind a custom domain (which unlocks WAF + Cloudflare Access) or a Worker.- Scope your API tokens. Issue per-bucket, least-privilege tokens (read-only for a download service, read-write only where uploads happen). R2 encrypts objects at rest automatically.
Setup
S3 credentials (for presigned URLs / SDK access)
Create an R2 API token (R2 → Manage API Tokens) to get an Access Key ID + Secret. The S3 endpoint is https://<ACCOUNT_ID>.r2.cloudflarestorage.com.
npm install @aws-sdk/client-s3 @aws-sdk/s3-request-presigner
// lib/r2.ts — server-only client. Never import this into client components.
import { S3Client } from "@aws-sdk/client-s3";
export const r2 = new S3Client({
region: "auto", // required by the SDK, ignored by R2
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!,
},
});
export const R2_BUCKET = process.env.R2_BUCKET!;
Pattern A — Presigned download URL (time-boxed read)
Generate a short-lived GET URL on the server, hand it to the authenticated user. The bucket stays private; the URL stops working when it expires.
// app/api/media/[key]/route.ts
import { GetObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import { auth } from "@clerk/nextjs/server";
import { NextRequest, NextResponse } from "next/server";
import { r2, R2_BUCKET } from "@/lib/r2";
export async function GET(req: NextRequest, { params }: { params: { key: string } }) {
const { userId } = await auth();
if (!userId) return new NextResponse("Unauthorized", { status: 401 });
// Authorize: only let a user fetch their own object (key is namespaced by userId).
if (!params.key.startsWith(`${userId}/`)) {
return new NextResponse("Forbidden", { status: 403 });
}
const url = await getSignedUrl(
r2,
new GetObjectCommand({ Bucket: R2_BUCKET, Key: params.key }),
{ expiresIn: 300 }, // 5 minutes — keep it short
);
return NextResponse.redirect(url);
}
Pattern B — Presigned upload URL (direct browser → R2)
The browser uploads straight to R2 with a presigned PUT, so the file never transits your server. Pin the ContentType so the client can't upload something else under that key.
// app/api/uploads/route.ts — returns a short-lived PUT URL
import { PutObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import { auth } from "@clerk/nextjs/server";
import { NextRequest, NextResponse } from "next/server";
import { r2, R2_BUCKET } from "@/lib/r2";
const ALLOWED = new Set(["image/png", "image/jpeg", "image/webp"]);
export async function POST(req: NextRequest) {
const { userId } = await auth();
if (!userId) return new NextResponse("Unauthorized", { status: 401 });
const { filename, contentType } = await req.json();
if (!ALLOWED.has(contentType)) {
return new NextResponse("Unsupported media type", { status: 415 });
}
// Namespace the key by user so one user can't overwrite another's media.
const key = `${userId}/${crypto.randomUUID()}-${filename}`;
const uploadUrl = await getSignedUrl(
r2,
new PutObjectCommand({ Bucket: R2_BUCKET, Key: key, ContentType: contentType }),
{ expiresIn: 120 },
);
return NextResponse.json({ uploadUrl, key });
}
// client — the PUT must send the SAME Content-Type used to sign the URL
const { uploadUrl, key } = await fetch("/api/uploads", {
method: "POST",
body: JSON.stringify({ filename: file.name, contentType: file.type }),
}).then((r) => r.json());
await fetch(uploadUrl, {
method: "PUT",
headers: { "Content-Type": file.type }, // must match, or signature mismatch
body: file,
});
Gotcha: a presigned
PUTURL is signed over theContent-Type. If the client'sContent-Typeheader doesn't match what you passed toPutObjectCommand, R2 rejects it with a signature error. Send the exact same value.
Pattern C — Worker in front of the bucket (policy per request)
When every request needs a live authorization decision (not just "has a valid URL"), put a Worker in front using the native binding. This is the canonical R2 access-control pattern: a pre-shared key gates writes, an allow-list / session check gates reads, everything else is 403.
Wrangler's default config format is now wrangler.jsonc; the TOML equivalent
below still works unchanged.
// wrangler.jsonc
{
"name": "media-gateway",
"main": "src/index.ts",
"r2_buckets": [
{ "binding": "MEDIA", "bucket_name": "amani-media" } // -> env.MEDIA
]
}
# wrangler.toml (equivalent)
name = "media-gateway"
main = "src/index.ts"
[[r2_buckets]]
binding = "MEDIA" # -> env.MEDIA
bucket_name = "amani-media"
// src/index.ts
const hasValidHeader = (request: Request, env: Env) =>
request.headers.get("X-Custom-Auth-Key") === env.AUTH_KEY_SECRET;
function authorize(request: Request, env: Env, key: string): boolean {
switch (request.method) {
case "PUT":
case "DELETE":
return hasValidHeader(request, env); // writes need the shared secret
case "GET":
// e.g. verify a signed session cookie / JWT here instead of an allow-list
return verifySession(request);
default:
return false;
}
}
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const key = new URL(request.url).pathname.slice(1);
if (!authorize(request, env, key)) {
return new Response("Forbidden", { status: 403 });
}
if (request.method === "GET") {
const object = await env.MEDIA.get(key);
if (!object) return new Response("Not Found", { status: 404 });
const headers = new Headers();
object.writeHttpMetadata(headers);
headers.set("etag", object.httpEtag);
return new Response(object.body, { headers });
}
if (request.method === "PUT") {
await env.MEDIA.put(key, request.body);
return new Response("OK", { status: 201 });
}
return new Response("Method Not Allowed", { status: 405 });
},
};
# the shared secret lives as a Wrangler secret, never in your Wrangler config
npx wrangler secret put AUTH_KEY_SECRET
Choosing a pattern
flowchart TD
A["What are you serving?"] --> B{"Short-lived link is enough?"}
B -->|"yes — download a doc, view a photo"| C["Presigned GET (Pattern A)"]
B -->|"no — policy can change per request,<br/>or you want WAF / rate-limit / caching"| D["Worker gate (Pattern C)"]
A --> E{"Letting users upload?"}
E -->|"yes"| F["Presigned PUT (Pattern B)<br/>+ ContentType + size limits"]
| Need | Pattern | Why |
|---|---|---|
| Time-limited download of a private file | A — presigned GET | No infra; URL expires on its own |
| Direct browser upload, bytes skip your server | B — presigned PUT | Offloads bandwidth; pin ContentType |
| Live per-request authz, WAF, caching, rate-limit | C — Worker + custom domain | Full control at the edge |
| Public, non-sensitive assets (logos, OG images) | Public bucket on a custom domain | Only when leakage is harmless |
Environment Variables
# Server-only — never NEXT_PUBLIC_*
R2_ACCOUNT_ID=...
R2_ACCESS_KEY_ID=...
R2_SECRET_ACCESS_KEY=...
R2_BUCKET=amani-media
# Worker (Pattern C) — set via `wrangler secret put`, not in your Wrangler config
AUTH_KEY_SECRET=...
codeAmani Notes
- This is the same stack that already powers
thumbs.codeamanilabs.org. Tech-stack thumbnails live in a public R2 bucket on a custom domain — correct, because thumbnails are non-sensitive. User media (receipts, KYC, profile photos) is the opposite case: private bucket + Pattern A/B/C only. - M-Pesa / KYC context. Store payment receipts and ID documents under a
userId/-namespaced key, serve them exclusively through presigned GET behind a Clerk session check (Pattern A). Short TTLs mean a leaked URL is dead within minutes — important for KDPA (Kenya Data Protection Act) compliance around personal data. - Zero egress = cheap media at African-market scale. Unlike S3, R2 doesn't bill bandwidth out. For image-heavy, mobile-first apps on 2G/3G this removes the cost penalty of serving lots of media — pair with Cloudflare's CDN cache on a custom domain.
- Keep keys off the device. Never embed R2 S3 credentials in the mobile/web client. Always mint presigned URLs from a server route or Worker; the client only ever holds a time-boxed URL.
- Direct uploads protect your server. Pattern B lets a low-bandwidth client push a photo straight to R2 without proxying through your (metered) app server — and the
ContentType+ size guard stops abuse.
Troubleshooting
| Issue | Fix |
|---|---|
Presigned PUT returns SignatureDoesNotMatch |
Client Content-Type must exactly match the value passed to PutObjectCommand |
| Object reachable by anyone | You enabled a public bucket / r2.dev — disable it; serve via presigned URL or Worker instead |
403 from the Worker on legit reads |
Your authorize() GET branch is rejecting — check the session/allow-list logic |
| Credentials leaked to browser | Move the S3 client into a server-only module; never expose keys via NEXT_PUBLIC_* |
Need WAF / caching but on r2.dev |
Move to a custom domain — r2.dev supports none of those |
| Presigned URL still works after "expiry" | Check expiresIn units (seconds) and server clock skew; SigV4 is time-sensitive |
| Presigned URL 403s on a custom domain | Presigned URLs only work against the S3 endpoint (<ACCOUNT_ID>.r2.cloudflarestorage.com), not custom domains — for auth on a custom domain use WAF HMAC validation (Pro plan+) |
Official docs: