Guide · AWS CloudFront
MCP Server CloudFront Signed Cookies — session-scoped access for tool download flows
When an MCP tool session grants access to multiple files under a path prefix — for example, all reports generated in a billing period or all diagnostic logs for a tenant — signed URLs per file are cumbersome. CloudFront signed cookies grant access to an entire URL pattern for the duration of a session without modifying individual file URLs. The MCP server sets three Set-Cookie headers in the authorization response (CloudFront-Policy, CloudFront-Signature, CloudFront-Key-Pair-Id); the browser or tool client then includes these cookies automatically on every subsequent request matching the distribution's domain. This is preferable to signed URLs when: the tool caller will request many files, the number of files is not known in advance, or you want to avoid exposing signature logic to the caller.
TL;DR
Set three Set-Cookie headers from the MCP server's auth endpoint: CloudFront-Policy (base64 custom policy JSON), CloudFront-Signature (RSA-SHA1 of the policy), CloudFront-Key-Pair-Id (public key ID in the key group). Use a custom policy with a wildcard resource (https://d*.cloudfront.net/downloads/TENANT/*). Mark all three cookies Secure; HttpOnly; SameSite=None and scope to the CloudFront distribution domain. For server-side MCP clients (no browser), forward cookies manually in HTTP requests — the same three headers sent as Cookie: values.
The three required cookies and what each contains
CloudFront signed cookies always use a custom policy (not a canned policy — canned is only available for signed URLs). The custom policy must be base64-encoded and included in the CloudFront-Policy cookie. CloudFront validates all three cookies together on every request to restricted paths.
import { getSignedCookies } from "@aws-sdk/cloudfront-signer";
import { SecretsManagerClient, GetSecretValueCommand } from "@aws-sdk/client-secrets-manager";
async function getPrivateKey(): Promise {
const sm = new SecretsManagerClient({ region: "us-east-1" });
const result = await sm.send(new GetSecretValueCommand({
SecretId: "mcp/cloudfront/private-key",
}));
return result.SecretString!;
}
interface SignedCookieSet {
"CloudFront-Policy": string;
"CloudFront-Signature": string;
"CloudFront-Key-Pair-Id": string;
}
async function grantTenantDownloadAccess(
tenantId: string,
expirySeconds = 3600
): Promise {
const privateKey = await getPrivateKey();
const keyPairId = process.env.CLOUDFRONT_KEY_PAIR_ID!;
const distributionDomain = process.env.CLOUDFRONT_DOMAIN!; // d1234abcdefg.cloudfront.net
const resourcePattern = `https://${distributionDomain}/downloads/${tenantId}/*`;
const now = Math.floor(Date.now() / 1000);
const policy = JSON.stringify({
Statement: [{
Resource: resourcePattern,
Condition: {
DateLessThan: { "AWS:EpochTime": now + expirySeconds },
// Optional: restrict by IP
// IpAddress: { "AWS:SourceIp": `${callerIp}/32` },
},
}],
});
// getSignedCookies returns an object with the three cookie name/value pairs
const cookies = getSignedCookies({
url: resourcePattern, // seed URL — used to derive the resource in the policy
keyPairId,
privateKey,
policy,
});
return cookies as SignedCookieSet;
}
The getSignedCookies function returns a plain object — not HTTP header strings. The calling code must serialize these into Set-Cookie headers with the appropriate attributes.
Setting cookies in the HTTP response: attributes and domain scoping
Each of the three CloudFront cookies must include Secure (HTTPS-only), HttpOnly (inaccessible to JavaScript), and a Domain attribute matching the CloudFront distribution domain. The SameSite=None attribute is required when the MCP server API domain differs from the CloudFront distribution domain (a cross-site scenario) — but only set it alongside Secure.
import type { FastifyReply } from "fastify";
async function setSignedCookiesOnResponse(
reply: FastifyReply,
tenantId: string
): Promise {
const cookies = await grantTenantDownloadAccess(tenantId, 3600);
const distributionDomain = process.env.CLOUDFRONT_DOMAIN!;
const expiresDate = new Date(Date.now() + 3600 * 1000).toUTCString();
// CloudFront requires all three cookies to be set — order doesn't matter
const cookieOptions = [
`Domain=${distributionDomain}`,
`Path=/downloads/${tenantId}/`, // scope to tenant's path only
`Expires=${expiresDate}`,
"Secure",
"HttpOnly",
"SameSite=None", // required for cross-origin distribution domain
].join("; ");
reply.header("Set-Cookie", `CloudFront-Policy=${cookies["CloudFront-Policy"]}; ${cookieOptions}`);
reply.header("Set-Cookie", `CloudFront-Signature=${cookies["CloudFront-Signature"]}; ${cookieOptions}`);
reply.header("Set-Cookie", `CloudFront-Key-Pair-Id=${cookies["CloudFront-Key-Pair-Id"]}; ${cookieOptions}`);
}
// Express equivalent — note header() appends for Set-Cookie (multiple headers allowed):
function setSignedCookiesExpress(res: Response, tenantId: string, cookies: SignedCookieSet): void {
const distributionDomain = process.env.CLOUDFRONT_DOMAIN!;
const maxAge = 3600;
for (const [name, value] of Object.entries(cookies)) {
res.cookie(name, value, {
domain: distributionDomain,
path: `/downloads/${tenantId}/`,
maxAge: maxAge,
secure: true,
httpOnly: true,
sameSite: "none",
});
}
}
A common mistake is setting the Domain to the MCP server's own API domain instead of the CloudFront distribution domain. Cookies scoped to the wrong domain are never sent to CloudFront — requests fail with 403 even though the client has the cookies.
Server-side MCP clients: forwarding cookies without a browser
Many MCP clients are servers themselves (agent runtimes, CI pipelines, background jobs) that call MCP tool endpoints programmatically. There is no browser cookie jar — signed cookies must be extracted from the auth response and forwarded as Cookie headers on each download request.
import axios from "axios";
interface McpSessionCookies {
policy: string;
signature: string;
keyPairId: string;
}
async function authenticateAndGetCookies(mcpBaseUrl: string, tenantToken: string): Promise {
// Call MCP auth endpoint which sets signed cookies in the response
const authResponse = await axios.post(
`${mcpBaseUrl}/auth/download-session`,
{ tenantToken },
{ withCredentials: false } // cookies won't auto-forward in axios; extract manually
);
// Parse Set-Cookie headers from the response
const setCookieHeaders = authResponse.headers["set-cookie"] ?? [];
const cookieMap: Record = {};
for (const cookieStr of setCookieHeaders) {
const [nameValue] = cookieStr.split(";");
const [name, value] = nameValue.split("=");
cookieMap[name.trim()] = value.trim();
}
return {
policy: cookieMap["CloudFront-Policy"],
signature: cookieMap["CloudFront-Signature"],
keyPairId: cookieMap["CloudFront-Key-Pair-Id"],
};
}
async function downloadArtifact(
distributionDomain: string,
objectKey: string,
cookies: McpSessionCookies
): Promise {
const url = `https://${distributionDomain}/downloads/${objectKey}`;
// Forward all three CloudFront cookies in the Cookie header
const cookieHeader = [
`CloudFront-Policy=${cookies.policy}`,
`CloudFront-Signature=${cookies.signature}`,
`CloudFront-Key-Pair-Id=${cookies.keyPairId}`,
].join("; ");
const response = await axios.get(url, {
headers: { Cookie: cookieHeader },
responseType: "arraybuffer",
});
return Buffer.from(response.data);
}
Cookie expiry and early revocation
CloudFront signed cookies have no server-side session table — CloudFront validates the embedded expiry and RSA signature on every request. There is no API to revoke an active set of signed cookies before the expiry time. This has two implications: set a short expiry (15 minutes for interactive sessions, up to 1 hour for background jobs), and for compliance scenarios where you need immediate revocation, pair cookies with a custom Lambda@Edge authorizer that checks an allowlist in DynamoDB or ElastiCache on every request.
// Lambda@Edge viewer-request for immediate revocation
// (viewer-request executes before CloudFront checks signed cookies)
exports.handler = async (event) => {
const request = event.Records[0].cf.request;
const cookies = parseCookies(request.headers.cookie);
// Extract tenant ID from the path or a custom cookie
const tenantId = extractTenantIdFromPath(request.uri);
// Check revocation list in DynamoDB (Global Table in us-east-1 for Lambda@Edge)
const isRevoked = await checkRevocationList(tenantId);
if (isRevoked) {
return {
status: "403",
statusDescription: "Forbidden",
body: JSON.stringify({ error: "Session revoked" }),
};
}
// CloudFront continues with signed cookie validation after this returns the request
return request;
};
// Important constraints for Lambda@Edge viewer-request:
// - Max 128 MB memory (vs 3 GB for origin-request)
// - Max 30s timeout
// - No environment variables — use SSM Parameter Store or hardcoded config
// - Must be deployed in us-east-1 regardless of distribution region
// - No VPC support — cannot reach private DynamoDB endpoint (use Global Table endpoint)
Failure modes reference
| Failure | Symptom | Fix |
|---|---|---|
| Cookie Domain set to API server instead of CloudFront domain | Browser never sends cookies to CloudFront; all download requests return 403 | Set Domain attribute to the CloudFront distribution domain (d*.cloudfront.net or custom domain) |
| Missing SameSite=None on cross-origin setup | Modern browsers block the cookie; cookie never reaches CloudFront | Add SameSite=None alongside Secure — required when API server and CDN are on different origins |
| Canned policy attempted for cookies | SDK error: canned policy is not supported for signed cookies | Always use custom policy (the policy JSON parameter) for signed cookies; only signed URLs support canned policy |
| Path scope too broad (/downloads/*) | Signed cookie grants access to all tenants' downloads if tenantId not in path scope | Scope cookie Path to /downloads/{tenantId}/ — one cookie set per tenant session |
| Three cookies not all set | CloudFront returns 403 even if two of three cookies are present and valid | All three cookies (Policy, Signature, Key-Pair-Id) must be present in every request to restricted paths |
| Cookie forwarding not enabled in cache behavior | CloudFront strips CloudFront-* cookies before reaching origin; signed cookie auth doesn't fire | Set "Cache Based on Selected Request Headers" and forward CloudFront-Policy, CloudFront-Signature, CloudFront-Key-Pair-Id in the cache behavior's cookie forwarding config |
| Server-side client parses Set-Cookie incorrectly | Extracted cookie value includes expires/path attributes; signature validation fails | Split on the first ";" only to get name=value, then take the portion after the first "=" |