Deep Dive · AWS CloudFront

CloudFront for MCP Servers: Signed URLs, Signed Cookies, Origin Shield, Cache Behaviors, and Functions — Five CDN Patterns

Published 2026-09-18 · 16 min read

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:

Consolidated failure modes

PatternFailureFix
Signed URLsPolicy JSON too long — URL exceeds 2KB; CloudFront returns 403Use canned policy (simpler, shorter URL) unless you need IP restriction or not-before window; test URL length with your longest expected policy
Signed CookiesSameSite=None rejected by browser without Secure flagAlways pair SameSite=None with Secure=true; HTTPS is required for cross-origin cookies
Signed CookiesCookies not sent to CloudFront domain; requests unauthorizedCookie Domain must be the CloudFront distribution domain, not the API server domain; browser cookie scope rules apply
Origin ShieldCache hit rate stays at 0% after enabling Origin ShieldInspect cache key — non-deterministic query params (request IDs, nonces) prevent cache reuse; narrow cache key with custom policy
Cache behaviorsCompress=true on SSE behavior breaks event streamSet Compress=false on all SSE and WebSocket behaviors; SSE requires uncompressed text/event-stream delivery
Cache behaviorsAPI catch-all placed before status behavior — status is never cachedReorder behaviors: most-specific paths first (/api/mcp/status/* before /api/*)
CloudFront FunctionsViewer-response Function attempts to change status code — silent 500Status code changes must happen in viewer-request; viewer-response can only modify response headers
CloudFront FunctionsFunction exceeds 2ms CPU time; 503 from CloudFront with x-cache: ErrorProfile 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.