Using signed URLs to secure your CDN content
Your application can authorize a download without making the file public. With AWS CloudFront, your server signs an object URL and gives it an expiration time. This recipe provides a runnable TypeScript signer and a check that the URL delivers the intended bytes while other access paths remain closed.
Understanding CDN security and signed URLs
A signed URL is a bearer credential: anyone who receives it can use it until it expires. Authorize the user in your application before issuing the URL, and choose the object on the server rather than signing arbitrary URLs supplied by a client.
CloudFront verifies the signature against a public key in a trusted key group attached to the matching cache behavior. The corresponding private key stays on your server. The example uses a canned policy, which binds access to one URL and an ending time; it does not bind the download to the person who requested it. See how CloudFront signed URLs work.
Separate viewer authorization from origin access
Two controls need to agree. The trusted key group controls requests from viewers to CloudFront. For a private S3 origin, an origin access control (OAC) and a bucket policy allow CloudFront to read the object while anonymous direct S3 requests remain denied. Configuring either control alone does not establish both protections.
This page focuses on the application signer. For infrastructure background, see our
S3 and CloudFront image-CDN guide.
Use AWS’s OAC setup instructions
for the origin policy and its distribution-specific AWS:SourceArn condition. Use a regular S3
bucket origin; an S3 website endpoint cannot use OAC.
Prerequisites for implementation
Have these ready before running the signer:
- A deployed CloudFront distribution with an HTTPS object URL and a known private object in S3. Keep a local copy of that object for the byte comparison below.
- A dedicated RSA-2048 key pair in PEM format. Upload the public key to CloudFront, put it in a trusted key group, and attach that group to the cache behavior serving the object. Follow AWS’s key-pair and trusted-key-group instructions.
- The public key’s ID and the matching private-key file, readable only by the server account. The key ID is neither an IAM access key nor a key group ID. Signing locally requires no AWS access key or SDK credential profile.
- A private bucket with S3 Block Public Access enabled, OAC configured to always sign origin requests, and a bucket policy granting this distribution read access. Wait for deployment before testing. Keep the signing server’s clock synchronized.
- Node.js 26 and separately installed Corepack. Use a maintained
Node.js patch release. The reproduction versions are Node.js 26.8.1, Yarn 4.12.0, and
@aws-sdk/cloudfront-signer3.1069.0; these are tested versions, not intrinsic minimums.
Implementing signed URLs with AWS CloudFront
Run this setup in Bash from a writable directory. It creates cloudfront-url-demo and leaves your
shell in its original directory. An existing directory is refused; choose a new name rather than
overwriting a project. The child lockfile and local Yarn settings isolate its package installation
from an enclosing workspace, including one using Plug’n’Play.
(
command -v node >/dev/null &&
command -v corepack >/dev/null &&
mkdir cloudfront-url-demo &&
cd cloudfront-url-demo &&
printf '%s\n' '{"private":true,"type":"module","packageManager":"yarn@4.12.0","dependencies":{"@aws-sdk/cloudfront-signer":"3.1069.0"}}' > package.json &&
printf '%s\n' 'nodeLinker: node-modules' 'ignorePath: true' 'enableGlobalCache: false' 'plugins: []' > .yarnrc.yml &&
touch yarn.lock &&
corepack yarn install
)
If installation fails, keep the project and inspect the package-manager error. After fixing the
cause, run corepack yarn install inside cloudfront-url-demo; do not repeat the directory-creation
step. Save this complete program as cloudfront-url-demo/signed-url.mts:
import { createPrivateKey } from 'node:crypto'
import { readFile } from 'node:fs/promises'
import { isAbsolute } from 'node:path'
import { getSignedUrl } from '@aws-sdk/cloudfront-signer'
async function main(): Promise<void> {
const args = process.argv.slice(2)
const [resourceUrl, lifetime = '300'] = args
const keyPairId = process.env.CLOUDFRONT_KEY_PAIR_ID
const keyFile = process.env.CLOUDFRONT_PRIVATE_KEY_FILE
if (args.length < 1 || args.length > 2 || !resourceUrl || !keyPairId || !keyFile) {
throw new Error('Missing configuration')
}
if (!/^[A-Za-z0-9]+$/u.test(keyPairId) || !isAbsolute(keyFile)) {
throw new Error('Invalid key configuration')
}
const url = new URL(resourceUrl)
if (
url.protocol !== 'https:' || url.username || url.password || url.search || url.hash ||
url.href !== resourceUrl
) {
throw new Error('Use an exact HTTPS object URL without a query or fragment')
}
const seconds = Number(lifetime)
if (!/^\d+$/u.test(lifetime) || !Number.isSafeInteger(seconds) || seconds < 1 || seconds > 3600) {
throw new Error('Lifetime must be an integer from 1 to 3600 seconds')
}
const privateKey = await readFile(keyFile, 'utf8')
const key = createPrivateKey(privateKey)
if (key.asymmetricKeyType !== 'rsa' || key.asymmetricKeyDetails?.modulusLength !== 2048) {
throw new Error('Use an RSA-2048 private key')
}
const signedUrl = getSignedUrl({
url: resourceUrl,
keyPairId,
privateKey,
dateLessThan: new Date(Date.now() + seconds * 1000).toISOString(),
})
process.stdout.write(`${signedUrl}\n`)
}
main().catch(() => {
console.error('Could not sign URL. Check the HTTPS URL, key ID, RSA-2048 PEM file, and 1–3600 second lifetime.')
process.exitCode = 1
})
Replace the three configuration values with your own. The second argument is a lifetime in seconds; omitting it selects 300 seconds. This example deliberately accepts only exact HTTPS URLs without query strings or fragments and caps the lifetime at one hour.
CLOUDFRONT_KEY_PAIR_ID='YOUR_PUBLIC_KEY_ID' \
CLOUDFRONT_PRIVATE_KEY_FILE='/absolute/path/to/private-key.pem' \
node cloudfront-url-demo/signed-url.mts \
'https://YOUR_DISTRIBUTION.cloudfront.net/private/guide.pdf' 300
Success writes one URL to standard output. Invalid configuration, an unreadable or malformed key, or an invalid lifetime produces a nonzero exit with no URL. A mismatched but well-formed key can still sign locally; only CloudFront can establish that the deployed behavior trusts it.
Testing and validation
Generating a signed string does not prove CDN enforcement. Save this as
cloudfront-url-demo/verify-private.mts. It runs the signer, downloads the complete response, and
compares it with your local original before testing the four denied access paths. Each HTTP check
has a 15-second deadline, including the response body, and redirects fail the check.
import assert from 'node:assert/strict'
import { execFileSync } from 'node:child_process'
import { readFile } from 'node:fs/promises'
import { setTimeout as delay } from 'node:timers/promises'
import { fileURLToPath } from 'node:url'
async function main(): Promise<void> {
const [objectUrl, directS3Url, originalFile, ...extra] = process.argv.slice(2)
assert(objectUrl && directS3Url && originalFile && extra.length === 0, 'Supply both URLs and the original file')
const expected = await readFile(originalFile)
const signerFile = fileURLToPath(new URL('./signed-url.mts', import.meta.url))
function sign(seconds: number): string {
return execFileSync(process.execPath, [signerFile, objectUrl, String(seconds)], {
encoding: 'utf8', timeout: 5000, stdio: ['ignore', 'pipe', 'pipe'],
}).trim()
}
async function check(url: string, allowed: boolean): Promise<void> {
const response = await fetch(url, { redirect: 'error', signal: AbortSignal.timeout(15_000) })
const bytes = Buffer.from(await response.arrayBuffer())
if (allowed) {
assert.equal(response.status, 200, 'Valid URL was denied')
assert.equal(Buffer.compare(bytes, expected), 0, 'Downloaded bytes differ from the original')
} else {
assert.equal(response.status, 403, 'Private access was not denied')
assert.notEqual(Buffer.compare(bytes, expected), 0, 'Denied response disclosed the object')
}
}
const valid = sign(300)
await check(valid, true)
await check(objectUrl, false)
const tampered = new URL(valid)
const signature = tampered.searchParams.get('Signature')
assert(signature, 'Missing signature')
tampered.searchParams.set('Signature', `${signature[0] === 'A' ? 'B' : 'A'}${signature.slice(1)}`)
await check(tampered.href, false)
const expired = sign(1)
await delay(Math.max(0, Number(new URL(expired).searchParams.get('Expires')) * 1000 - Date.now()) + 1500)
await check(expired, false)
await check(directS3Url, false)
console.log('Private delivery checks passed.')
}
main().catch(() => {
console.error('Private delivery check failed. Check the object bytes and the CloudFront and S3 access configuration.')
process.exitCode = 1
})
Run it with your CloudFront object URL, the direct S3 URL for the same object, and its local original:
CLOUDFRONT_KEY_PAIR_ID='YOUR_PUBLIC_KEY_ID' \
CLOUDFRONT_PRIVATE_KEY_FILE='/absolute/path/to/private-key.pem' \
node cloudfront-url-demo/verify-private.mts \
'https://YOUR_DISTRIBUTION.cloudfront.net/private/guide.pdf' \
'https://YOUR_BUCKET.s3.us-east-1.amazonaws.com/private/guide.pdf' \
'/absolute/path/to/original-guide.pdf'
The script prints Private delivery checks passed. only after the intended bytes arrive and all four denied requests return 403 without disclosing those bytes. The expiry check starts a new request after expiration. CloudFront checks expiry when a request begins; it does not terminate a download already in progress when that time passes.
Troubleshooting common issues
If the valid request fails, confirm the deployed cache behavior, the trusted group’s public key ID, and the matching private key first. A 403 alone does not distinguish a bad signature from an origin access problem. Check the object path, OAC, and bucket policy too. AWS’s 403 troubleshooting guide walks through those checks.
If the valid request returns different bytes, inspect the object URL and the local original, then check for an older cached object. If an unsigned request succeeds, inspect the cache behavior that actually matches that path. If direct S3 access succeeds, fix the bucket’s access policy before issuing download links. The verifier tests this one object and distribution path; repeat it for other protected behaviors.
Security best practices
Keep the private key out of browser bundles, source control, and logs. Mount it from your server’s secret storage as a restricted file. Treat the signed URL as a secret too: avoid recording it in analytics, request logs, or support messages. Share it only with the authorized recipient over HTTPS.
Choose a short lifetime that leaves enough time to begin the download. For rotation, deploy the new public key alongside the old key before changing the signer; retain the old key until outstanding URLs have expired, unless you intend to revoke them. AWS documents this overlap in its key rotation procedure.
Once private delivery passes, connect the signer to your application’s authorization decision. Return a URL only for the object the authenticated user is entitled to access, and keep the same delivery checks when changing the distribution or its origin policy.
Rather not run this yourself?
Transloadit’s Smart CDN transforms and delivers files on the fly.
