Guide · AWS DynamoDB

MCP Server DynamoDB TTL — session cleanup, expiry lifecycle, GSI on TTL attribute

DynamoDB TTL deletes expired items automatically — but deletion is eventual, not instant, and expired items can still be returned by reads for up to 48 hours after the TTL timestamp passes. For MCP session management, three details matter most: TTL must be a Unix epoch timestamp in seconds — not milliseconds, not ISO 8601; a value in milliseconds will be treated as a date far in the future and the item will never expire; filter expired items in your application — a GetItem or Query call may return an item whose ttl attribute is in the past because DynamoDB hasn't deleted it yet; always compare item.ttl to Math.floor(Date.now() / 1000) before using the session; and GSI on the TTL attribute enables pre-expiry queries (find sessions expiring in the next 15 minutes for warm-up or graceful termination) but consumes extra write capacity on every TTL update.

TL;DR

Set timeToLiveAttribute: "ttl" on the table. Store TTL as Unix epoch seconds: Math.floor(Date.now() / 1000) + sessionDurationSeconds. Filter expired items in application code — do not rely on DynamoDB to have already deleted them. If you need near-expiry queries, add a GSI with ttl as the sort key on a status-based partition. Integrate with DynamoDB Streams (eventName === "REMOVE") to trigger cleanup side effects when sessions actually expire.

Configuring TTL on the session table

TTL is enabled per table by specifying the attribute name that holds the expiry timestamp. DynamoDB checks items periodically and deletes those whose TTL value is in the past. The deletion process is best-effort within 48 hours of expiry — it is not guaranteed to be instantaneous.

// CDK: Enable TTL on session table
import * as dynamodb from "aws-cdk-lib/aws-dynamodb";

const sessionTable = new dynamodb.Table(this, "McpSessionTable", {
  tableName: "mcp-sessions",
  partitionKey: { name: "pk", type: dynamodb.AttributeType.STRING },
  sortKey: { name: "sk", type: dynamodb.AttributeType.STRING },
  billingMode: dynamodb.BillingMode.PAY_PER_REQUEST,
  timeToLiveAttribute: "ttl",   // must be a Number attribute
  stream: dynamodb.StreamViewType.NEW_AND_OLD_IMAGES,
});

Writing a session item with TTL:

import { DynamoDBDocumentClient, PutCommand } from "@aws-sdk/lib-dynamodb";
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";

const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({}));

const SESSION_DURATION_SECONDS = 3600; // 1 hour

export async function createSession(
  sessionId: string,
  data: Record
): Promise {
  const nowSec = Math.floor(Date.now() / 1000);
  const ttl = nowSec + SESSION_DURATION_SECONDS;

  await ddb.send(
    new PutCommand({
      TableName: "mcp-sessions",
      Item: {
        pk: `session#${sessionId}`,
        sk: "metadata",
        sessionId,
        data,
        createdAt: new Date().toISOString(),
        updatedAt: new Date().toISOString(),
        // TTL MUST be epoch seconds (not milliseconds)
        ttl,
      },
    })
  );
}

// Extend session TTL on activity (sliding window)
export async function touchSession(sessionId: string): Promise {
  const nowSec = Math.floor(Date.now() / 1000);
  const newTtl = nowSec + SESSION_DURATION_SECONDS;

  await ddb.send(
    new UpdateCommand({
      TableName: "mcp-sessions",
      Key: { pk: `session#${sessionId}`, sk: "metadata" },
      UpdateExpression: "SET #ttl = :ttl, updatedAt = :now",
      ExpressionAttributeNames: { "#ttl": "ttl" },
      ExpressionAttributeValues: {
        ":ttl": newTtl,
        ":now": new Date().toISOString(),
        ":minTtl": nowSec, // only extend if not already expired
      },
      // Only touch if the session is still alive
      ConditionExpression: "attribute_exists(pk) AND #ttl > :minTtl",
    })
  );
}

Filtering expired items in application code

Because DynamoDB TTL deletion is eventual, your application must filter out expired items on reads. Do not assume that a successful GetItem response means the session is still valid.

import { DynamoDBDocumentClient, GetCommand } from "@aws-sdk/lib-dynamodb";

export async function getSession(
  sessionId: string
): Promise | null> {
  const result = await ddb.send(
    new GetCommand({
      TableName: "mcp-sessions",
      Key: { pk: `session#${sessionId}`, sk: "metadata" },
      ConsistentRead: true, // avoid stale reads from replica
    })
  );

  if (!result.Item) return null;

  // Filter expired items that DynamoDB hasn't deleted yet
  const nowSec = Math.floor(Date.now() / 1000);
  if (result.Item.ttl && result.Item.ttl <= nowSec) {
    return null; // treat as expired even if DynamoDB hasn't removed it
  }

  return result.Item as Record;
}

For Query operations, add a FilterExpression to exclude expired items from the result set. Note that FilterExpression is applied after the items are read — you are billed for the read capacity of all items scanned, including those filtered out:

import { QueryCommand } from "@aws-sdk/lib-dynamodb";

const nowSec = Math.floor(Date.now() / 1000);

const result = await ddb.send(
  new QueryCommand({
    TableName: "mcp-sessions",
    KeyConditionExpression: "pk = :pk",
    FilterExpression: "#ttl > :now OR attribute_not_exists(#ttl)",
    ExpressionAttributeNames: { "#ttl": "ttl" },
    ExpressionAttributeValues: {
      ":pk": `session#${sessionId}`,
      ":now": nowSec,
    },
  })
);

GSI on TTL for pre-expiry queries

A GSI with ttl as the sort key enables efficient queries like "find all sessions expiring in the next 15 minutes" — useful for graceful warmup, pre-expiry notifications, or proactive session refresh. This requires a stable partition key for the GSI (typically a status attribute or a date bucket).

// CDK: Add GSI for TTL-based range queries
sessionTable.addGlobalSecondaryIndex({
  indexName: "status-ttl-index",
  partitionKey: {
    name: "status",
    type: dynamodb.AttributeType.STRING,
  },
  sortKey: {
    name: "ttl",
    type: dynamodb.AttributeType.NUMBER,
  },
  projectionType: dynamodb.ProjectionType.INCLUDE,
  nonKeyAttributes: ["sessionId", "createdAt", "updatedAt"],
});

// Query: sessions expiring in the next 15 minutes
const nowSec = Math.floor(Date.now() / 1000);
const in15min = nowSec + 15 * 60;

const result = await ddb.send(
  new QueryCommand({
    TableName: "mcp-sessions",
    IndexName: "status-ttl-index",
    KeyConditionExpression: "#status = :active AND #ttl BETWEEN :now AND :soon",
    ExpressionAttributeNames: {
      "#status": "status",
      "#ttl": "ttl",
    },
    ExpressionAttributeValues: {
      ":active": "active",
      ":now": nowSec,
      ":soon": in15min,
    },
  })
);

Write cost: every session write that updates ttl or status also writes a new entry to the GSI. For high-throughput MCP servers, consider whether the pre-expiry query pattern is worth the extra write capacity cost. An alternative is to use DynamoDB Streams (see the Streams guide) to detect REMOVE events and trigger downstream cleanup without a GSI.

Stream-triggered cleanup on expiry

DynamoDB Streams emits a REMOVE event when a TTL deletion occurs, just like a manual DeleteItem. The difference: TTL-caused REMOVE events have a userIdentity.type of "Service" and userIdentity.principalId of "dynamodb.amazonaws.com". Use this to distinguish TTL cleanup from application-initiated deletes.

// Lambda stream handler: clean up side effects on TTL expiry
export async function handler(event: DynamoDBStreamEvent): Promise {
  for (const record of event.Records) {
    if (record.eventName !== "REMOVE") continue;

    // Distinguish TTL-triggered delete from application DeleteItem
    const isTtlExpiry =
      record.userIdentity?.type === "Service" &&
      record.userIdentity?.principalId === "dynamodb.amazonaws.com";

    if (!isTtlExpiry) continue; // skip application deletes if only cleaning up TTL expiry

    const oldImage = record.dynamodb?.OldImage;
    if (!oldImage) continue;

    const sessionId = oldImage.sessionId?.S;
    if (!sessionId) continue;

    // Trigger cleanup: revoke tokens, update status in another system, log
    await cleanupExpiredSession(sessionId);
  }
}

Common failure modes

SymptomCauseFix
Sessions never expire; items accumulate indefinitelyTTL attribute is stored in milliseconds instead of seconds (e.g., Date.now() instead of Math.floor(Date.now() / 1000)); DynamoDB treats the value as a timestamp far in the futureAlways use Math.floor(Date.now() / 1000) + durationSeconds; verify with aws dynamodb get-item and check the numeric value
Expired sessions still returned by GetItemDynamoDB TTL deletion is eventual — expired items may persist for up to 48 hours after their TTL timestampAdd application-level expiry check: compare item.ttl to current epoch seconds; treat items with ttl <= nowSec as expired regardless of whether DynamoDB has deleted them
GSI queries return fewer items than expectedItems without a status attribute are not included in the status-ttl-index GSI — sparse index behaviorEnsure all session items include both the GSI partition key (status) and sort key (ttl) attributes; items missing either attribute are silently excluded from the GSI
TTL stream REMOVE events not firing for cleanup LambdaStreams are not enabled on the table, or the Lambda event source mapping filters out REMOVE eventsEnable stream: dynamodb.StreamViewType.NEW_AND_OLD_IMAGES on the table; remove any Lambda event source filter that excludes eventName === "REMOVE"
Cleanup Lambda cannot distinguish TTL expiry from application deletesBoth produce REMOVE events; without checking userIdentity, all deletes trigger cleanupCheck record.userIdentity?.principalId === "dynamodb.amazonaws.com" to identify TTL-triggered removals