Cloudflare R2 bucket exposing private files: step-by-step fix
You uploaded files to R2 but they're public when they shouldn't be. Here's how to lock them down in under 30 seconds, or audit the whole setup in 15 minutes.
Quick fix: flip the public access switch (30 seconds)
If you just need to stop the leak right now, this is it. Go to your R2 bucket in the Cloudflare dashboard. Click the Settings tab. Look for the Public Access toggle. It says “Allow access to bucket content via public URL”. Turn it off. That's it.
What's actually happening here is that Cloudflare R2 buckets default to private—no one can access objects without a token. But the moment you flip that toggle on, every object becomes readable by anyone with the URL. No authentication, no rate limiting, nothing. So turning it off immediately kills all public access. Your existing presigned URLs will still work because they use your account's secret keys, not the bucket’s public endpoint.
Real-world trigger: You were building a static site and wanted to serve images directly from R2. You enabled public access so your HTML <img> tags could point to the bucket. But then someone uploaded a CSV of customer PII to the same bucket, and now it's world-readable. Flip that toggle off, regenerate the CSV with a presigned URL, and move on.
Moderate fix: audit and restrict bucket policies (5 minutes)
If the public access toggle was off but files are still accessible, the problem is deeper. R2 allows bucket-level policies that can override the global setting. Think of policies as rules that say “allow anonymous users to GetObject from this bucket”. They're written in the same IAM-style JSON as AWS S3 policies. Here's how to check yours.
- In the Cloudflare dashboard, go to R2 > your bucket > Settings.
- Scroll down to Bucket Policy. If it says “No policy defined”, you're good on this front.
- If there is a policy, click Edit and look for a statement like this:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": "*",
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::your-bucket-name/*"
}
]
}
That Principal": "*" is the smoking gun. It says anyone can read any object. You need to delete that statement, or at least restrict the Principal to your Cloudflare account ARN. If you actually need public access for some objects (like a public website's CSS files), don't use a blanket policy—use presigned URLs for each file that needs to stay private.
Why step 3 works: The bucket policy is evaluated before the public access toggle. Even if the toggle is off, a policy that explicitly allows anonymous access will still work. Cloudflare's docs say the toggle “disables public access via the bucket’s public URL”, but policies are separate. It's a design choice that makes sense for advanced use cases but catches people by surprise.
Real-world trigger: You copied an old S3 bucket policy from a tutorial that made a “public read” bucket for a static site. You pasted it into your R2 bucket without thinking. Now every file is exposed. Delete that statement, and the toggle's state becomes authoritative again.
Advanced fix: full security audit and migration to presigned URLs (15+ minutes)
This is when you've confirmed the toggle is off and no bucket policy allows public access, but you still see data being accessed from unexpected IPs. Or maybe you just want to be absolutely certain nothing leaks. Time to audit the entire setup and move to a presigned-URL-only architecture.
Step 1: Check CORS configuration
A misconfigured CORS policy doesn't make objects public by itself, but it can allow browser-based exfiltration if an attacker tricks a user's session. Go to Settings > Cross-Origin Resource Sharing (CORS). If you see ["*"] in the AllowedOrigins list, change it to your specific domains. For example:
[
{
"AllowedOrigins": ["https://yourdomain.com", "https://app.yourdomain.com"],
"AllowedMethods": ["GET"],
"AllowedHeaders": ["*"]
}
]
That restricts which websites can load your R2 objects via JavaScript (fetch, XMLHttpRequest). Not critical for direct URL leaks, but it closes a side channel.
Step 2: Verify no exposed secret keys
If you ever hardcoded your R2 access key ID or secret access key in client-side code—like a React app's .env file that got committed to GitHub—that's how attackers are enumerating your buckets. Run a git history scan with git log -p | grep 'R2_ACCESS_KEY_ID' or use trufflehog. If keys are leaked, rotate them immediately in the Cloudflare dashboard under Account > API Tokens.
Step 3: Migrate to presigned URLs for all access
The cleanest setup: public access toggle off, bucket policy empty, and every object access goes through a presigned URL generated server-side. Here's how to generate one in Node.js using the @aws-sdk/client-s3 package:
import { S3Client, GetObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
const client = new S3Client({
region: "auto",
endpoint: `https://${accountId}.r2.cloudflarestorage.com`,
credentials: {
accessKeyId: process.env.R2_ACCESS_KEY_ID,
secretAccessKey: process.env.R2_SECRET_ACCESS_KEY,
},
});
const command = new GetObjectCommand({
Bucket: "your-bucket",
Key: "private/report.csv",
});
const url = await getSignedUrl(client, command, { expiresIn: 3600 });
// url is usable for 1 hour
Notice expiresIn: 3600—that's 3600 seconds. Set it to whatever your use case needs. For file downloads, 5 minutes is usually enough. For images embedded in emails, 24 hours. The URL contains a cryptographic signature that ties it to that specific object, so even if someone shares the URL, it won't work on other files.
Why this matters: Presigned URLs are the only way to grant temporary, scoped access without making your bucket public. They're the standard in AWS S3 for a reason. R2 implements them identically because it mimics the S3 API. Use them.
Step 4: Enable access logs and monitor
R2 doesn't have native access logging (yet), but you can proxy requests through Cloudflare Workers and log there. Or set up a Worker that sits in front of your R2 bucket and checks each request against an allowlist. Here's a minimal example:
export default {
async fetch(request, env) {
const url = new URL(request.url);
const allowedOrigins = ["https://yourdomain.com"];
const origin = request.headers.get("Origin");
if (origin && !allowedOrigins.includes(origin)) {
return new Response("Forbidden", { status: 403 });
}
return env.BUCKET.fetch(request);
}
}
Bind your bucket to the Worker as BUCKET. This gives you a reverse proxy with your own authentication logic. Overkill for most cases, but if you're dealing with regulatory requirements (GDPR, HIPAA), this is the path.
Summary checklist
Here's your cheat sheet to verify everything's locked down:
- ☐ Public Access toggle off
- ☐ Bucket policy empty or restricted to specific principals
- ☐ CORS allowed origins are your domains, not
* - ☐ No secret keys in git history
- ☐ All object access via presigned URLs or a Worker proxy
If you hit all five, your R2 bucket is as private as it gets. The data is safe.
Was this solution helpful?