Guide · AWS CloudFront
MCP Server CloudFront Signed URLs — RSA key pair, canned vs custom policy, S3 OAC
When an MCP tool serves downloadable artifacts — schema snapshots, SLA PDF reports, private log exports — via S3 and CloudFront, you need signed URLs so that only authenticated callers can download them. A CloudFront signed URL embeds an RSA-SHA1 signature and an expiry timestamp in the query string. CloudFront validates the signature at the edge before forwarding the request to S3; the S3 bucket itself is locked to Origin Access Control (OAC) so that direct S3 requests without a valid CloudFront signature are denied. The MCP server generates signed URLs on demand using the CloudFront private key stored in Secrets Manager — the URL is handed to the tool caller who fetches the artifact directly from CloudFront. No presigned S3 URLs, no long-lived public buckets, no IAM credentials in the browser.
TL;DR
Create a CloudFront key group (upload public key, reference key group ID in the distribution), store the private key in Secrets Manager, and use @aws-sdk/cloudfront-signer to generate URLs. Use canned policy for simple fixed-expiry downloads (all you need 95% of the time). Use custom policy when you need IP restriction, a not-before time, or a wildcard resource pattern that covers multiple paths in one signature. Lock S3 with OAC — not OAI (deprecated) — and add a bucket policy that allows s3:GetObject only from the CloudFront distribution service principal. Signed URL expiry should match the human interaction window (5–15 minutes for interactive downloads, up to 24 hours for background jobs).
Key pair setup: CloudFront key groups and Secrets Manager
CloudFront signed URLs require an RSA key pair (2048-bit minimum). The public key is uploaded to CloudFront and associated with a key group; the private key is stored in Secrets Manager and loaded by the MCP server at runtime. Never embed the private key in environment variables or code — Secrets Manager rotation ensures the key can be rolled without redeployment.
# Step 1: Generate RSA key pair (2048-bit)
openssl genrsa -out cloudfront-private.pem 2048
openssl rsa -pubout -in cloudfront-private.pem -out cloudfront-public.pem
# Step 2: Upload public key to CloudFront (CLI)
aws cloudfront create-public-key \
--public-key-config '{
"CallerReference": "mcp-tool-key-2026",
"Name": "mcp-tool-downloads-key",
"EncodedKey": "'"$(cat cloudfront-public.pem)"'",
"Comment": "MCP server tool download key"
}'
# Returns: PublicKey.Id (e.g. K2X4WKWCB3JHFS)
# Step 3: Create key group referencing the public key ID
aws cloudfront create-key-group \
--key-group-config '{
"Name": "mcp-tool-download-keygroup",
"Items": ["K2X4WKWCB3JHFS"],
"Comment": "Key group for MCP artifact downloads"
}'
# Returns: KeyGroup.Id (e.g. EH1HDMB17M31T)
# Step 4: Store private key in Secrets Manager
aws secretsmanager create-secret \
--name "mcp/cloudfront/private-key" \
--description "CloudFront signing private key for tool downloads" \
--secret-string file://cloudfront-private.pem
# Delete local key files after storing
rm cloudfront-private.pem cloudfront-public.pem
In the CloudFront distribution, attach the key group to the cache behavior covering the download paths (/downloads/*). Set Restrict Viewer Access to Yes and specify the key group ID under Trusted Key Groups. Once this is set, unsigned requests to that path return 403.
Canned policy signed URLs: simple fixed-expiry downloads
A canned policy signed URL encodes exactly one parameter: the expiry timestamp (Expires as a Unix epoch integer). CloudFront rejects the URL after that timestamp. This is sufficient for the vast majority of MCP tool download scenarios.
import { getSignedUrl } from "@aws-sdk/cloudfront-signer";
import { SecretsManagerClient, GetSecretValueCommand } from "@aws-sdk/client-secrets-manager";
let cachedPrivateKey: string | null = null;
async function getPrivateKey(): Promise {
if (cachedPrivateKey) return cachedPrivateKey;
const sm = new SecretsManagerClient({ region: "us-east-1" });
const result = await sm.send(new GetSecretValueCommand({
SecretId: "mcp/cloudfront/private-key",
}));
cachedPrivateKey = result.SecretString!;
return cachedPrivateKey;
}
async function signedDownloadUrl(objectKey: string, expirySeconds = 600): Promise {
const privateKey = await getPrivateKey();
const keyPairId = process.env.CLOUDFRONT_KEY_PAIR_ID!; // K2X4WKWCB3JHFS
const distributionDomain = "d1234abcdefg.cloudfront.net";
const url = `https://${distributionDomain}/downloads/${encodeURIComponent(objectKey)}`;
const expiresAt = Math.floor(Date.now() / 1000) + expirySeconds;
return getSignedUrl({
url,
keyPairId,
privateKey,
dateLessThan: new Date(expiresAt * 1000).toISOString(), // ISO 8601 for canned policy
});
}
// Usage in MCP tool handler:
// const downloadUrl = await signedDownloadUrl("reports/tenant-123/sla-2026-09.pdf", 900);
// Return this URL to the tool caller — it expires in 15 minutes
Cache the private key in memory (as shown above) but implement rotation awareness: register a Secrets Manager rotation event handler that sets cachedPrivateKey = null so the next call fetches the fresh key. Without this, a rotated key will cause 403s until the Lambda/container restarts.
Custom policy signed URLs: IP restriction and wildcard paths
A custom policy lets you restrict signed URLs by source IP, add a not-before window, and cover multiple paths with one signature (useful when a tool download involves fetching multiple related files in sequence). The policy is a JSON document base64-encoded and included in the URL query string alongside the RSA signature.
import { getSignedUrl } from "@aws-sdk/cloudfront-signer";
async function signedUrlWithIpRestriction(
objectKey: string,
callerIp: string,
expirySeconds = 300
): Promise {
const privateKey = await getPrivateKey();
const keyPairId = process.env.CLOUDFRONT_KEY_PAIR_ID!;
const distributionDomain = "d1234abcdefg.cloudfront.net";
const url = `https://${distributionDomain}/downloads/${encodeURIComponent(objectKey)}`;
const now = Math.floor(Date.now() / 1000);
// Custom policy JSON — must be minified (no extra whitespace)
const policy = JSON.stringify({
Statement: [{
Resource: url,
Condition: {
DateLessThan: { "AWS:EpochTime": now + expirySeconds },
DateGreaterThan: { "AWS:EpochTime": now - 30 }, // 30s not-before window
IpAddress: { "AWS:SourceIp": `${callerIp}/32` },
},
}],
});
return getSignedUrl({
url,
keyPairId,
privateKey,
policy, // passing `policy` activates custom policy mode (vs `dateLessThan` for canned)
});
}
// Wildcard path custom policy — covers the entire tenant's download folder:
async function signedFolderAccess(tenantId: string, expirySeconds = 3600): Promise {
const url = `https://d1234abcdefg.cloudfront.net/downloads/${tenantId}/*`;
const now = Math.floor(Date.now() / 1000);
const policy = JSON.stringify({
Statement: [{
Resource: url, // wildcard — all files under /downloads/{tenantId}/
Condition: {
DateLessThan: { "AWS:EpochTime": now + expirySeconds },
},
}],
});
return getSignedUrl({
url: `https://d1234abcdefg.cloudfront.net/downloads/${tenantId}/`, // seed URL for signing
keyPairId: process.env.CLOUDFRONT_KEY_PAIR_ID!,
privateKey: await getPrivateKey(),
policy,
});
// The returned signed URL embeds the wildcard policy — any URL matching the pattern
// /downloads/{tenantId}/* with these query params will be accepted by CloudFront
}
Custom policy signed URLs are longer because they encode the full base64 policy JSON in the Policy query parameter. Total URL length can exceed 2,000 characters — verify that your MCP tool response channel and any downstream HTTP clients handle long URLs without truncation.
S3 OAC: locking the bucket to CloudFront only
Origin Access Control (OAC) replaces the older Origin Access Identity (OAI). With OAC, CloudFront signs every request to S3 using SigV4, and the S3 bucket policy allows only requests from the CloudFront distribution — direct requests to s3.amazonaws.com are denied. This ensures that even if an attacker discovers the S3 bucket name, they cannot download objects without a valid CloudFront signature.
# Create OAC for S3
aws cloudfront create-origin-access-control \
--origin-access-control-config '{
"Name": "mcp-tool-downloads-oac",
"Description": "OAC for MCP artifact download bucket",
"SigningProtocol": "sigv4",
"SigningBehavior": "always",
"OriginAccessControlOriginType": "s3"
}'
# Returns: OriginAccessControl.Id (e.g. E3NJH8CREXAMPLE)
# Attach OAC to the distribution origin config for the S3 bucket
# (done in the distribution config, not CLI-friendly — use console or CloudFormation)
# S3 bucket policy — allow GetObject only from this distribution's OAC
# Replace DISTRIBUTION_ID and BUCKET_NAME
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Service": "cloudfront.amazonaws.com"
},
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::BUCKET_NAME/downloads/*",
"Condition": {
"StringEquals": {
"AWS:SourceArn": "arn:aws:cloudfront::ACCOUNT_ID:distribution/DISTRIBUTION_ID"
}
}
},
{
"Effect": "Deny",
"Principal": "*",
"Action": "s3:*",
"Resource": [
"arn:aws:s3:::BUCKET_NAME/downloads/*",
"arn:aws:s3:::BUCKET_NAME"
],
"Condition": {
"StringNotEquals": {
"AWS:SourceArn": "arn:aws:cloudfront::ACCOUNT_ID:distribution/DISTRIBUTION_ID"
},
"Bool": {
"aws:SecureTransport": "true"
}
}
}
]
}
The explicit Deny statement in the bucket policy prevents any direct S3 access — even from within the same AWS account, which would otherwise bypass the Allow. This double-lock (OAC on CloudFront + Deny in bucket policy) is the recommended pattern for artifact buckets containing tenant data.
Failure modes reference
| Failure | Symptom | Fix |
|---|---|---|
| Key group not attached to cache behavior | Unsigned requests succeed — CloudFront doesn't enforce signature validation | Set "Restrict Viewer Access = Yes" and specify key group ID in the cache behavior for download paths |
| Private key rotated, in-memory cache stale | 403 errors begin after rotation; clear only on container restart | Implement a Secrets Manager rotation hook that sets cachedPrivateKey = null; or use short in-memory TTL (15 min) |
| Clock skew between server and CloudFront | Signed URLs return 403 "Request expired" immediately after generation | Add 30–60s padding to DateGreaterThan in custom policy; ensure server NTP is synced; CloudFront allows ±5s clock drift for canned policy |
| OAI instead of OAC on S3 origin | OAI still works but is deprecated; no SigV4 signing; cannot use SSE-KMS with OAI | Migrate to OAC: delete OAI from distribution, create OAC, update S3 bucket policy to use cloudfront.amazonaws.com principal |
| Custom policy URL truncated by client | 403 or 400 from CloudFront because Policy/Signature params are cut off | Check URL length — custom policy URLs can exceed 2KB; if hitting proxy limits, use signed cookies instead |
| Wildcard resource pattern too broad | Signed URL covers /downloads/* — all tenants' files accessible with one URL | Scope Resource in custom policy to /downloads/{tenantId}/* or specific object key |
| S3 bucket allows public access despite OAC | Direct S3 URLs bypass CloudFront and serve files without signature validation | Block all public access at bucket level (S3 Block Public Access); add explicit Deny in bucket policy scoped to non-OAC sources |