Deep Dive · AWS CloudFront
CloudFront for MCP Servers: Signed URLs, Signed Cookies, Origin Shield, Cache Behaviors, and Functions — Five CDN Patterns
Most MCP server deployments treat CloudFront as a pass-through: put it in front of the origin, enable HTTPS, call it done. This misses most of CloudFront's value. The patterns that actually matter for a production MCP server are: signed URLs for delivering tool output artifacts (generated files, reports, exports) that should be time-limited and tenant-scoped; signed cookies for session-scoped multi-file downloads where a single authorization covers many objects; Origin Shield as a regional caching tier that collapses N edge cache misses into a single origin request; cache behaviors to route WebSocket, SSE, cacheable status, and static assets through different policies without conflating their cache semantics; and CloudFront Functions for sub-millisecond URL normalization and security header injection at the edge. This post synthesizes the production-critical decisions in each pattern.
Pattern 1: Signed URLs for time-limited tool artifact delivery
MCP servers that generate files — CSV exports, PDF reports, rendered images, ZIP archives — need a way to deliver those files to the calling client that is: (1) time-limited so stale links don't remain valid indefinitely, (2) tenant-scoped so one tenant cannot access another's files, and (3) bypassing the application server for delivery so a large file download doesn't consume an MCP handler thread. CloudFront signed URLs with S3 OAC (Origin Access Control) is the canonical solution.
The infrastructure: generated artifacts are uploaded to a private S3 bucket. CloudFront sits in front of the bucket. The MCP server generates a signed URL using the RSA private key of a CloudFront key group; the URL contains an encoded policy and signature that CloudFront validates at the edge before serving the S3 object. The S3 bucket policy denies direct access — all requests must come through CloudFront.
// MCP tool handler: generate a signed URL for a tenant artifact
import { getSignedUrl } from "@aws-sdk/cloudfront-signer";
import {
SecretsManagerClient,
GetSecretValueCommand,
} from "@aws-sdk/client-secrets-manager";
const sm = new SecretsManagerClient({});
// Cache the private key in memory — fetched from Secrets Manager once per cold start
let privateKeyCache = null;
async function getPrivateKey() {
if (privateKeyCache) return privateKeyCache;
const resp = await sm.send(new GetSecretValueCommand({
SecretId: process.env.CF_PRIVATE_KEY_SECRET_ARN,
}));
privateKeyCache = JSON.parse(resp.SecretString).privateKey;
return privateKeyCache;
}
// Tool: export_tenant_data — returns a time-limited download URL
export async function exportTenantData(tenantId, format) {
// 1. Generate the artifact (upload to S3)
const s3Key = `artifacts/${tenantId}/${Date.now()}.${format}`;
await uploadArtifactToS3(s3Key, tenantId, format);
// 2. Sign a URL — expires in 15 minutes
const privateKey = await getPrivateKey();
const url = getSignedUrl({
url: `https://${process.env.CF_DOMAIN}/artifacts/${tenantId}/${s3Key.split("/").pop()}`,
keyPairId: process.env.CF_KEY_PAIR_ID,
privateKey,
dateLessThan: new Date(Date.now() + 15 * 60 * 1000).toISOString(),
});
return { downloadUrl: url, expiresInSeconds: 900 };
}
Three signed URL pitfalls that catch teams at the CloudFront edge: (1) custom policy URLs with IP restrictions can exceed CloudFront's URL length limit (~2KB in query params) when the custom policy JSON is large; test with your longest expected policy payload. (2) The S3 bucket policy must use the cloudfront.amazonaws.com service principal with a condition on the CloudFront distribution ARN — the legacy OAI principal (AWS: arn:aws:iam::cloudfront:user/...) is being phased out. (3) Signed URLs are per-object — if the MCP tool generates 50 objects for a tenant, you either generate 50 URLs or switch to signed cookies.
Pattern 2: Signed Cookies for session-scoped multi-file downloads
When an MCP tool operation produces multiple files that the client needs to access in one session — a bulk export, a rendered report package, a multi-part dataset — signed URLs require N URL generations and N distinct authorizations. Signed cookies are the alternative: one authorization covers access to all objects matching a path prefix for the duration of the session.
// MCP server: issue signed cookies for a tenant's export session
import { getSignedCookies } from "@aws-sdk/cloudfront-signer";
async function issueExportSessionCookies(tenantId, res) {
const privateKey = await getPrivateKey();
const expiresAt = new Date(Date.now() + 60 * 60 * 1000); // 1 hour
// Custom policy: access all objects under /exports/{tenantId}/ for 1 hour
const policy = JSON.stringify({
Statement: [{
Resource: `https://${process.env.CF_DOMAIN}/exports/${tenantId}/*`,
Condition: {
DateLessThan: { "AWS:EpochTime": Math.floor(expiresAt.getTime() / 1000) },
},
}],
});
const cookies = getSignedCookies({
keyPairId: process.env.CF_KEY_PAIR_ID,
privateKey,
policy,
});
// Set the three required CloudFront cookies
// Domain MUST be the CloudFront domain, not the API server domain
const cookieOpts = {
domain: process.env.CF_DOMAIN, // e.g. "d1234abcd.cloudfront.net"
path: `/exports/${tenantId}/`,
secure: true,
httpOnly: true,
sameSite: "None", // Required for cross-origin requests from API domain to CF domain
expires: expiresAt,
};
res.cookie("CloudFront-Policy", cookies["CloudFront-Policy"], cookieOpts);
res.cookie("CloudFront-Signature", cookies["CloudFront-Signature"], cookieOpts);
res.cookie("CloudFront-Key-Pair-Id", cookies["CloudFront-Key-Pair-Id"], cookieOpts);
}
Four signed cookie constraints that differ from signed URLs: (1) Signed cookies only work with the custom policy format — the canned policy format is not supported for cookies. (2) The cookie Domain must match the CloudFront distribution domain, not the MCP server's API domain — this is why SameSite=None is required (cookies are sent cross-origin from API domain to CloudFront domain). (3) The cookie Path should be scoped to the tenant's path prefix, not /, to prevent the cookies from being sent on every CloudFront request. (4) CloudFront signed cookies have no revocation API — once issued, they are valid until DateLessThan. For immediate revocation, use Lambda@Edge at the viewer-request event with a DynamoDB allowlist that tracks active session tokens.
Pattern 3: Origin Shield — collapsing cache misses
When an MCP server's status pages (/api/mcp/status/{serverId}) see cache misses from multiple CloudFront edge locations simultaneously — common during a traffic spike when many users check the same server's status at once — each edge location sends an independent request to the origin. Without Origin Shield, a cache miss on a popular endpoint hits the origin O(PoP count) times per second; with Origin Shield, those misses are collapsed into a single request from the shield region to the origin.
# Terraform: enable Origin Shield on the CloudFront origin
resource "aws_cloudfront_distribution" "mcp" {
origin {
domain_name = aws_lb.mcp_api.dns_name
origin_id = "mcp-api-alb"
# Enable Origin Shield — choose region closest to your origin, not your users
origin_shield {
enabled = true
origin_shield_region = "us-east-1" # same region as your ECS/Lambda origin
}
custom_origin_config {
http_port = 80
https_port = 443
origin_protocol_policy = "https-only"
origin_ssl_protocols = ["TLSv1.2"]
}
}
# ... cache behaviors, viewer certificate, etc.
}
Three Origin Shield design decisions that determine effectiveness: (1) Choose the region closest to your origin, not your users. The shield sits between the edge PoPs and the origin — you want the origin→shield hop to be short, not the user→shield hop. (2) Cache key narrowing is critical: if the cache key includes request IDs, nonces, or per-user parameters that change every request, cache hit rate at the shield is zero and Origin Shield adds latency without reducing origin load. Strip all non-deterministic parameters from the cache key using a custom cache policy. (3) Origin Shield is independent of origin group failover — if you configure an origin group with a failover origin, Origin Shield applies to each origin in the group independently. Requests that miss at the shield still go to one specific origin (primary or failover), not both.
Pattern 4: Cache behavior ordering for mixed MCP workloads
An MCP server deployment typically serves at least five distinct traffic types from the same origin: WebSocket connections (for SSE transport), SSE event streams, cacheable status API responses, non-cacheable API calls, and static assets. Each type needs different CloudFront cache policy, origin request policy, and compression settings. CloudFront routes requests to the first matching cache behavior, evaluated in order from most-specific path pattern to the default behavior (catch-all).
# Recommended cache behavior order for an MCP server distribution
ordered_cache_behavior {
# 1. WebSocket — must be first; no caching, all headers forwarded
path_pattern = "/ws/*"
allowed_methods = ["GET", "HEAD"]
cached_methods = ["GET", "HEAD"]
cache_policy_id = "4135ea2d-6df8-44a3-9df3-4b5a84be39ad" # CachingDisabled
origin_request_policy_id = "b689b0a8-53d0-40ab-baf2-68738e2966ac" # AllViewer (passes all headers including Upgrade)
viewer_protocol_policy = "https-only"
compress = false # WebSocket frames must not be compressed by CloudFront
}
ordered_cache_behavior {
# 2. SSE streams — no caching, compress=false (compressed SSE breaks some clients)
path_pattern = "/sse/*"
allowed_methods = ["GET", "HEAD"]
cached_methods = ["GET", "HEAD"]
cache_policy_id = "4135ea2d-6df8-44a3-9df3-4b5a84be39ad" # CachingDisabled
origin_request_policy_id = "88a5eaf4-2fd4-4709-b370-b4c650ea3fcb" # CORS-S3Origin
viewer_protocol_policy = "https-only"
compress = false # CRITICAL: Compress=true breaks SSE text/event-stream
}
ordered_cache_behavior {
# 3. Cacheable MCP status — short TTL, narrow cache key
path_pattern = "/api/mcp/status/*"
allowed_methods = ["GET", "HEAD"]
cached_methods = ["GET", "HEAD"]
cache_policy_id = aws_cloudfront_cache_policy.mcp_status.id # 60s TTL, serverId only
viewer_protocol_policy = "https-only"
compress = true
}
ordered_cache_behavior {
# 4. API catch-all — no caching, forward Authorization header
path_pattern = "/api/*"
allowed_methods = ["DELETE", "GET", "HEAD", "OPTIONS", "PATCH", "POST", "PUT"]
cached_methods = ["GET", "HEAD"]
cache_policy_id = "4135ea2d-6df8-44a3-9df3-4b5a84be39ad" # CachingDisabled
origin_request_policy_id = "b689b0a8-53d0-40ab-baf2-68738e2966ac" # AllViewer (passes Authorization)
viewer_protocol_policy = "https-only"
compress = true
}
default_cache_behavior {
# 5. Static assets — long TTL, immutable
allowed_methods = ["GET", "HEAD"]
cached_methods = ["GET", "HEAD"]
cache_policy_id = "658327ea-f89d-4fab-a63d-7e88639e58f6" # CachingOptimized
viewer_protocol_policy = "redirect-to-https"
compress = true
}
The most common misconfiguration is placing the API catch-all (/api/*) before the status behavior (/api/mcp/status/*). CloudFront evaluates behaviors in array order — the API catch-all (with CachingDisabled) would match before the status behavior can cache anything. More-specific path patterns must always appear first in the behaviors array.
Pattern 5: CloudFront Functions for URL normalization and security headers
Two operations should run on every request through a CloudFront distribution serving MCP server SEO pages: URL normalization (trailing slash removal and query param alphabetization for cache key consistency) and security header injection (HSTS, CSP, X-Frame-Options on every response). Both are too lightweight to justify Lambda@Edge (with its 30s timeout, 128MB memory, Node.js cold starts, and $0.60/1M invocations price) but are a natural fit for CloudFront Functions (2ms CPU limit, no cold start, $0.10/1M invocations, global deployment from a single region).
// CloudFront Function — viewer-request event
// Trailing slash removal + query param sort (for cache key consistency)
function handler(event) {
var request = event.request;
var uri = request.uri;
if (uri !== "/" && uri.endsWith("/")) {
return {
statusCode: 301,
statusDescription: "Moved Permanently",
headers: {
location: { value: uri.slice(0, -1) },
"cache-control": { value: "public, max-age=31536000" },
},
};
}
// Sort query params for consistent cache key
if (request.querystring) {
var keys = Object.keys(request.querystring).sort();
var newQs = {};
for (var i = 0; i < keys.length; i++) {
newQs[keys[i]] = request.querystring[keys[i]];
}
request.querystring = newQs;
}
return request;
}
// CloudFront Function — viewer-response event
// Security header injection on every response (including cache hits)
function handler(event) {
var response = event.response;
var headers = response.headers;
headers["strict-transport-security"] = { value: "max-age=31536000; includeSubDomains; preload" };
headers["x-content-type-options"] = { value: "nosniff" };
headers["x-frame-options"] = { value: "DENY" };
headers["referrer-policy"] = { value: "strict-origin-when-cross-origin" };
headers["permissions-policy"] = { value: "camera=(), microphone=(), geolocation=()" };
return response;
}
Viewer-request Functions run on every request — including cache hits — before CloudFront checks its cache. This means the URL normalization Function runs even when the normalized URL is already cached: a trailing-slash request at a cache hit still gets the 301 redirect. This is correct behavior — without it, trailing-slash URLs would serve from cache as distinct entries with duplicated content, hurting SEO. Viewer-response Functions also run on every response including cache hits, so security headers are injected consistently regardless of whether the response came from the origin or the edge cache.
Two constraints to remember: (1) Viewer-response Functions cannot change the status code. If you need conditional redirects on response (e.g., redirect 200 to 301 based on a response header), use a viewer-request Function instead. (2) CloudFront Functions run on a restricted JavaScript runtime (cloudfront-js-2.0 supports most ES2020) — async/await, fetch, and require are not available. All logic must be synchronous and self-contained within the 2KB function size limit.
Pattern interactions and sequencing
These five patterns interact in ways that require careful sequencing:
- Signed URLs + signed cookies conflict: a request that has both signed URL query parameters and signed cookie headers — CloudFront honors the signed URL and ignores the cookies for that request. Don't mix patterns for the same resource type.
- Origin Shield + CloudFront Functions: Functions run at the edge PoP, before the request reaches Origin Shield. URL normalization happens at the edge; the normalized URL is what Origin Shield uses as the cache key. This is correct — it means Origin Shield sees only normalized URLs and has a higher hit rate.
- Cache behaviors + Origin Shield: Origin Shield applies to the entire origin, not per behavior. If SSE paths (Compress=false, CachingDisabled) share an origin with static asset paths (CachingOptimized), Origin Shield applies to requests that miss in both cases — but SSE misses pass through immediately since TTL=0.
- Security headers + API responses: attach the security header Function only to cache behaviors that serve HTML pages. Attaching it to API behaviors adds unnecessary latency and may set incorrect Content-Security-Policy on JSON API responses that don't need it.
Consolidated failure modes
| Pattern | Failure | Fix |
|---|---|---|
| Signed URLs | Policy JSON too long — URL exceeds 2KB; CloudFront returns 403 | Use canned policy (simpler, shorter URL) unless you need IP restriction or not-before window; test URL length with your longest expected policy |
| Signed Cookies | SameSite=None rejected by browser without Secure flag | Always pair SameSite=None with Secure=true; HTTPS is required for cross-origin cookies |
| Signed Cookies | Cookies not sent to CloudFront domain; requests unauthorized | Cookie Domain must be the CloudFront distribution domain, not the API server domain; browser cookie scope rules apply |
| Origin Shield | Cache hit rate stays at 0% after enabling Origin Shield | Inspect cache key — non-deterministic query params (request IDs, nonces) prevent cache reuse; narrow cache key with custom policy |
| Cache behaviors | Compress=true on SSE behavior breaks event stream | Set Compress=false on all SSE and WebSocket behaviors; SSE requires uncompressed text/event-stream delivery |
| Cache behaviors | API catch-all placed before status behavior — status is never cached | Reorder behaviors: most-specific paths first (/api/mcp/status/* before /api/*) |
| CloudFront Functions | Viewer-response Function attempts to change status code — silent 500 | Status code changes must happen in viewer-request; viewer-response can only modify response headers |
| CloudFront Functions | Function exceeds 2ms CPU time; 503 from CloudFront with x-cache: Error | Profile with test-function command in DEVELOPMENT stage; eliminate loops over large datasets; Functions are not for business logic |
Monitoring CloudFront for MCP server deployments
CloudFront provides standard access logs (to S3) and real-time logs (to Kinesis Data Streams). For MCP server deployments, the critical metrics to watch are: cache hit rate per behavior (low hit rate on status endpoints means the cache key is too wide or TTL too short), origin error rate (4xx/5xx from origin reaching end users), and Function error rate (Functions that throw or exceed 2ms appear as 503s from CloudFront, not origin errors). Enable CloudFront distribution metrics in CloudWatch — the CacheHitRate, OriginLatency, and 5xxErrorRate metrics are the first three monitors to configure for any production MCP server distribution. See the cache behaviors deep-dive for per-behavior CloudWatch metric configuration and the CloudFront Functions guide for the development/live stage testing workflow.