Stream private S3 videos with CloudFront signed URLs and SST
How I stream videos from a private S3 bucket through CloudFront signed URLs: one SST deploy, a Lambda that signs URLs, a password lock, and the costs to watch.
By Shaheer Q. · · 6 min read
To stream videos from a private S3 bucket, put a CloudFront distribution in front of it that only accepts signed URLs, and have a small API hand those URLs to signed-in users. The bucket never becomes public, CloudFront caches the files near viewers, and the signing key never leaves your backend. I built this for my own video library in October 2026: one sst deploy creates the CDN, the signing keys, a Lambda API and a static site, and a password keeps everyone else out.
This post walks through the parts that matter and the two mistakes it saved me from repeating.
The architecture
Browser ──► Router (CloudFront)
├─ / → static site
└─ /api/* → Lambda: lists the bucket, returns signed URLs
Browser ──► Video CDN (CloudFront, signed URLs only) ──OAC──► private S3 bucket
There are two CloudFront distributions on purpose:
- The Router serves the site and the API from one domain. Same origin means the session cookie stays first-party and there is no CORS to configure.
- The video CDN sits in front of the bucket and rejects any request that is not signed with the app's key.
No database: sessions are a signed cookie, and the file list comes straight from S3.
Step 1: generate the signing key inside SST
CloudFront checks signatures against a public key in a key group that the distribution trusts. You need an RSA key pair for that. Instead of generating a PEM by hand and storing it somewhere, I let the deploy create it with the Pulumi TLS provider:
const signingKey = new tls.PrivateKey("CdnSigningKey", {
algorithm: "RSA",
rsaBits: 2048,
});
const publicKey = new aws.cloudfront.PublicKey("CdnPublicKey", {
encodedKey: signingKey.publicKeyPem,
});
const keyGroup = new aws.cloudfront.KeyGroup("CdnKeyGroup", {
items: [publicKey.id],
});
The private key is stored encrypted in SST's state and passed to the API through a linked resource, so there is no key file anywhere. Rotating it is a rename: change CdnSigningKey to CdnSigningKey2 (and the public key with it), redeploy, and SST creates a new pair, swaps it into the key group and deletes the old one.
The distribution then trusts that key group and allows only GET and HEAD:
defaultCacheBehavior: {
targetOriginId: "s3",
viewerProtocolPolicy: "redirect-to-https",
allowedMethods: ["GET", "HEAD"],
cachedMethods: ["GET", "HEAD"],
cachePolicyId: cachePolicy.id,
compress: false,
trustedKeyGroups: [keyGroup.id],
},
compress: false because video is already compressed; gzip would only cost CPU.
Step 2: keep the bucket private with Origin Access Control
CloudFront reads the bucket through Origin Access Control (OAC), which signs its requests to S3 with SigV4. The bucket keeps Block Public Access on. A bucket policy lets the CloudFront service read objects, but only for distributions in my AWS account:
if ($app.stage === "production") {
const { accountId } = aws.getCallerIdentityOutput();
new aws.s3.BucketPolicy("CdnBucketPolicy", {
bucket: BUCKET_NAME,
policy: $jsonStringify({
Version: "2012-10-17",
Statement: [{
Effect: "Allow",
Principal: { Service: "cloudfront.amazonaws.com" },
Action: "s3:GetObject",
Resource: `arn:aws:s3:::${BUCKET_NAME}/*`,
Condition: {
StringLike: {
"AWS:SourceArn": $interpolate`arn:aws:cloudfront::${accountId}:distribution/*`,
},
},
}],
}),
});
}
That if is the first lesson. A bucket has exactly one policy. My first version created the policy on every stage, so when I removed a dev stage, SST deleted the bucket policy with it, and every video in production returned Access Denied. Now only the production stage owns the policy. It admits any distribution in the account, so dev stages can still read the bucket through it, and removing a dev stage can't touch production.
The bucket itself was created outside this app, so the stack only looks it up with aws.s3.getBucketOutput and never manages its lifecycle.
Step 3: cache hard, because S3 reads cost money
My videos sit in S3 Standard-IA, which is cheaper to store but charges a retrieval fee of about $0.01 per GB read. Every cache miss reads from S3, so the cache policy is deliberately long:
const cachePolicy = new aws.cloudfront.CachePolicy("CdnCachePolicy", {
minTtl: 86400, // 1 day
defaultTtl: 2592000, // 30 days
maxTtl: 31536000, // 1 year
parametersInCacheKeyAndForwardedToOrigin: {
cookiesConfig: { cookieBehavior: "none" },
headersConfig: { headerBehavior: "none" },
queryStringsConfig: { queryStringBehavior: "none" },
enableAcceptEncodingGzip: false,
enableAcceptEncodingBrotli: false,
},
});
The cache key is the path only. The signature lives in the query string, and leaving query strings out of the key means every viewer's signed URL hits the same cached object.
I also turned on Origin Shield in the bucket's region. It adds a shared cache layer between the edge locations and S3, so a file is fetched from the bucket once rather than once per edge region. It has its own per-request charge, which is worth it here because S3 reads are the expensive part. PriceClass_100 keeps the distribution to the cheapest edge locations.
Retrieval is billed on bytes actually transferred, so watching the first five seconds of a 1 GB file costs a fraction of a cent, not the full gigabyte. Objects in Glacier Flexible Retrieval or Deep Archive would not play at all without a restore, so keep streamable files in Standard or Standard-IA.
Step 4: sign URLs in the API
The API is one Lambda behind the Router at /api. Listing uses ListObjectsV2 with a / delimiter, so S3 prefixes come back as folders:
const res = await s3.send(new ListObjectsV2Command({
Bucket: BUCKET,
Prefix: prefix,
Delimiter: "/",
ContinuationToken: token,
}));
It loops on NextContinuationToken, because one call returns at most 1,000 keys. The S3 client is created with followRegionRedirects: true, so it still works if the bucket lives in a different region from the function.
Playing a file returns a signed CloudFront URL:
import { getSignedUrl } from "@aws-sdk/cloudfront-signer";
const VIDEO_URL_TTL_SECONDS = 12 * 60 * 60;
const path = key.split("/").map(encodeURIComponent).join("/");
const url = getSignedUrl({
url: `https://${Resource.Cdn.domain}/${path}`,
keyPairId: Resource.Cdn.keyPairId,
privateKey: Resource.Cdn.privateKey,
dateLessThan: new Date(Date.now() + VIDEO_URL_TTL_SECONDS * 1000).toISOString(),
});
The second lesson is in that TTL. A video player does not download the file once: it makes range requests against the same URL for as long as you watch and every time you seek. If the URL expires halfway through a film, playback stops. The signed URL has to outlive the longest video, so I use 12 hours. The trade-off is that anyone who copies a URL can play that one file until it expires.
Encode each path segment separately, as above. Encoding the whole key would turn / into %2F and break folders.
Step 5: a password lock without a database
This is a personal app, so one shared password is enough. It is an SST secret, and login checks it and sets an HttpOnly cookie that carries an expiry and an HMAC of that expiry:
const expires = Math.floor(Date.now() / 1000) + SESSION_TTL_SECONDS;
const token = `${expires}.${sign(String(expires))}`;
// Set-Cookie: session=<token>; Path=/; Max-Age=604800; HttpOnly; Secure; SameSite=Strict
function sign(value: string): string {
return createHmac("sha256", Resource.SitePassword.value)
.update(`session:${value}`)
.digest("base64url");
}
Three details are worth copying:
- The HMAC key is the password. Changing the password invalidates every session, with no session table to clear.
- Compare in constant time. Both the password check and the signature check hash the two values and compare them with
timingSafeEqual, so response time leaks nothing about how close a guess was. - Slow down failures. A wrong password waits one second before answering. It is not real rate limiting, but it makes guessing slow.
Every route except login, logout and the session check returns 401 without a valid cookie.
The static site and caching
The front end is plain HTML, CSS and JavaScript with no build step. SST's StaticSite assumes hashed filenames and gives non-HTML files a one-year immutable cache by default. My filenames are not hashed, so I override it to make browsers revalidate:
assets: {
fileOptions: [
{ files: "**", cacheControl: "public,max-age=0,must-revalidate" },
{ files: "**/*.html", cacheControl: "max-age=0,no-cache,no-store,must-revalidate" },
],
},
Without this, a deploy that changes app.js would not reach browsers that loaded the old one.
Check it works
After npm run deploy (the first one takes several minutes while two distributions roll out), confirm the CDN rejects unsigned requests:
curl -I https://<cdn-domain>/some-file.mp4 # expect 403, MissingKey
Then sign in on the site URL and play something. If a file shows up but plays without sound, check its audio codec: browsers do not decode AC-3 or DTS. H.264 video with AAC audio plays in Chrome and Edge, even inside .mkv; Safari does not play .mkv.
What I would change for more than one user
- Real rate limiting on login, for example a counter per IP in DynamoDB, instead of a one-second delay.
- Shorter URLs or signed cookies. CloudFront also supports signed cookies, which grant access to many files at once. With per-user accounts, short-lived cookies are a better fit than 12-hour URLs that can be shared.
- Per-user accounts instead of one shared password, if anyone else ever uses it.
For one person's library, none of that is needed: there is no server and no database to look after. If you want the same infrastructure-as-code approach for a full Next.js app, I wrote up how this site runs on AWS with SST.
Frequently asked questions
What is the difference between a CloudFront signed URL and an S3 presigned URL?
An S3 presigned URL sends the viewer straight to the bucket, so every view is an S3 request. A CloudFront signed URL goes through the CDN: CloudFront checks the signature, serves cached copies from the edge, and only reads the bucket on a cache miss. For video that people watch more than once, CloudFront is cheaper and faster.
How long should a signed video URL be valid?
Longer than the longest video. Players fetch a video in many range requests against the same URL while you watch and seek, so a URL that expires mid-film stops playback. I use 12 hours, and the trade-off is that anyone holding the URL can play that one file until then.
Can browsers play .mkv files from S3?
It depends on the codecs inside, not the extension. H.264 video with AAC audio in an .mkv plays in Chrome and Edge; AC-3 or DTS audio plays silent there, and Safari does not play .mkv at all.
Does CloudFront need the S3 bucket to be public?
No. With Origin Access Control the bucket stays private and keeps Block Public Access on. A bucket policy allows the CloudFront service to read objects, limited to distributions in your account.