Cloudflare R2 (Private Media Storage) Integration Guide
Keeping media private on R2
Private buckets + server-minted presigned URLs or a Worker gate; keys never client-side.
Use the AWS SDK (`@aws-sdk/client-s3` + `s3-request-presigner`) against `https://<ACCOUNT_ID>.r2.cloudflarestorage.com` to issue short-`expiresIn` GET/PUT URLs from a server route — the browser uploads straight to R2 (bytes skip your app server) and downloads via a 5-minute link. For per-request policy, bind the bucket to a Worker (`env.BUCKET.get/put/delete`) and authorize before touching it (shared secret for writes, session check for reads → 403). `r2.dev` URLs have no WAF/cache/access controls, so anything sensitive goes behind a custom domain. For codeAmani: namespace keys by `userId`, gate receipts/KYC behind a Clerk session, keep TTLs minutes-short for KDPA, and lean on zero-egress to serve media cheaply over 2G/3G.
Five R2 privacy primitives
Buckets start locked — these are the tools for handing out scoped, temporary access without ever exposing your keys.
██████╗██╗ ██████╗ ██╗ ██╗██████╗ ███████╗██╗ █████╗ ██████╗ ███████╗ ██████╗ ██████╗
██╔════╝██║ ██╔═══██╗██║ ██║██╔══██╗██╔════╝██║ ██╔══██╗██╔══██╗██╔════╝ ██╔══██╗╚════██╗
██║ ██║ ██║ ██║██║ ██║██║ ██║█████╗ ██║ ███████║██████╔╝█████╗ ██████╔╝ █████╔╝
██║ ██║ ██║ ██║██║ ██║██║ ██║██╔══╝ ██║ ██╔══██║██╔══██╗██╔══╝ ██╔══██╗██╔═══╝
╚██████╗███████╗╚██████╔╝╚██████╔╝██████╔╝██║ ███████╗██║ ██║██║ ██║███████╗ ██║ ██║███████╗
╚═════╝╚══════╝ ╚═════╝ ╚═════╝ ╚═════╝ ╚═╝ ╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝╚══════╝ ╚═╝ ╚═╝╚══════╝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:
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)
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). Mint them on demand, scope them to one object + one operation, keep TTL small (minutes, not days). 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 one-time 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.toml
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 wrangler.toml
npx wrangler secret put AUTH_KEY_SECRETChoosing a pattern
| 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 wrangler.toml
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 |