AWS ElastiCache · 2026-09-05 · AWS ElastiCache arc
AWS ElastiCache Redis for MCP Servers: VPC Networking, AUTH Security, Session State, and Cluster Mode — Four Production Patterns
AWS ElastiCache Redis is a managed Redis service with no public internet endpoint — every connection must come from inside the same VPC. For MCP servers, this means Lambda functions need VPC configuration, ECS tasks need to run in private subnets alongside the ElastiCache cluster, and all security parameters (AUTH token, TLS, eviction policy) must be baked into the cluster at creation because ElastiCache blocks CONFIG SET at runtime. Four production patterns trip up teams moving from self-hosted Redis to ElastiCache for MCP session state.
TL;DR
Use volatile-lru eviction (not allkeys-lru) to protect active session keys from memory-pressure eviction. Enable AUTH token and TLS at cluster creation — they cannot be added later. Use SET key token NX EX ttl for distributed locking and WATCH/MULTI/EXEC for optimistic concurrency. Replace KEYS session:* with SCAN cursor iteration. In cluster mode, use hash tags ({session:abc}:state) to co-locate related keys and prevent CROSSSLOT errors.
Pattern 1: VPC networking — ElastiCache has no public endpoint
ElastiCache Redis clusters are deployed into private VPC subnets with no public internet endpoint. Every application component — Lambda functions, ECS tasks, EC2 instances — must be in the same VPC, or connected via VPC peering or Transit Gateway. This is the most common first blocker for teams migrating from a publicly-reachable Redis instance.
Lambda VPC configuration
Lambda functions without VPC configuration cannot reach ElastiCache. You must add vpcConfig to the Lambda function, which places the function's elastic network interface (ENI) inside your private subnets:
// CDK: Lambda with VPC config to reach ElastiCache
const mcpHandler = new lambda.Function(this, "McpHandler", {
runtime: lambda.Runtime.NODEJS_22_X,
handler: "index.handler",
code: lambda.Code.fromAsset("lambda"),
vpc, // same VPC as ElastiCache
vpcSubnets: { subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS },
securityGroups: [mcpServerSg], // security group defined below
environment: {
ELASTICACHE_PRIMARY_ENDPOINT: redis.attrPrimaryEndPointAddress,
ELASTICACHE_READER_ENDPOINT: redis.attrReaderEndPointAddress,
REDIS_AUTH_TOKEN: authTokenSecret.secretValueFromJson("token").unsafeUnwrap(),
},
});
VPC-attached Lambda functions have slightly higher cold-start latency (~5–10ms extra for ENI attachment). This is now substantially lower than in early Lambda generations and is acceptable for MCP server workloads.
Eviction policy: volatile-lru is the correct default for session data
The maxmemory-policy parameter controls what ElastiCache Redis does when memory reaches its limit. Teams migrating from self-hosted Redis often use allkeys-lru as a cache default — but this is wrong for MCP session state. allkeys-lru evicts any key under memory pressure, including active session keys that have no TTL set. Sessions disappear mid-conversation without warning.
volatile-lru only evicts keys that have a TTL set. If you store all session data with an explicit EX ttl (as you should), volatile-lru manages expiry correctly. Keys without TTL — configuration constants, lookup tables — are protected from eviction regardless of memory pressure.
| Policy | What gets evicted | For MCP session state |
|---|---|---|
volatile-lru | Only TTL-bearing keys, least-recently-used first | Correct — always set TTL on session keys; long-lived config keys without TTL are protected |
allkeys-lru | Any key, TTL or not, LRU first | Wrong — active sessions without TTL evicted under memory pressure; use only for pure caches |
noeviction | Nothing — writes fail with OOM when memory is full | Dangerous for session state — MCP tool writes fail with errors under load |
volatile-ttl | TTL-bearing keys, shortest-TTL first | Acceptable alternative — evicts near-expiry sessions first, but may evict sessions you still want |
Parameter groups: all configuration must be set before cluster creation
ElastiCache blocks CONFIG SET at runtime — any attempt returns ERR unknown command 'config'. Every Redis parameter must be declared in a custom parameter group before the cluster is created:
// CDK: custom parameter group with all required settings
const paramGroup = new elasticache.CfnParameterGroup(this, "RedisParamGroup", {
cacheParameterGroupFamily: "redis7",
description: "MCP server Redis parameters",
properties: {
"maxmemory-policy": "volatile-lru", // Protect active sessions from eviction
"hz": "20", // Faster TTL expiry detection (default: 10)
"lazyfree-lazy-eviction": "yes", // Async eviction — reduces event-loop jitter
"lazyfree-lazy-expire": "yes", // Async TTL expiry — reduces jitter on burst expiry
"notify-keyspace-events": "Ex", // Keyevent notifications for expired events only
},
});
Two endpoints matter for MCP servers using non-cluster (cluster mode disabled) replication groups: the primary endpoint for all writes, and the reader endpoint that load-balances across replicas for read-heavy operations like session reads and health checks. Point ioredis writer at the primary endpoint and reader at the reader endpoint.
Pattern 2: AUTH + TLS — security configured at cluster creation, not after
ElastiCache AUTH token and encryption are configured at cluster creation and cannot be added to an existing cluster. This is the most common surprise for teams that deploy without security and try to add it later. You must create a new cluster and migrate data.
AUTH token requirements
The AUTH token is a password that Redis checks on every new connection. On ElastiCache, it has specific constraints:
- Length: 16–128 characters.
- Allowed characters: alphanumeric plus
!&#$^<>-. Spaces and most punctuation are not allowed. - In-transit encryption is required to use AUTH. Setting
authTokenwithouttransitEncryptionEnabled: trueis rejected by the ElastiCache API. - The token is stored encrypted by AWS and is not retrievable via the ElastiCache API after creation. Store it in Secrets Manager at creation time.
// CDK: generate AUTH token in Secrets Manager and pass to cluster
const authTokenSecret = new secretsmanager.Secret(this, "RedisAuthToken", {
secretName: "prod/mcp-server/redis-auth-token",
generateSecretString: {
excludePunctuation: true, // Only alphanumeric chars — avoids format issues
passwordLength: 32,
generateStringKey: "token",
secretStringTemplate: JSON.stringify({ description: "ElastiCache auth token" }),
},
});
const redis = new elasticache.CfnReplicationGroup(this, "McpRedis", {
replicationGroupDescription: "MCP server session cache",
cacheNodeType: "cache.t4g.small",
engine: "redis",
engineVersion: "7.1",
authToken: authTokenSecret.secretValueFromJson("token").unsafeUnwrap(),
transitEncryptionEnabled: true, // Required for authToken; TLS on all connections
atRestEncryptionEnabled: true, // AES-256; cannot be changed post-creation
automaticFailoverEnabled: true,
multiAzEnabled: true,
numCacheClusters: 2, // 1 primary + 1 replica
cacheParameterGroupName: paramGroup.ref,
cacheSubnetGroupName: subnetGroup.ref,
securityGroupIds: [elasticacheSg.securityGroupId],
});
Zero-downtime AUTH token rotation
ElastiCache supports a two-token transition window. During the window, the cluster accepts both the old and new AUTH token simultaneously. This allows rolling deploys without dropping existing connections.
Rotation sequence:
- ROTATE strategy — add the new token via
modify-replication-groupwithAuthTokenUpdateStrategy: ROTATE. ElastiCache begins accepting both old and new tokens. The transition window is up to one hour. - Deploy the new application version — update Secrets Manager with the new token value; redeploy MCP server instances. Old instances continue to function with the old token.
- SET strategy — once all instances run the new version, finalize with
AuthTokenUpdateStrategy: SET. The old token is rejected from this point.
# Step 1: Start rotation — both tokens accepted
aws elasticache modify-replication-group \
--replication-group-id mcp-redis \
--auth-token "NEW_TOKEN_HERE" \
--auth-token-update-strategy ROTATE \
--apply-immediately
# Step 3: After full rollout — only new token accepted
aws elasticache modify-replication-group \
--replication-group-id mcp-redis \
--auth-token "NEW_TOKEN_HERE" \
--auth-token-update-strategy SET \
--apply-immediately
Using SET directly (without the ROTATE step) immediately disconnects all connections authenticated with the old token — causing a brief outage for active MCP sessions.
RBAC user groups for multi-tenant MCP servers
ElastiCache Redis 7+ supports Access Control Lists via managed users and user groups. This lets multi-tenant MCP servers restrict which Redis keys and commands each tenant's tool context can access — without separate clusters per tenant.
# Reader user: can only read keys starting with "session:"
aws elasticache create-user \
--user-id mcp-reader \
--user-name mcp-reader \
--engine redis \
--passwords "ReadOnlyPassword123!" \
--access-string "on ~session:* &* -@all +@read +@connection"
# Writer user: read + write on session keys, plus expire and del
aws elasticache create-user \
--user-id mcp-writer \
--user-name mcp-writer \
--engine redis \
--passwords "ReadWritePassword456!" \
--access-string "on ~session:* &* -@all +@read +@write +@connection +expire +del"
When user groups are enabled, the global authToken on the replication group must be cleared — authentication is done via individual user passwords passed in the ioredis password and username options.
Pattern 3: Session state — distributed locks, optimistic concurrency, and safe key iteration
MCP servers present a session state challenge that standard web applications do not: MCP tool calls from the same agent can fire concurrently. Two branches of an agent may execute the same tool in parallel, both trying to write the same session key simultaneously. Standard web sessions assume one request per session — MCP does not.
Distributed locking for idempotent tool calls
Use SET key token NX EX ttl — atomic in a single command — to acquire a distributed lock before executing a tool call. The NX flag makes the set conditional on the key not existing. The EX ttl sets a safety TTL so crashed handlers do not hold the lock forever.
import Redis from "ioredis";
import crypto from "node:crypto";
// Acquire a distributed lock
async function acquireLock(
redis: Redis,
lockKey: string,
ttlSeconds: number
): Promise {
const token = crypto.randomBytes(16).toString("hex");
// Single atomic command — correct; do NOT use SETNX + EXPIRE (two commands, not atomic)
const result = await redis.set(lockKey, token, "NX", "EX", ttlSeconds);
return result === "OK" ? token : null;
}
// Release the lock atomically — only if this holder still owns it
async function releaseLock(
redis: Redis,
lockKey: string,
token: string
): Promise {
// Lua script: compare token, then delete — single atomic operation
const script = `
if redis.call('get', KEYS[1]) == ARGV[1] then
return redis.call('del', KEYS[1])
else
return 0
end
`;
const result = await redis.eval(script, 1, lockKey, token);
return result === 1;
}
// MCP tool handler with distributed lock
async function handleToolCall(callId: string, params: unknown) {
const lockKey = `lock:tool:${callId}`;
const lockToken = await acquireLock(redis, lockKey, 30);
if (!lockToken) {
return { status: "duplicate", message: "Tool call already in progress" };
}
try {
return await executeToolLogic(params);
} finally {
await releaseLock(redis, lockKey, lockToken); // Always release, even on error
}
}
The critical mistake is using two separate commands — SETNX then EXPIRE — which are not atomic. If the process crashes between the two commands, the lock never expires. Always use the single-command SET key value NX EX ttl form.
Optimistic concurrency with WATCH/MULTI/EXEC
For read-modify-write operations on session data — appending to a tool call history, incrementing a counter — use WATCH to detect concurrent modifications and retry if the key changed between your read and write:
async function appendToolResult(
sessionId: string,
toolResult: unknown,
maxRetries = 3
): Promise {
const key = `{session:${sessionId}}:tool-history`;
for (let attempt = 0; attempt < maxRetries; attempt++) {
await redis.watch(key); // Watch: abort transaction if key changes
const raw = await redis.get(key);
const history: unknown[] = raw ? JSON.parse(raw) : [];
history.push(toolResult);
// EXEC returns null if WATCHed key changed since WATCH was called
const result = await redis
.multi()
.set(key, JSON.stringify(history), "EX", 3600)
.exec();
if (result !== null) return; // Success
await new Promise((r) => setTimeout(r, 10 * (attempt + 1))); // Brief backoff
}
throw new Error(`Failed to append after ${maxRetries} attempts`);
}
For high-contention keys (a counter updated by many concurrent tool calls), prefer Redis's built-in atomic commands like INCR or INCRBY, or a Lua script — they avoid the WATCH overhead entirely.
SCAN instead of KEYS for session enumeration
KEYS session:* is O(N) and blocks Redis's single-threaded event loop for the entire scan duration. On a keyspace of 100,000 session keys this blocks for 20–50 milliseconds — causing timeouts for all concurrent MCP tool calls during that window. Replace every KEYS call with cursor-based SCAN:
// Correct: cursor-based SCAN — does not block Redis
async function* scanSessions(redis: Redis): AsyncGenerator {
let cursor = "0";
do {
const [nextCursor, keys] = await redis.scan(
cursor,
"MATCH", "mcp:sess:*",
"COUNT", 100 // Hint; actual count per call may vary
);
cursor = nextCursor;
for (const key of keys) yield key;
} while (cursor !== "0");
}
async function countActiveSessions(redis: Redis): Promise {
let count = 0;
for await (const _ of scanSessions(redis)) count++;
return count;
}
Pattern 4: Cluster mode and keyspace notifications — two features with sharp edges
Cluster mode: CROSSSLOT errors and hash tag co-location
ElastiCache cluster mode enabled (CME) distributes keyspace across multiple shards using 16,384 hash slots. This increases throughput but breaks applications that use multi-key commands without thinking about key placement.
MGET key1 key2 returns CROSSSLOT Keys in request don't hash to the same slot if key1 and key2 hash to different slots. Fix this with hash tags: wrap the shared part of related key names in {} braces. Redis computes the slot from only the content between the first { and }:
// Wrong: keys may hash to different slots → CROSSSLOT on MGET
await cluster.mset(
`session:${sessionId}:state`, JSON.stringify(state),
`session:${sessionId}:tools`, JSON.stringify(tools)
);
// Correct: hash tags force both keys to the same slot
await cluster.mset(
`{session:${sessionId}}:state`, JSON.stringify(state),
`{session:${sessionId}}:tools`, JSON.stringify(tools)
);
// Now MGET works — both keys are on the same shard
const [rawState, rawTools] = await cluster.mget(
`{session:${sessionId}}:state`,
`{session:${sessionId}}:tools`
);
Use new Redis.Cluster() (not new Redis()) for cluster-mode connections. Pass the configuration endpoint (not individual shard addresses) as the seed — the configuration endpoint is stable across scaling events:
const cluster = new Redis.Cluster(
[{ host: process.env.ELASTICACHE_CONFIG_ENDPOINT!, port: 6379 }],
{
redisOptions: {
password: process.env.REDIS_AUTH_TOKEN,
tls: {},
},
scaleReads: "slave", // Spread reads across replicas
clusterRetryStrategy: (times: number) => Math.min(times * 500, 5000),
}
);
ioredis handles MOVED and ASK redirects automatically — you do not need to handle them in application code. MOVED occurs when a key is on a different shard than expected (permanent after resharding). ASK occurs during slot migration (temporary). Both are transparent.
Pub/sub in cluster mode: subscribe to all shards or use Streams
In cluster mode, PUBLISH only delivers to subscribers on the shard that owns the channel's hash slot — it does not propagate to other shards. A subscriber on shard A misses messages published on shard B. For MCP server push notifications or tool result broadcasts, this means keyspace notifications arrive only on the shard that owns the expiring key's slot.
Two options:
- Subscribe to all shards — create one subscriber connection per primary node, discovered via
cluster.nodes('master'). This is operationally complex and must handle scaling events (new nodes joining). - Use Redis Streams instead of pub/sub —
XADDwrites are shard-local and can be reliably consumed via consumer groups on the same shard. Streams provide at-least-once delivery semantics, unlike pub/sub's at-most-once.
For most MCP deployments, cluster mode is not necessary until session state throughput exceeds what a single-shard replication group (with one primary and one replica) can handle. Single-shard ElastiCache with automaticFailoverEnabled: true handles tens of thousands of operations per second — most MCP workloads stay within this range for months.
Keyspace notifications: at-most-once delivery, dedicated connection required
Keyspace notifications let your MCP server react to key expiry without polling. When a session key's TTL fires, ElastiCache publishes to __keyevent@0__:expired with the expired key name as the message payload.
Three constraints shape how you use them:
- ElastiCache blocks
CONFIG SET notify-keyspace-eventsat runtime. Enable it only in a custom parameter group set before cluster creation (or with a parameter group update + cluster reboot). - A subscribed connection cannot issue regular Redis commands. Create a dedicated ioredis instance for the subscriber — separate from your main client.
- Delivery is at-most-once. If the subscriber is disconnected when a key expires, the notification is dropped. Add lazy cleanup (check on access) and a periodic SCAN sweep as reliability safety nets.
// Dedicated subscriber — never reuse the main client
const subscriber = new Redis({
host: process.env.ELASTICACHE_PRIMARY_ENDPOINT,
port: 6379,
password: process.env.REDIS_AUTH_TOKEN,
tls: {},
});
await subscriber.subscribe("__keyevent@0__:expired");
subscriber.on("message", async (_channel: string, expiredKey: string) => {
const match = expiredKey.match(/^session:([^:]+):/);
if (!match) return;
await cleanupSession(match[1]); // Best-effort; not guaranteed to fire on every expiry
});
// Safety net: also check on session access
async function getSession(sessionId: string) {
const raw = await redisClient.get(`session:${sessionId}:state`);
if (!raw) {
await cleanupSession(sessionId); // Lazy cleanup — catches missed notifications
return null;
}
return JSON.parse(raw);
}
Failure mode reference
| Symptom | Cause | Fix |
|---|---|---|
| Connection timeout from Lambda to ElastiCache | Lambda not in VPC, or in different VPC from ElastiCache; or security group missing TCP 6379 inbound from Lambda SG | Add vpcConfig to Lambda pointing to the same VPC and private subnets; add inbound rule on ElastiCache SG allowing TCP 6379 from the Lambda SG |
| Sessions unexpectedly disappearing under load | maxmemory-policy = allkeys-lru evicting active sessions without TTL during memory pressure | Switch parameter group to volatile-lru; ensure all session keys are stored with EX ttlSeconds |
ERR unknown command 'config' on startup | Application running CONFIG SET at startup; ElastiCache blocks this command | Move all Redis configuration to the parameter group; remove CONFIG SET calls from application code |
AuthToken is not allowed for non-encrypted clusters | Setting authToken without transitEncryptionEnabled: true | Enable both at cluster creation — authToken requires transitEncryptionEnabled: true |
| Connections dropped after AUTH token rotation | Using single-step SET strategy immediately; disconnects all old-token connections | Use ROTATE strategy first to accept both tokens, deploy new app version, then SET to finalize |
| Duplicate tool executions despite lock attempt | Using two separate commands (SETNX + EXPIRE) which are not atomic; crash between the two leaves a permanent lock | Use single atomic command: redis.set(key, token, 'NX', 'EX', ttl) |
| Redis freezes briefly during session listing | KEYS session:* blocking the event loop on a large keyspace | Replace with SCAN cursor iteration; never use KEYS in production |
CROSSSLOT Keys in request don't hash to the same slot | MGET, MSET, or multi-key DEL with keys on different shards in cluster mode | Add hash tags: {session:abc}:state and {session:abc}:tools — both hash on session:abc |
| Pub/sub messages received on some MCP clients but not others in cluster mode | PUBLISH only delivers to subscribers on the shard owning the channel's slot; other shards miss it | Subscribe to all shard primary endpoints independently, or use Redis Streams for cross-shard event delivery |
| No keyspace expiry notifications received | notify-keyspace-events is empty (disabled) in the parameter group, or parameter group change not applied (requires cluster reboot) | Set notify-keyspace-events = Ex in parameter group; reboot cluster nodes to apply; verify from within VPC with redis-cli |
ERR Command not allowed with subscribed connection | GET, SET, or other regular command sent on the same ioredis connection that called SUBSCRIBE | Create a dedicated ioredis instance for subscribe-only use; never mix subscribe and command traffic on the same connection |
Related guides
- MCP Server ElastiCache Redis — VPC placement, parameter groups, eviction policy, ioredis
- MCP Server ElastiCache AUTH and Encryption — AUTH token, TLS, RBAC
- MCP Server ElastiCache Session State — distributed locks, atomic operations, connect-redis
- MCP Server ElastiCache Cluster Mode — hash slots, MOVED redirects, multi-key commands
- MCP Server ElastiCache Keyspace Notifications — TTL expiry events, session cleanup
- AWS RDS for MCP Servers — RDS Proxy, IAM Auth, Multi-AZ Failover
- AWS Lambda for MCP Servers — cold starts, streaming, provisioned concurrency