← Back to dashboard
cloudflare-r2hostingfresh

Cloudflare R2 (Private Media Storage) Integration Guide

Keeping media private on R2

The real model

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.

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

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


The privacy model (read this first)

Five rules that keep media private:

  1. Leave the bucket private. Do not enable a public bucket for user data. Public = anyone with the URL, forever.
  2. Credentials are server-only. S3 access keys and the AUTH_KEY_SECRET live in server env / Wrangler secrets — never in client JS, never in NEXT_PUBLIC_*.
  3. Hand out short-lived capability URLs. Presigned URLs expire (expiresIn seconds). Mint them on demand, scope them to one object + one operation, keep TTL small (minutes, not days).
  4. r2.dev is 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.
  5. 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.

Bash
npm install @aws-sdk/client-s3 @aws-sdk/s3-request-presigner
TypeScript
// 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.

TypeScript
// 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.

TypeScript
// 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 });
}
TypeScript
// 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 PUT URL is signed over the Content-Type. If the client's Content-Type header doesn't match what you passed to PutObjectCommand, 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.

TOML
# wrangler.toml
name = "media-gateway"
main = "src/index.ts"

[[r2_buckets]]
binding = "MEDIA"            # -> env.MEDIA
bucket_name = "amani-media"
TypeScript
// 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 });
  },
};
Bash
# the shared secret lives as a Wrangler secret, never in wrangler.toml
npx wrangler secret put AUTH_KEY_SECRET

Choosing a pattern

NeedPatternWhy
Time-limited download of a private fileA — presigned GETNo infra; URL expires on its own
Direct browser upload, bytes skip your serverB — presigned PUTOffloads bandwidth; pin ContentType
Live per-request authz, WAF, caching, rate-limitC — Worker + custom domainFull control at the edge
Public, non-sensitive assets (logos, OG images)Public bucket on a custom domainOnly when leakage is harmless

Environment Variables

Bash
# 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

IssueFix
Presigned PUT returns SignatureDoesNotMatchClient Content-Type must exactly match the value passed to PutObjectCommand
Object reachable by anyoneYou enabled a public bucket / r2.dev — disable it; serve via presigned URL or Worker instead
403 from the Worker on legit readsYour authorize() GET branch is rejecting — check the session/allow-list logic
Credentials leaked to browserMove the S3 client into a server-only module; never expose keys via NEXT_PUBLIC_*
Need WAF / caching but on r2.devMove to a custom domainr2.dev supports none of those
Presigned URL still works after "expiry"Check expiresIn units (seconds) and server clock skew; SigV4 is time-sensitive