Guide · AWS Secrets Manager

Secrets Caching in Lambda for MCP Servers

Calling GetSecretValue on every Lambda invocation is the single most common performance mistake in MCP servers that use AWS Secrets Manager. The Secrets Manager API charges $0.05 per 10,000 requests — that sounds cheap until you have a function processing 50,000 invocations per hour, which translates to $2.50/hour in API costs alone for a single secret. More critically, each GetSecretValue call adds 5–50 ms of network latency (within-region to the Secrets Manager endpoint) on every invocation. Three caching traps to avoid: the module-level cache is per-execution-context, not per-function — two warm Lambda containers have independent caches, so after a rotation, one container may get the new secret immediately on cache expiry while another continues using the old value for up to TTL seconds longer; setting TTL too long breaks rotation — a 3600-second TTL means a rotated credential can be stale for an hour, causing auth failures that are painful to diagnose; not forcing a refresh after rotation events — the optimal pattern is a short TTL (5 minutes) combined with an EventBridge trigger that force-flushes the cache in all containers immediately after a successful rotation.

TL;DR

Use the AWS Secrets Manager caching library (aws-secretsmanager-caching) with a 300-second (5-minute) TTL. Set max_cache_size=1000 if the function accesses many secrets. After a rotation event, force-refresh by calling cache.get_secret_string(arn, force_refresh=True) — not by invalidating the cache and waiting for TTL expiry. The cache is per-execution-context; do not assume all Lambda instances have the same cached value at any moment.

The AWS Secrets Manager caching library

AWS provides an official caching library for Python (aws-secretsmanager-caching) and Java. The Python library wraps GetSecretValue with an in-memory LRU cache keyed on (secret_id, version_stage):

# Install: pip install aws-secretsmanager-caching
from aws_secretsmanager_caching import SecretCache, SecretCacheConfig
import boto3
import json

# Create at module level — cache persists across warm invocations
_cache = SecretCache(
    config=SecretCacheConfig(
        max_cache_size=1000,          # max number of secrets cached per execution context
        exception_retry_delay_base=1, # base seconds before retrying after a GetSecretValue exception
        exception_retry_growth_factor=2,
        exception_retry_delay_max=3600,
        default_version_stage_name="AWSCURRENT",
        secret_refresh_interval=300,  # TTL in seconds (default is 3600 — too long for most rotation policies)
        secret_version_stage_refresh_interval=300,
    ),
    client=boto3.client("secretsmanager", region_name="us-east-1"),
)


def handler(event: dict, context) -> dict:
    # Returns the SecretString value (str) — parse as JSON for structured secrets
    secret_str = _cache.get_secret_string(
        "arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/mcp-server/database-AbCdEf"
    )
    creds = json.loads(secret_str)
    # ... use creds["password"] etc ...

The first call within a new execution context fetches from Secrets Manager. Subsequent calls within secret_refresh_interval seconds return the cached value. After the interval expires, the next call triggers a background refresh — it fetches the new value and updates the cache, but still returns the cached value for that invocation (the refresh is asynchronous to avoid adding latency to the call that triggered it).

Manual module-level cache (no library dependency)

If you prefer not to add a dependency, a simple module-level dict with a timestamp achieves the same effect with full control over the refresh logic:

import boto3
import json
import time

_client = boto3.client("secretsmanager", region_name="us-east-1")
_cache: dict[str, dict] = {}
_TTL_SECONDS = 300


def get_secret(secret_arn: str) -> dict:
    entry = _cache.get(secret_arn)
    if entry and (time.monotonic() - entry["ts"]) < _TTL_SECONDS:
        return entry["value"]

    resp = _client.get_secret_value(
        SecretId=secret_arn,
        VersionStage="AWSCURRENT",
    )
    value = json.loads(resp["SecretString"])
    _cache[secret_arn] = {"value": value, "ts": time.monotonic()}
    return value


def force_refresh_secret(secret_arn: str) -> dict:
    """Call this after a rotation event to immediately use the new credential."""
    _cache.pop(secret_arn, None)  # invalidate
    return get_secret(secret_arn)  # re-fetch

time.monotonic() is the right clock for cache TTL — it is not affected by system time changes (NTP adjustments, DST) and does not go backward. time.time() can jump backward on AWS Lambda if the VPC's time source is adjusted, which would reset your TTL logic incorrectly.

TTL selection — balancing staleness and API cost

The right TTL depends on your rotation schedule and your tolerance for stale credentials:

Rotation policy Recommended TTL Worst-case staleness after rotation
Never (static credentials) 3600 s (1 hour) N/A — credentials never change
30-day rotation (nightly at 02:00) 300 s (5 minutes) 5 minutes — acceptable for most SLOs
Daily rotation (high-security) 60 s (1 minute) 1 minute; still ~20× cheaper than no cache at 1,000 req/min
On-demand rotation (incident response) 60 s + EventBridge force-refresh Near-zero if EventBridge trigger reaches all containers

The default TTL in the AWS caching library (secret_refresh_interval) is 3600 seconds — one hour. This is safe for environments that never rotate secrets, but dangerous if you rotate monthly: a rotation that completes at 02:00 UTC can leave Lambda functions using the old credential until 03:00 UTC, causing an hour of auth failures if any downstream system invalidates the old credential immediately after rotation.

Force-refresh after rotation via EventBridge

For zero-downtime rotation, pair the cache with an EventBridge rule that invokes a force-refresh Lambda immediately after rotation completes:

# EventBridge rule — fires when Secrets Manager rotation succeeds
{
  "source": ["aws.secretsmanager"],
  "detail-type": ["Secret Rotation Event"],
  "detail": {
    "eventName": ["RotationSucceeded"]
  }
}

# Force-refresh Lambda handler
def cache_invalidation_handler(event: dict, context) -> None:
    secret_arn = event["detail"]["secretArn"]
    # Option 1: invoke the MCP server Lambdas directly to flush their caches
    # (requires a cache-flush endpoint or an SNS topic they're subscribed to)

    # Option 2: use SSM Parameter Store to signal a "rotation version counter"
    # MCP server reads the counter and compares to its cached version
    ssm = boto3.client("ssm")
    ssm.put_parameter(
        Name=f"/mcp-server/rotation-counter/{secret_arn.split(':')[-1]}",
        Value=str(int(time.time())),
        Type="String",
        Overwrite=True,
    )

The SSM parameter approach works because Lambda execution contexts poll the parameter on each invocation (cheap — SSM GetParameter is ~1 ms) and compare its value to the last time they fetched the secret. If the counter changed, they force-refresh regardless of the TTL. This is a publish-subscribe pattern: the rotation event increments the counter, all execution contexts consume it lazily.

Caching binary secrets and multiple versions

The AWS caching library also supports SecretBinary and explicit version stages:

# Cache a binary secret (TLS certificate, PKCS#12 keystore)
cert_bytes = _cache.get_secret_binary(
    "arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/mcp-server/tls-cert-XyZwWv"
)

# Explicitly request AWSPREVIOUS for rollback (e.g., in a rotation Lambda's error handler)
old_secret_str = _cache.get_secret_string(
    "arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/mcp-server/database-AbCdEf",
    version_stage="AWSPREVIOUS",
)

The cache keys on both the ARN and the version stage — so AWSCURRENT and AWSPREVIOUS versions of the same secret are cached independently. This is important for the rollback scenario: fetching AWSPREVIOUS does not evict the AWSCURRENT cache entry.

Per-execution-context isolation — the concurrency trap

The most misunderstood aspect of Lambda secrets caching is that the module-level cache is per-execution-context, not per-function. At 100 concurrent invocations, Lambda may have 100 separate execution contexts — each with its own independent cache. This has two implications:

  1. After rotation, the new credential propagates to each execution context only when that context's cache TTL expires. At 300-second TTL, a rotation that completes at T=0 is fully propagated across all contexts by T=300s — but individual contexts may see the new credential anywhere from T=0 (cold start after rotation) to T=300s (warm context at the end of its TTL).
  2. During the propagation window, different Lambda instances are using different credentials. For MultiUser rotation (where both the old and new credentials are simultaneously valid), this is fine. For SingleUser rotation (where the old credential is invalidated immediately), this window causes auth failures on warm contexts.

This is why MultiUser rotation is the correct strategy for high-concurrency MCP servers — both credentials are valid simultaneously, so the per-context cache propagation window is benign.

Common failures

Symptom Root cause Fix
Auth failures on ~5% of requests for 5 minutes after rotation Some warm containers still using cached AWSCURRENT (old credential) — SingleUser rotation invalidated it immediately Switch to MultiUser rotation, or reduce TTL to 60s and accept higher Secrets Manager API costs
Cache never refreshes — always using initial credential value Cache object created inside the handler function, not at module level — a new cache is created on every invocation Move SecretCache or _cache = {} to module level, outside the handler function
ThrottlingException from Secrets Manager under load despite caching Many concurrent cold starts all fetching the same secret simultaneously (thundering herd at scale-up) Add jitter to the cache miss handler; or pre-warm secrets during Lambda init phase using LAMBDA_TASK_ROOT detection
Old credential cached for longer than expected after manual rotation secret_refresh_interval defaults to 3600 s in the AWS caching library Explicitly set secret_refresh_interval=300 in SecretCacheConfig

Know immediately when a cache bug causes MCP server downtime

Credential caching bugs are silent — your Lambda thinks it's healthy but auth is failing. AliveMCP probes your MCP endpoints externally every 60 seconds and fires a Slack alert the moment tool calls start failing. Faster feedback loop than CloudWatch Alarms on Lambda errors.

Join the waitlist →