CDN S3 Origin Authentication — Pull from Private Buckets
If your origin is an object store (AWS S3, Cloudflare R2, Wasabi, Backblaze B2, MinIO, etc.) and the bucket is private, the CDN needs to authenticate every pull request with AWS Signature Version 4 (SigV4). With S3 Origin Authentication you give the CDN your bucket credentials once, and from then on every cache miss is signed transparently — visitors never see the credentials, your bucket stays private, and you don't have to publish it to make it work.
This guide explains when to use it, how to configure it, and how to avoid the most common 403 / 404 / 421 you'll hit on the first try.
When you need this
| Your origin | Do you need this? |
|---|---|
| Your own web server (Nginx, Apache, custom API) | No — origin serves over plain HTTPS, no signing required |
| Public S3-compatible bucket (you've enabled public access) | No — anyone can fetch with a plain GET |
| Private S3-compatible bucket (read requires AWS signature) | Yes — without this every fetch gets 403 |
Supported providers
Any object store that speaks AWS SigV4 over an S3 API works out of the box:
- AWS S3 — endpoint
s3.<region>.amazonaws.com - Cloudflare R2 — endpoint
<account_id>.r2.cloudflarestorage.com(or<account_id>.eu.r2.cloudflarestorage.comfor EU jurisdiction) - Wasabi — endpoint
s3.<region>.wasabisys.com - Backblaze B2 (S3-compatible API) — endpoint
s3.<region>.backblazeb2.com - MinIO (self-hosted) — your own endpoint
- Any other S3-compatible — provide the endpoint URL
Configuring the origin
Add or edit an origin on your CDN zone: dashboard → Origins tab → Add Origin (or pencil icon to edit).
The first part is the same as any other origin:
| Field | Value |
|---|---|
| Name | Free text. Anything that helps you recognize this origin. |
| Origin URL | https://<your-endpoint> (no trailing path). Example: https://s3.eu-central-1.amazonaws.com |
| Host Header | Usually leave blank — auto-filled with the endpoint hostname. Don't set it to your CDN domain. |
| Base path | /<bucket-name> if your customer-facing URLs don't include the bucket name. See the section below. |
Then expand the Private bucket (S3 SigV4) section at the bottom and fill:
| Field | Value |
|---|---|
| Enable SigV4 signing | Toggle on |
| Access key ID | The S3-style access key (looks like an ID — safe to display) |
| Secret access key | The S3-style secret. Write-only — once saved, we don't display it again |
| Region | Provider-specific. For R2 leave blank (defaults to auto). For AWS / Wasabi / Backblaze use the bucket's region (us-east-1, eu-central-1, etc.) |
| Service | s3 (default — works for all the supported providers) |
Save. Within a few seconds the CDN propagates the new credentials to every PoP and starts signing requests transparently.
Path mapping (the most common gotcha)
The first segment of the URL path is what S3 considers the bucket name. So if your CDN URL is:
https://your-zone.cubecdn.io/videos/clip.mp4
The CDN forwards /videos/clip.mp4 to your origin. S3 reads:
- bucket =
videos - key (object path inside the bucket) =
clip.mp4
If you actually have your files under bucket my-media, the request looks for bucket videos which doesn't exist → 403 / 404 / "InvalidBucketName".
You have two ways to fix this:
Option A — Use the bucket name as the first segment of your customer URLs
Cleanest. If your bucket is my-media, customers request:
https://your-zone.cubecdn.io/my-media/path/to/file.mp4
Leave Base path empty in the origin. Files like path/to/file.mp4 will be read from bucket my-media. This works if you don't mind exposing the bucket name in your URLs.
Option B — Hide the bucket name with Base path
If you want shorter URLs (no bucket name visible to customers), set:
Base path=/my-media
Then customers request:
https://your-zone.cubecdn.io/path/to/file.mp4
And the CDN rewrites the path to /my-media/path/to/file.mp4 before forwarding to S3. The bucket name never appears in customer URLs.
Choosing the right token / API key permissions
When you create the credentials on your S3 provider, give them the minimum permissions needed:
- Object Read — sufficient if the CDN only fetches existing objects (this is the common case)
- Object Read & Write — only if your application also uses the same credentials for uploads via something else
- Admin — avoid
And scope the credentials to only the bucket(s) you're serving from this CDN zone. On Cloudflare R2 use "Specify bucket" and pick the relevant bucket; on AWS use an IAM policy with Resource: "arn:aws:s3:::my-media/*". Don't give it access to your whole account.
Updating credentials (rotation)
When you regenerate the S3 credentials at the provider (security best practice every few months), update them in the dashboard the same way you set them up:
- Open the origin → edit → expand Private bucket (S3 SigV4) section
- Replace the Access key ID with the new one
- Paste the new Secret access key. Leaving this field blank keeps the current one — only fill it if you're rotating
- Save
We propagate the new credentials within ~30 seconds. Cached responses from before rotation keep being served from cache as long as their TTL is valid — only fresh fetches use the new credentials.
Cloudflare R2 jurisdictions
R2 has two jurisdictions: default and EU. Buckets are created in one or the other, and the endpoint URL is different:
- Default:
<account_id>.r2.cloudflarestorage.com - EU:
<account_id>.eu.r2.cloudflarestorage.com
If you point the CDN at the wrong jurisdiction, R2 returns 421 Misdirected Request even with correct credentials. Check your bucket's jurisdiction in the Cloudflare dashboard (R2 → your bucket → Settings → Location) and use the matching endpoint.
Also: API tokens are scoped to a jurisdiction. A token created from the default R2 view doesn't work against EU buckets and vice versa. If you migrate a bucket between jurisdictions, regenerate the token too.
Common errors and what they mean
| Status / Code | Meaning | Fix |
|---|---|---|
403 AccessDenied (XML body) | S3 received a valid signature but the credentials don't have permission on this bucket/object | Check the bucket scope of the token and the resource permissions |
403 SignatureDoesNotMatch | The signature math itself is wrong | Re-enter the secret access key; check the region is correct for the bucket |
400 InvalidArgument: Authorization | Request reached S3 without auth — credentials were missing entirely | Re-save the origin with credentials; verify SigV4 toggle is on |
404 NoSuchBucket | The first path segment doesn't match any bucket in your account | See "Path mapping" above |
404 NoSuchKey | Bucket is correct but the object doesn't exist at that path | Verify the exact key in your bucket — case matters |
421 Misdirected Request (Cloudflare R2) | Wrong jurisdiction endpoint, or wrong account ID in the URL | See "Cloudflare R2 jurisdictions" above |
503 Service Unavailable | Origin unreachable from the edge | Check your provider's status page; verify the endpoint URL |
The XML body in the response usually tells you the exact <Code> and <Message>. If you only get an HTML response, that means the error came from a layer in front of S3 (typically a routing / SNI mismatch — see the 421 case).
Constraints
- All S3-authenticated origins within the same zone must share the same credentials. The CDN signs per backend, not per server, so if you list two origins both with SigV4 enabled they need identical access key + region + endpoint. For separate buckets with separate credentials, create separate CDN zones.
- The secret access key is write-only. After you save the origin we don't display it again. If you forget it, regenerate at the provider and re-enter.
- GET / HEAD requests only. The CDN doesn't sign PUT/POST/DELETE — it's a delivery network, not an upload pipeline.
API reference
POST /cdn/zones/{uuid}/origins create origin
PATCH /cdn/zones/{uuid}/origins/{origin_uuid} update origin
Body fields for both:
{
"s3_auth_enabled": true|false,
"s3_access_key": "AKIAXXXXXXXXXXXXX",
"s3_secret_key": "<write-only, leave blank to keep current on PATCH>",
"s3_region": "us-east-1" // or empty for R2 (defaults to "auto")
}
s3_secret_key blank on PATCH = keep the persisted value. It's never returned in any GET response.
