Every SaaS ends up storing files users shouldn't share with the world: invoices, contracts, exports, course videos, profile photos. The two common setups both have a catch. A public bucket leaks anything whose URL leaks. S3 presigned URLs keep files private, but every download comes straight from the S3 bucket's Region, at S3's internet transfer price, with no CDN and no cache.
The better setup puts CloudFront in front of a private bucket: the bucket only answers CloudFront, CloudFront only serves requests carrying a valid signature, and files are cached at edge locations near your users. Here's how to build it, how to sign URLs in Node.js, and how to keep the signatures from wrecking your cache hit rate.
Part of my guide to AWS infrastructure for small SaaS teams.
TL;DR#
- Keep the bucket private, with Block Public Access on, and let CloudFront read it through Origin Access Control (OAC), not the legacy OAI.
- Restrict the CloudFront behaviour to a trusted key group and sign URLs in your API with
@aws-sdk/cloudfront-signer, after checking the user may see the file. - Use signed URLs for single files and signed cookies for many (video segments, a whole folder).
- Keep query strings out of the cache key, or every signed URL becomes its own cache miss.
- Set
Cache-Controlper kind of file, use new keys instead of invalidations, and send uploads straight to S3 with presigned POST.
The setup at a glance#
Browser ──(1) "download invoice 42"──▶ API: checks the user may see it, signs a URL
│
└──(2) GET files.example.com/invoices/42.pdf?Expires=…&Signature=…&Key-Pair-Id=…
▼
CloudFront ── checks the signature at the edge (every request, cache hit or not)
│ cache miss
▼
S3 bucket (private) ── answers only this distribution, through OAC
Your API decides who may download a file; CloudFront enforces it, without your API in the download path.
Which kind of signed link?#
| S3 presigned URL | CloudFront signed URL | CloudFront signed cookies | |
|---|---|---|---|
| Served from | The bucket's Region | The nearest CloudFront edge | The nearest CloudFront edge |
| Cached | No | Yes | Yes |
| Covers | One object | One object, or a wildcard path with a custom policy | Many objects (a path with a wildcard) |
| Signed with | AWS credentials (IAM) | Your own key pair, registered with CloudFront | Your own key pair |
| Longest lifetime | 7 days, and less if signed with temporary role credentials | Whatever you set | Whatever you set |
| Also good for | Uploads (PUT or POST) | Downloads | Video streaming, static sections of a site |
The pattern I use: CloudFront signed URLs or cookies for downloads, S3 presigned POST for uploads.
Step 1: a private bucket behind Origin Access Control#
Create the bucket with Block Public Access left on (the default). In CloudFront, add the bucket as an origin and create an Origin Access Control for it. OAC signs CloudFront's requests to S3 with SigV4, works in every Region and with SSE-KMS encryption, and replaces the older Origin Access Identity.
Then let only that distribution read the bucket:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "AllowCloudFrontRead",
"Effect": "Allow",
"Principal": { "Service": "cloudfront.amazonaws.com" },
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::acme-private-files/*",
"Condition": {
"StringEquals": { "AWS:SourceArn": "arn:aws:cloudfront::123456789012:distribution/EDFDVBD6EXAMPLE" }
}
}
]
}
The AWS:SourceArn condition matters: without it, any CloudFront distribution in any account could read your bucket. AWS's guide to restricting access to an S3 origin has the details.
Without s3:ListBucket, a request for a file that doesn't exist returns 403, not 404. That's usually what you want for private files, since it doesn't reveal which names exist, but it confuses everyone the first time.
Step 2: a key pair and a key group#
CloudFront checks signatures against public keys you register with it. Generate an RSA key pair:
openssl genrsa -out cloudfront-private.pem 2048
openssl rsa -pubout -in cloudfront-private.pem -out cloudfront-public.pem
In the CloudFront console, under Key management:
- Add
cloudfront-public.pemas a public key. Note its ID, something likeK2JCJMDEHXQW5F. - Create a key group containing it.
- In the distribution's behaviour for these files, set Restrict viewer access to Yes, with that trusted key group.
Store the private key in Secrets Manager or SSM Parameter Store (as a SecureString), and load it into your API at startup. It never goes near the frontend, the repository or a Docker image.
Key groups replace the old CloudFront key pairs that only the root user could create; don't use those.
Step 3: sign URLs in Node.js#
AWS's @aws-sdk/cloudfront-signer package does the signing:
import { getSignedUrl } from '@aws-sdk/cloudfront-signer';
const FILES_URL = 'https://files.example.com';
const KEY_ID = process.env.CLOUDFRONT_KEY_ID; // the public key's ID, e.g. K2JCJMDEHXQW5F
const PRIVATE_KEY = process.env.CLOUDFRONT_PRIVATE_KEY; // PEM text, loaded from Secrets Manager
// Sign exactly the URL the browser will request, with each path segment encoded.
export function signedFileUrl(key, seconds = 300) {
const path = key.split('/').map(encodeURIComponent).join('/');
return getSignedUrl({
url: `${FILES_URL}/${path}`,
keyPairId: KEY_ID,
privateKey: PRIVATE_KEY,
dateLessThan: new Date(Date.now() + seconds * 1000).toISOString(),
});
}
The authorization check is the part that matters, and it belongs in your API:
app.get('/invoices/:id/download', requireUser, async (req, res) => {
const invoice = await invoices.findOne({ id: req.params.id, accountId: req.user.accountId });
if (!invoice) return res.sendStatus(404);
res.set('Cache-Control', 'no-store');
return res.redirect(302, signedFileUrl(invoice.fileKey));
});
Short lifetimes are fine for links that are used immediately: CloudFront checks the expiry when a request starts, so a large download that began in time finishes even if the URL expires halfway through.
That's a canned policy: one exact URL and an expiry. A custom policy can cover a wildcard path, add a start time or limit the signer to an IP range:
const policy = JSON.stringify({
Statement: [
{
Resource: `${FILES_URL}/courses/${courseId}/*`,
Condition: { DateLessThan: { 'AWS:EpochTime': Math.floor(Date.now() / 1000) + 3600 } },
},
],
});
const url = getSignedUrl({ url: `${FILES_URL}/courses/${courseId}/intro.mp4`, keyPairId: KEY_ID, privateKey: PRIVATE_KEY, policy });
Signed cookies for many files#
A video player requesting hundreds of HLS segments, or a page loading dozens of private images, shouldn't need a signed URL for each. Signed cookies carry one custom policy for a whole path:
import { getSignedCookies } from '@aws-sdk/cloudfront-signer';
const cookies = getSignedCookies({ url: `${FILES_URL}/courses/${courseId}/*`, keyPairId: KEY_ID, privateKey: PRIVATE_KEY, policy });
for (const [name, value] of Object.entries(cookies)) {
res.cookie(name, value, { domain: '.example.com', path: '/', secure: true, httpOnly: true, sameSite: 'lax' });
}
Cookies only reach CloudFront if it serves a domain they're set for, so give the distribution a custom domain under yours (files.example.com, with the cookie on .example.com); the default *.cloudfront.net name won't receive them. One set of cookies applies per domain and path, so one user can't hold two different policies for the same path at once.
Caching done right#
Signed content can still be cached: CloudFront validates the signature on every request before it looks in the cache, so a cached file is only ever served to someone with a valid signature. Three things decide whether caching works.
1. Keep the signature out of the cache key. The signature travels in the query string (Expires, Policy, Signature, Key-Pair-Id). If the behaviour's cache policy includes all query strings, every signed URL, for every user, every time, is a different cache entry and a guaranteed miss. Use the managed CachingOptimized policy, which leaves query strings, headers and cookies out of the key and turns on gzip and Brotli, or a custom policy that allow-lists only the query strings your origin really uses.
2. Send the right Cache-Control from S3. CloudFront and browsers follow the Cache-Control metadata you set on each object:
| Kind of file | Cache-Control |
Why |
|---|---|---|
Versioned build assets (app.3f9c2a.js) |
public, max-age=31536000, immutable |
A change gets a new file name, so the old one never changes |
Private uploads at unique keys (uploads/<uuid>.pdf) |
max-age=86400 |
Cached at the edge, still signed per request |
Files that change under the same key (avatar.png) |
max-age=300, or put a version in the key |
Short enough that changes show up soon |
| HTML | no-cache |
Always revalidated, so deploys show up at once |
Don't mark private files private or no-store: CloudFront then won't keep them at the edge, and every request goes back to S3. Set the header when you upload, with --cache-control on aws s3 cp or CacheControl on the SDK's PutObjectCommand.
3. Change keys instead of invalidating. CloudFront invalidations are free for the first 1,000 paths a month and $0.005 per path after that, and they take a minute or more to spread. A new key (avatar-v2.png, or a content hash in the name) is instant and free.
CloudFront compresses responses itself when the cache policy enables it and the file is between 1,000 bytes and 10 MB, so you don't need to store gzipped copies in S3.
Uploads: straight to S3 with presigned POST#
Uploads shouldn't pass through your API either. A presigned POST lets the browser upload directly to S3, with conditions your API sets: the exact key, the content type and a size limit.
import { S3Client } from '@aws-sdk/client-s3';
import { createPresignedPost } from '@aws-sdk/s3-presigned-post';
const s3 = new S3Client({ region: 'us-east-1' });
const ALLOWED = new Set(['image/png', 'image/jpeg', 'application/pdf']);
export async function uploadForm(accountId, contentType) {
if (!ALLOWED.has(contentType)) throw new Error('Unsupported file type');
const key = `uploads/${accountId}/${crypto.randomUUID()}`;
const { url, fields } = await createPresignedPost(s3, {
Bucket: 'acme-private-files',
Key: key,
Conditions: [
['content-length-range', 1, 10 * 1024 * 1024], // 1 byte to 10 MB
['eq', '$Content-Type', contentType],
],
Fields: { 'Content-Type': contentType, 'Cache-Control': 'max-age=86400' },
Expires: 300,
});
return { key, url, fields };
}
The browser posts a form with every field from fields, and the file last. Add a CORS rule to the bucket allowing POST from your app's origin. POST is the better fit for browser uploads because S3 enforces a size range; a presigned PUT can only pin one exact length.
An S3 presigned URL is valid for at most 7 days, and if it was signed with temporary credentials, such as an instance role's, it stops working when those expire, often within hours. Sign uploads just before they happen, never ahead of time.
Gotchas#
- 403 "Missing Key-Pair-Id query parameter or cookie value" comes from CloudFront: the behaviour is restricted but the request isn't signed, or the cookies aren't reaching your domain.
- 403 "Access Denied" in S3's XML format comes from the bucket: the bucket policy, the OAC, or a file that doesn't exist.
- Clocks. Expiry is checked against CloudFront's clock using the time your server put in the policy, so a server with a drifting clock signs URLs that are already expired. Keep time sync on.
- Video seeking makes new range requests, each needing a valid signature. Use signed cookies, or URLs that live as long as the viewing session.
- Rotating keys. Add the new public key to the key group, switch the API to sign with it, and remove the old key only once the longest-lived URL signed with it has expired.
The checklist#
- Bucket private, Block Public Access on
- CloudFront reads it through OAC; the bucket policy checks
AWS:SourceArn - The behaviour restricts viewer access to a trusted key group
- The private key lives in Secrets Manager or Parameter Store, never in code or images
- The API checks authorization before signing; links live minutes, not days
- Signed cookies for video and many-file paths, on a custom domain
- Query strings stay out of the cache key (CachingOptimized or an allow-list)
-
Cache-Controlis set on upload for each kind of file - New keys instead of invalidations
- Uploads go straight to S3 with presigned POST and a size limit
FAQ#
What is the difference between an S3 presigned URL and a CloudFront signed URL?#
An S3 presigned URL is signed with AWS credentials and downloads directly from the bucket, with no caching, for at most 7 days. A CloudFront signed URL is signed with your own key registered with CloudFront, is served and cached at CloudFront's edge locations, and can have any expiry or a custom policy covering a wildcard path. Use CloudFront signed URLs for downloads and S3 presigned URLs or POSTs for uploads.
Should I use Origin Access Control or Origin Access Identity?#
Origin Access Control. AWS recommends it over the legacy Origin Access Identity: it supports every AWS Region, buckets encrypted with SSE-KMS, and all HTTP methods. Pair it with a bucket policy that allows cloudfront.amazonaws.com to read the bucket only when AWS:SourceArn is your distribution.
Can CloudFront cache content behind signed URLs?#
Yes. CloudFront checks the signature on every request before looking in its cache, so cached files are still only served to valid signatures. To get cache hits, keep query strings out of the cache key, because the signature is in the query string and would otherwise make every signed URL a separate cache entry.
Should I use signed URLs or signed cookies?#
Signed URLs for individual files, like a download link for one invoice. Signed cookies when a user needs many files under one path, like video segments for HLS streaming or a folder of images, because one set of cookies covers all of them. Cookies need CloudFront on a custom domain that shares a parent domain with your app.
How long should a signed URL last?#
As short as the use allows: a few minutes for a download link created when the user clicks, longer only for things like video sessions. CloudFront checks the expiry when a request starts, so a download that began before expiry finishes even if it outlasts the link.
Related reading#
- AWS infrastructure for small SaaS teams: what you actually need
- Bash scripts to automate Postgres backups to S3
- How we cut infrastructure costs by 35%, including S3 lifecycle rules
- Stripe webhooks that never double-charge, for another place where the server, not the browser, must decide
