Guide · AWS Secrets Manager · Lambda
MCP Server Secrets Manager Lambda Caching — AWS Secrets Extension vs SDK cache, batch GetSecretValue
A Lambda-based MCP server that calls GetSecretValue on every tool invocation hits Secrets Manager API rate limits and adds 10–50ms of latency to every call. AWS provides the AWS Secrets Manager Lambda extension — a Lambda layer that runs as a sidecar process within the execution environment, caches secret values locally, and serves them via a local HTTP endpoint on port 2773. The alternative is an SDK-level in-memory cache: a module-scope object that survives across warm Lambda invocations, with TTL-based expiry and retry-on-auth-failure. Both approaches reduce Secrets Manager API call volume; they differ in who manages the cache lifecycle, how secrets are refreshed after rotation, and the cold start behavior.
TL;DR
Use the AWS Secrets Manager Lambda extension when you have many Lambda functions that share the same secrets (the extension caches per execution environment, not per invocation) and want zero-code caching. Use SDK-level in-memory cache when you need control over TTL, want to batch-load multiple secrets at startup, or are in an environment where Lambda layers are restricted. Both approaches must handle the post-rotation credential refresh window — implement retry-on-auth-failure with cache invalidation regardless of which caching approach you choose.
AWS Secrets Manager Lambda Extension
The Secrets Manager Lambda extension is a Lambda layer that runs as a process alongside your function handler within the Lambda execution environment. It starts before your handler is invoked (as part of the Lambda extension init phase), pre-fetches secrets configured via environment variables, and serves them via a local HTTP API on port 2773. Your function code fetches secrets from http://localhost:2773/secretsmanager/get?secretId=... instead of calling the Secrets Manager API directly.
# Terraform: attach the Secrets Manager Lambda extension layer
# Layer ARN varies by region — find current ARN at:
# https://docs.aws.amazon.com/secretsmanager/latest/userguide/retrieving-secrets_lambda.html
resource "aws_lambda_function" "mcp_server" {
function_name = "mcp-server-tool-handler"
runtime = "nodejs20.x"
handler = "index.handler"
role = aws_iam_role.mcp_lambda.arn
filename = "mcp-server.zip"
layers = [
# Secrets Manager extension — us-east-1 ARN (check docs for your region)
"arn:aws:lambda:us-east-1:177933569100:layer:AWS-Parameters-and-Secrets-Lambda-Extension:11"
]
environment {
variables = {
# Extension configuration
PARAMETERS_SECRETS_EXTENSION_CACHE_ENABLED = "true"
PARAMETERS_SECRETS_EXTENSION_CACHE_SIZE = "1000" # max cached items
SECRETS_MANAGER_TTL = "300" # seconds; 0 = no TTL
# AWS_SESSION_TOKEN is auto-populated by Lambda — extension uses it
}
}
}
# Lambda IAM role must have secretsmanager:GetSecretValue
resource "aws_iam_role_policy" "mcp_lambda_secrets" {
role = aws_iam_role.mcp_lambda.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Action = ["secretsmanager:GetSecretValue", "secretsmanager:DescribeSecret"]
Resource = "arn:aws:secretsmanager:us-east-1:ACCOUNT_ID:secret:mcp/*"
}]
})
}
// MCP server handler — fetching secrets via the extension HTTP API
// The extension handles caching; your code always fetches from localhost
const EXTENSION_PORT = 2773;
const SESSION_TOKEN = process.env.AWS_SESSION_TOKEN;
async function getSecretViaExtension(secretId) {
const url = `http://localhost:${EXTENSION_PORT}/secretsmanager/get?secretId=${encodeURIComponent(secretId)}`;
const resp = await fetch(url, {
headers: {
// Required: extension validates the session token to prevent SSRF
"X-Aws-Parameters-Secrets-Token": SESSION_TOKEN,
},
});
if (!resp.ok) {
const body = await resp.text();
throw new Error(`Secrets extension error ${resp.status}: ${body}`);
}
const data = await resp.json();
return JSON.parse(data.SecretString);
}
// Usage in tool handler
export const handler = async (event) => {
const creds = await getSecretViaExtension(
"arn:aws:secretsmanager:us-east-1:ACCOUNT_ID:secret:mcp/prod/db-creds-AbCdEf"
);
// creds.username, creds.password — served from extension cache if TTL not expired
return await handleMCPToolCall(event, creds);
};
SDK-level in-memory cache (without extension)
If you can't use Lambda layers or need more control over the cache lifecycle, an SDK-level in-memory cache at module scope is the alternative. Module-scope variables persist across warm Lambda invocations within the same execution environment. The cache must implement TTL expiry and handle post-rotation credential failures with a retry-and-invalidate pattern.
// Module-scope cache — persists across warm Lambda invocations
import { SecretsManagerClient, GetSecretValueCommand } from "@aws-sdk/client-secrets-manager";
const sm = new SecretsManagerClient({ region: process.env.AWS_REGION });
const cache = new Map(); // secretId → { value, expiresAt }
const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
async function getCachedSecret(secretId) {
const now = Date.now();
const cached = cache.get(secretId);
if (cached && now < cached.expiresAt) {
return cached.value;
}
const resp = await sm.send(new GetSecretValueCommand({
SecretId: secretId,
VersionStage: "AWSCURRENT",
}));
const value = JSON.parse(resp.SecretString);
cache.set(secretId, { value, expiresAt: now + CACHE_TTL_MS });
return value;
}
function invalidateCachedSecret(secretId) {
cache.delete(secretId);
}
// Retry-on-auth-failure pattern for post-rotation window
async function withSecretRetry(secretId, operation) {
let creds = await getCachedSecret(secretId);
try {
return await operation(creds);
} catch (err) {
if (isAuthenticationError(err)) {
invalidateCachedSecret(secretId);
creds = await getCachedSecret(secretId); // fresh fetch
return await operation(creds); // retry once
}
throw err;
}
}
function isAuthenticationError(err) {
// Postgres auth failure
if (err.code === "28P01") return true;
// MySQL auth failure
if (err.code === "ER_ACCESS_DENIED_ERROR") return true;
// HTTP 401/403 from backend APIs
if (err.status === 401 || err.status === 403) return true;
return false;
}
// Batch load multiple secrets at function cold start
async function preloadSecrets(secretIds) {
await Promise.all(secretIds.map(id => getCachedSecret(id)));
}
// Module init — preload during cold start (outside handler)
await preloadSecrets([
"arn:aws:secretsmanager:us-east-1:ACCOUNT_ID:secret:mcp/prod/db-creds-AbCdEf",
"arn:aws:secretsmanager:us-east-1:ACCOUNT_ID:secret:mcp/prod/slack-token-XyZwQr",
]);
Extension vs SDK cache: comparison
| Factor | Secrets Extension (layer) | SDK in-memory cache |
|---|---|---|
| Cache location | Extension process in execution environment | Module-scope Map/object in function process |
| Cache sharing | Shared across all function instances in same execution environment | Per function instance (no sharing across warm instances) |
| TTL configuration | SECRETS_MANAGER_TTL environment variable (seconds) | Programmatic (any value in your code) |
| Cold start impact | Extension init adds ~200ms to cold start (pre-fetches configured secrets) | No cold start overhead (lazy fetch on first invocation) |
| Rotation handling | Automatic on TTL expiry; no retry-on-auth-failure built in | Manual retry-on-auth-failure with cache invalidation |
| Lambda layer requirement | Yes — one layer per region | No — pure SDK code |
| Works with Lambda SnapStart | Session token in snapshot is invalid after restore; extension re-fetches at restore | Must re-initialize cache after SnapStart restore (afterRestore hook) |
Failure modes reference
| Failure | Symptom | Fix |
|---|---|---|
| Missing X-Aws-Parameters-Secrets-Token header | Extension returns 403; error body: "Token missing or invalid" | Always include the X-Aws-Parameters-Secrets-Token header with AWS_SESSION_TOKEN value in extension HTTP requests |
| Extension not initialized at handler invocation | localhost:2773 connection refused at function start | Extension is an internal extension — it starts before your handler; if connection refused, verify the layer is attached and the ARN is for your region |
| SDK cache not invalidated after rotation | Tool calls return auth errors until Lambda cold start or TTL expires | Implement retry-on-auth-failure with cache.delete(secretId) before retry; set CACHE_TTL_MS < rotation schedule minimum interval |
| SnapStart restore uses stale cached credentials | First invocation after SnapStart restore uses credentials cached at snapshot time | Clear credential cache in Lambda's afterRestore lifecycle hook; preload fresh secrets after restore |
| Extension fetches secret at cold start but IAM role lacks permission | Lambda cold start fails with extension permission error; function never initializes | Verify IAM role has secretsmanager:GetSecretValue on the secret ARN before attaching the extension layer |