CloudFront signed URLs are my answer to a question that shows up right after every upload feature: how do the files get back out safely? In my article on the S3 presigned URL for uploads, PDFs and photos land directly in the bucket. For downloads, many teams reach for an S3 presigned URL again. That works, but it has limits you only notice once users complain about expired links or the S3 bill for outbound traffic starts to grow.
In this article you build a private download for a fictional city library, step by step: anyone who has borrowed an e-book gets a short-lived, signed CloudFront link from a Lambda function. Everyone else gets a 403. This time I describe the infrastructure with the Serverless Framework and write the code in TypeScript, and we verify the signature with tests that run without any AWS account.
In short
With CloudFront signed URLs, your Lambda function signs download links with its own private key, and CloudFront checks them at the edge using the matching public key from a key group. Validity no longer depends on the lifetime of IAM sessions, and the bucket stays completely private. The most common pitfall: without a trusted key group on the cache behavior, CloudFront serves the files to anyone who knows the address.
What you will learn in this article
- When CloudFront signed URLs fit better than S3 presigned URLs
- How key groups, public keys and origin access control work together
- A complete download endpoint with the Serverless Framework and TypeScript
- How to test signatures locally and track down typical 403 errors
Why not just use an S3 presigned URL?
An S3 presigned URL is a regular S3 address with a signature in the query string. It is created with the AWS credentials of whoever issues it. That is exactly the catch with Lambda: your function runs with temporary credentials from its IAM role. According to the S3 documentation, a presigned URL stops working when that role session expires, even if you asked for a longer lifetime. With long-term keys of an IAM user you get at most seven days, but I don't want to see such keys in any Lambda function.
Two practical points come on top. First, every download is served straight from S3, with no caching close to the readers. Second, the bucket name appears in every URL. That is not a security problem, but it leaks internals and makes moving the files later harder. CloudFront signed URLs solve both: you sign with your own key pair, which has nothing to do with IAM, and the files come from the CloudFront edge cache.
S3 presigned URL and CloudFront signed URL side by side
To be fair, the S3 option is often good enough. For an internal export that someone downloads five minutes after clicking, you don't need a CDN distribution. Once many people load the same files, links need to outlive a role session, or you already run CloudFront in front of your application, switching pays off.
How CloudFront signed URLs work
The principle is asymmetric cryptography. You create a key pair made of a private and a public key. You upload the public key to CloudFront and put it into a key group, which is simply a list of trusted keys. You attach this key group to a cache behavior of your distribution. From then on, CloudFront only accepts requests with a valid signature for that path.
Only your application knows the private key. The Lambda function uses it to build a so-called policy, a small JSON statement such as “this file, valid until 3:10 pm”, signs it and appends expiry, signature and key ID to the URL. CloudFront verifies the signature at the edge with the public key before anything is loaded from the cache or the origin.
AWS explicitly recommends key groups over the old CloudFront key pairs. Only the account's root user can manage those old key pairs in the console, while key groups can be managed via the API and infrastructure as code. According to the documentation, a key group holds up to five public keys by default, which is enough for a clean rotation. The key pair must be RSA 2048 or ECDSA 256, in PEM format.
Canned policy or custom policy?
CloudFront knows two kinds of policies. A canned policy only sets an expiry time and produces short URLs. A custom policy can also include a start time, IP ranges and wildcards in the path, so one signature covers several files. In return, the full policy travels base64-encoded in the URL, which makes it noticeably longer.
| Feature | Canned policy | Custom policy |
|---|---|---|
| Expiry time | yes | yes |
| Start time | no | optional |
| Restrict IP range | no | optional |
| One signature for several files | no | yes, via wildcards |
| URL length | short | longer |
For downloading a single file I use the canned policy. IP restrictions sound tempting, but in practice they often fail on mobile networks and corporate proxies, where the address changes between fetching the link and starting the download. If you want to unlock many files at once, say all pages of a comic, look at signed cookies instead. They follow the same principle, except the signature lives in a cookie rather than in the URL.
New since April 2026: SHA-256 instead of SHA-1
For a long time CloudFront only used SHA-1 for signatures. Since April 2026, CloudFront also supports SHA-256. To use it, you add the Hash-Algorithm=SHA256 parameter to the URL, or the CloudFront-Hash-Algorithm attribute for cookies. Existing links without this parameter keep using SHA-1. The AWS SDK for JavaScript already supports it: the @aws-sdk/cloudfront-signer package has an algorithm: "SHA256" option. For new projects I wouldn't configure anything else, especially since it costs nothing extra.
When CloudFront checks the expiry
One detail that often causes confusion: CloudFront checks the expiry at the time of the HTTP request. A large download that starts shortly before expiry will finish. If the connection drops and the client retries after expiry, it gets a 403. The same applies to range requests, which video players or PDF viewers use to load files piece by piece. So don't make the validity too tight. Ten minutes is a reasonable starting point for an e-book.
The example: private e-book downloads for a library
Our fictional city library lends e-books as PDFs. The files live in a private S3 bucket that only CloudFront may access. Readers sign in through an identity provider and send a JWT, a signed login token, to an HTTP API. The Lambda function checks in DynamoDB whether the book is currently on loan, loads the private key from SSM Parameter Store and returns a signed URL. The browser then downloads the PDF directly from CloudFront.
🎬 Flow of a signed download – this animated diagram only runs on my site: watch it on sascha-ms.de
The Lambda function never touches the file itself. It only decides whether someone may get it. That is the same pattern as uploads with presigned URLs, just in the other direction, and it keeps the function small, fast and cheap.
Step by step: CloudFront signed URLs with Lambda
Prerequisites
- An AWS account with permissions for CloudFront, S3, DynamoDB, SSM, IAM and API Gateway
- Node.js 24 and Serverless Framework v4 (
npm i -g serverless) - OpenSSL and AWS CLI v2
- An identity provider that issues JWTs, for example Amazon Cognito (you will need the issuer URL and audience in a moment)
- Basic knowledge of TypeScript and HTTP APIs
Step 1: Create and store the key pair
First you need the key pair. You store the private key as a SecureString in Parameter Store and the public key as a plain string. The Serverless Framework reads the public key from Parameter Store during deployment and passes it on to CloudFront.
openssl genrsa -out private_key.pem 2048 openssl rsa -pubout -in private_key.pem -out public_key.pem aws ssm put-parameter --name /library/dev/cloudfront/private-key \ --type SecureString --value file://private_key.pem aws ssm put-parameter --name /library/dev/cloudfront/public-key \ --type String --value file://public_key.pem rm private_key.pem I mean that last command. The private key does not belong in the repository, in an environment variable or on your laptop. Whoever holds it can issue links for every file behind this distribution.
Step 2: The infrastructure in serverless.yml
The serverless.yml contains the function, the HTTP API with a JWT authorizer and, as CloudFormation resources, the bucket, table, public key, key group, origin access control and distribution. Origin access control (OAC) makes CloudFront authenticate to S3 with SigV4, so the bucket needs no public access at all.
# serverless.yml service: library-downloads frameworkVersion: "4" provider: name: aws runtime: nodejs24.x region: eu-central-1 memorySize: 256 timeout: 10 httpApi: authorizers: readerJwt: type: jwt identitySource: $request.header.Authorization issuerUrl: ${param:jwtIssuerUrl} audience: - ${param:jwtAudience} environment: LOANS_TABLE: !Ref LoansTable DOWNLOAD_DOMAIN: !GetAtt DownloadDistribution.DomainName KEY_PAIR_ID: !Ref ReaderPublicKey PRIVATE_KEY_PARAM: /library/${sls:stage}/cloudfront/private-key iam: role: statements: - Effect: Allow Action: dynamodb:GetItem Resource: !GetAtt LoansTable.Arn - Effect: Allow Action: ssm:GetParameter Resource: arn:aws:ssm:${aws:region}:${aws:accountId}:parameter/library/${sls:stage}/cloudfront/private-key functions: signDownload: handler: src/handler.handler events: - httpApi: method: GET path: /books/{bookId}/download authorizer: name: readerJwt resources: Resources: BooksBucket: Type: AWS::S3::Bucket Properties: PublicAccessBlockConfiguration: BlockPublicAcls: true BlockPublicPolicy: true IgnorePublicAcls: true RestrictPublicBuckets: true LoansTable: Type: AWS::DynamoDB::Table Properties: BillingMode: PAY_PER_REQUEST AttributeDefinitions: - { AttributeName: readerId, AttributeType: S } - { AttributeName: bookId, AttributeType: S } KeySchema: - { AttributeName: readerId, KeyType: HASH } - { AttributeName: bookId, KeyType: RANGE } ReaderPublicKey: Type: AWS::CloudFront::PublicKey Properties: PublicKeyConfig: Name: library-${sls:stage}-reader-key-1 CallerReference: library-${sls:stage}-reader-key-1 EncodedKey: ${ssm:/library/${sls:stage}/cloudfront/public-key} ReaderKeyGroup: Type: AWS::CloudFront::KeyGroup Properties: KeyGroupConfig: Name: library-${sls:stage}-readers Items: - !Ref ReaderPublicKey BooksOriginAccess: Type: AWS::CloudFront::OriginAccessControl Properties: OriginAccessControlConfig: Name: library-${sls:stage}-books-oac OriginAccessControlOriginType: s3 SigningBehavior: always SigningProtocol: sigv4 DownloadDistribution: Type: AWS::CloudFront::Distribution Properties: DistributionConfig: Enabled: true Comment: Signed e-book downloads Origins: - Id: books DomainName: !GetAtt BooksBucket.RegionalDomainName OriginAccessControlId: !GetAtt BooksOriginAccess.Id S3OriginConfig: OriginAccessIdentity: "" DefaultCacheBehavior: TargetOriginId: books ViewerProtocolPolicy: https-only CachePolicyId: 658327ea-f89d-4fab-a63d-7e88639e58f6 # Managed-CachingOptimized TrustedKeyGroups: - !Ref ReaderKeyGroup BooksBucketPolicy: Type: AWS::S3::BucketPolicy Properties: Bucket: !Ref BooksBucket PolicyDocument: Statement: - Effect: Allow Principal: Service: cloudfront.amazonaws.com Action: s3:GetObject Resource: !Join ["", [!GetAtt BooksBucket.Arn, "/*"]] Condition: StringEquals: AWS:SourceArn: !Join ["", ["arn:aws:cloudfront::${aws:accountId}:distribution/", !Ref DownloadDistribution]] Outputs: BooksBucketName: Value: !Ref BooksBucket DownloadDomain: Value: !GetAtt DownloadDistribution.DomainName LoansTableName: Value: !Ref LoansTable Three places deserve a second look. TrustedKeyGroups on the default cache behavior is what turns on signature checks in the first place. The bucket policy allows s3:GetObject only for the CloudFront service and only for this exact distribution, checked via AWS:SourceArn. And in serverless.yml I use !Join instead of !Sub, because the Serverless Framework would try to resolve the ${…} syntax as its own variable. Almost everyone falls into that trap once.
Step 3: Sign the URL
The actual signing lives in a small, pure function without AWS calls. That makes it easy to test. It caps validity at one hour, encodes the path properly and uses SHA-256.
// src/sign-download.ts import { getSignedUrl } from "@aws-sdk/cloudfront-signer"; export interface SignDownloadInput { domain: string; // e.g. d111111abcdef8.cloudfront.net objectKey: string; // e.g. books/the-little-baker.pdf keyPairId: string; // ID of the CloudFront public key privateKey: string; // PEM, never written to logs validForSeconds: number; now?: Date; } export interface SignedDownload { url: string; expiresAt: string; } const MAX_VALIDITY_SECONDS = 60 * 60; // never hand out links longer than one hour export function signDownload(input: SignDownloadInput): SignedDownload { if (input.validForSeconds < 30 || input.validForSeconds > MAX_VALIDITY_SECONDS) { throw new Error(`validForSeconds must be between 30 and ${MAX_VALIDITY_SECONDS}`); } const now = input.now ?? new Date(); const expires = new Date(now.getTime() + input.validForSeconds * 1000); const path = input.objectKey.split("/").map(encodeURIComponent).join("/"); const url = getSignedUrl({ url: `https://${input.domain}/${path}`, keyPairId: input.keyPairId, privateKey: input.privateKey, dateLessThan: expires, algorithm: "SHA256", }); return { url, expiresAt: expires.toISOString() }; } Applying encodeURIComponent per path segment matters. CloudFront compares the signed resource with the URL that is actually requested. If the policy contains a space and the browser sends %20, the signature no longer matches.
Step 4: Load the private key
I fetch the key from Parameter Store on the first call and keep it in the execution environment's memory for five minutes. That saves calls and latency. At the same time, a key rotation takes effect within five minutes without redeploying the function.
// src/private-key.ts import { SSMClient, GetParameterCommand } from "@aws-sdk/client-ssm"; const ssm = new SSMClient({}); let cached: { value: string; loadedAt: number } | undefined; const TTL_MS = 5 * 60 * 1000; // reload after 5 minutes so key rotation takes effect export async function loadPrivateKey(parameterName: string): Promise<string> { if (cached && Date.now() - cached.loadedAt < TTL_MS) return cached.value; const res = await ssm.send( new GetParameterCommand({ Name: parameterName, WithDecryption: true }), ); const value = res.Parameter?.Value; if (!value) throw new Error(`Parameter ${parameterName} is empty`); cached = { value, loadedAt: Date.now() }; return value; } Step 5: The Lambda handler
The handler reads the user ID from the JWT claims that API Gateway has already validated. Then it checks the loan in DynamoDB and, if everything is fine, returns the URL and expiry time. I deliberately don't log the signed URL itself, because until it expires it works like an access key.
// src/handler.ts import type { APIGatewayProxyEventV2WithJWTAuthorizer, APIGatewayProxyResultV2 } from "aws-lambda"; import { DynamoDBClient } from "@aws-sdk/client-dynamodb"; import { DynamoDBDocumentClient, GetCommand } from "@aws-sdk/lib-dynamodb"; import { signDownload } from "./sign-download"; import { loadPrivateKey } from "./private-key"; const db = DynamoDBDocumentClient.from(new DynamoDBClient({})); const BOOK_ID = /^[a-z0-9-]{3,64}$/; const json = (statusCode: number, body: unknown): APIGatewayProxyResultV2 => ({ statusCode, headers: { "content-type": "application/json", "cache-control": "no-store" }, body: JSON.stringify(body), }); export const handler = async ( event: APIGatewayProxyEventV2WithJWTAuthorizer, ): Promise<APIGatewayProxyResultV2> => { const readerId = event.requestContext.authorizer.jwt.claims.sub as string | undefined; const bookId = event.pathParameters?.bookId ?? ""; if (!readerId) return json(401, { message: "Not signed in" }); if (!BOOK_ID.test(bookId)) return json(400, { message: "Invalid book ID" }); // Is the book currently on loan to this reader? const loan = await db.send( new GetCommand({ TableName: process.env.LOANS_TABLE, Key: { readerId, bookId } }), ); const dueAt = loan.Item?.dueAt as string | undefined; if (!dueAt || new Date(dueAt) < new Date()) { return json(403, { message: "No active loan for this book" }); } const privateKey = await loadPrivateKey(process.env.PRIVATE_KEY_PARAM!); const download = signDownload({ domain: process.env.DOWNLOAD_DOMAIN!, objectKey: `books/${bookId}.pdf`, keyPairId: process.env.KEY_PAIR_ID!, privateKey, validForSeconds: 10 * 60, }); console.log(JSON.stringify({ msg: "download signed", bookId, expiresAt: download.expiresAt })); return json(200, download); }; Step 6: Test locally without an AWS account
The test generates its own RSA key pair at runtime, signs a URL and verifies the signature against the canned policy using built-in Node.js functions. That way you test exactly what CloudFront does at the edge, just without a network.
// test/sign-download.test.ts import { generateKeyPairSync, verify } from "node:crypto"; import { describe, expect, it } from "vitest"; import { signDownload } from "../src/sign-download"; const { privateKey, publicKey } = generateKeyPairSync("rsa", { modulusLength: 2048, privateKeyEncoding: { type: "pkcs1", format: "pem" }, publicKeyEncoding: { type: "spki", format: "pem" }, }); const base = { domain: "d111111abcdef8.cloudfront.net", objectKey: "books/the-little-baker.pdf", keyPairId: "K2EXAMPLEKEYID", privateKey, validForSeconds: 600, now: new Date("2026-11-12T15:00:00Z"), }; // CloudFront uses URL-safe base64: + becomes -, = becomes _, / becomes ~ const fromCloudFrontBase64 = (s: string) => Buffer.from(s.replace(/-/g, "+").replace(/_/g, "=").replace(/~/g, "/"), "base64"); describe("signDownload", () => { it("creates a URL with expiry, key ID and SHA-256 marker", () => { const { url, expiresAt } = signDownload(base); const u = new URL(url); expect(u.hostname)Source:https://dev.to/sascha-ms/cloudfront-signed-urls-private-downloads-with-lambda-1kmo Related posts
Thirty plugins and one of them is abandoned: a plugin audit you can actually finish

One Jira Ticket Through an Org of AI Agents: The Architecture
Find Duplicate and Near-Duplicate Pages Across Your Sitemap with Python (Standard Library Only): Content Hashing, difflib Similarity, CSV Report

