Guide · AWS DevOps
MCP Server Secrets Manager — AWS Secrets Manager rotation, staging labels, cross-account access
AWS Secrets Manager stores MCP server credentials (database passwords, API keys, OAuth tokens) with built-in automatic rotation, version staging, and cross-account access that SSM Parameter Store cannot provide. Four Secrets Manager patterns cause the most friction for MCP server teams: rotation staging labels (Secrets Manager stages a new secret as AWSPENDING while testing it, then promotes it to AWSCURRENT and demotes the old value to AWSPREVIOUS — this three-label lifecycle is implemented by a Lambda rotation function with four required steps: createSecret, setSecret, testSecret, finishSecret; missing any step causes the rotation to fail silently and leave the secret un-rotated), ECS tasks don't refresh injected secrets (secrets injected via the ECS task definition secrets array are fetched by the ECS agent at task start and injected as environment variables — they are not refreshed during the task's lifetime; rotating a database password requires a force-new-deployment to replace tasks with fresh environment), rotation Lambda VPC placement (if the target database is in a VPC, the rotation Lambda must be in the same VPC with a security group rule permitting outbound to the DB port; the Lambda also needs a route to the Secrets Manager API endpoint — use a VPC endpoint for Secrets Manager, or a NAT gateway; without one of these, rotation succeeds creating the pending secret but fails at setSecret or testSecret with a timeout), and the 7-day recovery window on deletion (deleting a secret does not immediately remove it — by default there is a 7-30 day recovery window; creating a new secret with the same name fails during the recovery window with "already scheduled for deletion"; use --recovery-window-in-days 0 or --force-delete-without-recovery for immediate deletion in dev environments).
TL;DR
Use GetSecretValue with the aws-secretsmanager-caching client to reduce API costs (default cache TTL 1 hour). Rotation Lambda must be in the same VPC as the database and have a route to the Secrets Manager API. Force a new ECS deployment after every rotation to replace tasks using the old secret value. Use secretsmanager:GetSecretValue on the task role for runtime secret reads, or on the execution role for task injection.
Creating and reading secrets
// CDK: create a database secret with auto-generated password
import * as secretsmanager from "aws-cdk-lib/aws-secretsmanager";
const dbSecret = new secretsmanager.Secret(this, "DbSecret", {
secretName: "/mcp-server/prod/db", // conventional hierarchy
description: "MCP server database credentials",
generateSecretString: {
secretStringTemplate: JSON.stringify({ username: "mcp_user", host: "mydb.cluster.amazonaws.com" }),
generateStringKey: "password", // key to receive the generated value
excludePunctuation: true, // some DB drivers mishandle special chars
passwordLength: 32,
},
removalPolicy: cdk.RemovalPolicy.RETAIN, // never delete secrets on stack teardown
});
// Node.js: reading a secret with caching to reduce API call costs
// npm install @aws-sdk/client-secrets-manager @aws-lambda-powertools/parameters
import { SecretsProvider } from "@aws-lambda-powertools/parameters/secrets";
// Or use the AWS SDK directly with manual caching:
import { SecretsManagerClient, GetSecretValueCommand } from "@aws-sdk/client-secrets-manager";
const client = new SecretsManagerClient({ region: process.env.AWS_REGION });
let cachedSecret: { value: string; fetchedAt: number } | null = null;
const CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour
async function getDbCredentials(): Promise<{ username: string; password: string; host: string }> {
const now = Date.now();
if (cachedSecret && now - cachedSecret.fetchedAt < CACHE_TTL_MS) {
return JSON.parse(cachedSecret.value);
}
const response = await client.send(new GetSecretValueCommand({
SecretId: "/mcp-server/prod/db",
// VersionStage defaults to AWSCURRENT — always returns the active value
// During rotation: AWSCURRENT = old (still valid), AWSPENDING = new (being tested)
}));
const secretValue = response.SecretString;
if (!secretValue) throw new Error("Secret has no string value");
cachedSecret = { value: secretValue, fetchedAt: now };
return JSON.parse(secretValue);
}
SecretString vs SecretBinary: GetSecretValue returns either SecretString (for text secrets like JSON credentials) or SecretBinary (for binary secrets like certificates). Check which field is populated — accessing the wrong field returns undefined silently. JSON secrets stored as strings must be parsed after retrieval: JSON.parse(response.SecretString).
Automatic rotation: the four-step Lambda pattern
Secrets Manager rotation calls the rotation Lambda four times — once per step — in sequence. The Lambda must handle all four steps idempotently (rotation can be retried).
// rotation-lambda/index.ts — skeleton for all four rotation steps
import { SecretsManagerRotationEvent } from "aws-lambda";
import { SecretsManagerClient, GetSecretValueCommand, PutSecretValueCommand, UpdateSecretVersionStageCommand } from "@aws-sdk/client-secrets-manager";
import { Client } from "pg"; // or your database driver
const sm = new SecretsManagerClient({});
export const handler = async (event: SecretsManagerRotationEvent) => {
const { SecretId, ClientRequestToken, Step } = event;
switch (Step) {
case "createSecret": {
// Create a new version (AWSPENDING) with a new password
// Check if AWSPENDING already exists (idempotent retry safety)
try {
await sm.send(new GetSecretValueCommand({
SecretId,
VersionId: ClientRequestToken,
VersionStage: "AWSPENDING",
}));
return; // Already created in a prior attempt
} catch (e: any) {
if (e.name !== "ResourceNotFoundException") throw e;
}
const currentSecret = JSON.parse(
(await sm.send(new GetSecretValueCommand({ SecretId, VersionStage: "AWSCURRENT" }))).SecretString!
);
const newPassword = generateSecurePassword(32);
await sm.send(new PutSecretValueCommand({
SecretId,
ClientRequestToken,
SecretString: JSON.stringify({ ...currentSecret, password: newPassword }),
VersionStages: ["AWSPENDING"],
}));
break;
}
case "setSecret": {
// Apply the new password to the actual database
const pendingSecret = JSON.parse(
(await sm.send(new GetSecretValueCommand({ SecretId, VersionId: ClientRequestToken, VersionStage: "AWSPENDING" }))).SecretString!
);
const currentSecret = JSON.parse(
(await sm.send(new GetSecretValueCommand({ SecretId, VersionStage: "AWSCURRENT" }))).SecretString!
);
const db = new Client({ host: currentSecret.host, user: currentSecret.username, password: currentSecret.password, database: "postgres", ssl: { rejectUnauthorized: true } });
await db.connect();
await db.query("ALTER USER $1 PASSWORD $2", [pendingSecret.username, pendingSecret.password]);
await db.end();
break;
}
case "testSecret": {
// Verify the new password actually works
const pendingSecret = JSON.parse(
(await sm.send(new GetSecretValueCommand({ SecretId, VersionId: ClientRequestToken, VersionStage: "AWSPENDING" }))).SecretString!
);
const db = new Client({ host: pendingSecret.host, user: pendingSecret.username, password: pendingSecret.password, database: "postgres", ssl: { rejectUnauthorized: true } });
await db.connect();
await db.query("SELECT 1"); // connectivity test
await db.end();
break;
}
case "finishSecret": {
// Promote AWSPENDING to AWSCURRENT
await sm.send(new UpdateSecretVersionStageCommand({
SecretId,
VersionStage: "AWSCURRENT",
MoveToVersionId: ClientRequestToken,
RemoveFromVersionId: (await sm.send(new GetSecretValueCommand({ SecretId, VersionStage: "AWSCURRENT" }))).VersionId,
}));
break;
}
}
};
Cross-account access
Unlike SSM Parameter Store (which doesn't support cross-account), Secrets Manager supports cross-account reads via a resource-based policy attached to the secret itself.
// CDK in the secret-owning account: grant cross-account read access
dbSecret.addToResourcePolicy(new iam.PolicyStatement({
sid: "CrossAccountRead",
principals: [
new iam.ArnPrincipal("arn:aws:iam::CONSUMER_ACCOUNT:role/McpServerTaskRole"),
],
actions: ["secretsmanager:GetSecretValue", "secretsmanager:DescribeSecret"],
conditions: {
// Optionally restrict to specific version stage
StringEquals: { "secretsmanager:VersionStage": "AWSCURRENT" },
},
}));
// In the consuming account: the task role's IAM policy must also allow the action
// (both the resource policy AND the identity policy must permit the action)
taskRole.addToPolicy(new iam.PolicyStatement({
actions: ["secretsmanager:GetSecretValue"],
resources: ["arn:aws:secretsmanager:us-east-1:SECRET_OWNER_ACCOUNT:secret:/mcp-server/prod/db-*"],
}));
// Cross-account access also requires the KMS key policy to allow the consumer account
// if the secret is encrypted with a customer-managed KMS key (not aws/secretsmanager)
Common failure modes
| Symptom | Cause | Fix |
|---|---|---|
Rotation Lambda times out at setSecret step | Lambda in a VPC with no route to Secrets Manager API — no NAT gateway and no VPC endpoint | Add a Secrets Manager VPC interface endpoint, or attach a NAT gateway to the Lambda's subnet |
| ECS tasks use old database password after rotation | Secrets injected at task start are not refreshed during the task's lifetime | Force new ECS deployment after rotation; or switch from task injection to runtime GetSecretValue with caching |
| "InvalidRequestException: You can't create this secret because a secret with this name is already scheduled for deletion" | Prior DeleteSecret left the secret in the recovery window | Use aws secretsmanager delete-secret --force-delete-without-recovery in dev, or wait out the recovery window in prod |
| Cross-account read: "not authorized to perform secretsmanager:GetSecretValue" | Either the resource policy or the consumer account's IAM policy is missing — both must allow the action | Add the action to both the secret's resource policy (in the owning account) and the task role's IAM policy (in the consuming account) |
| Rotation fails silently — secret version stays as AWSPENDING | Lambda rotation function threw an error in one of the four steps; rotation didn't reach finishSecret | Check rotation Lambda CloudWatch Logs; the error is logged there; fix the failing step (usually setSecret or testSecret) |
| API cost spike: $40+/month for Secrets Manager | Calling GetSecretValue per-request in a high-traffic handler — at $0.05/10,000 calls, 1,000 RPS = $12,960/month | Cache the secret in-process with a 1-hour TTL; use @aws-lambda-powertools/parameters or write a simple cache around the SDK call |
response.SecretString is undefined | Secret was stored as binary (SecretBinary) not string | Check response.SecretBinary or re-store the secret as a string value |