Guide · AWS Kinesis

Kinesis Security for MCP Servers

MCP server event streams flowing through Kinesis carry tool call inputs, session IDs, and sometimes PII — they need the same security posture as your API: encryption at rest, network isolation, audit logging, and least-privilege access per producer and consumer role. Three things teams consistently get wrong: the default aws/kinesis encryption key limits cross-account access (the managed key is account-scoped — consumers in other accounts cannot decrypt records encrypted with aws/kinesis; use a customer-managed CMK with an explicit key policy that grants cross-account kms:Decrypt), VPC endpoints for Kinesis are interface endpoints (not gateway endpoints like S3/DynamoDB — they cost $0.01/endpoint-hour per AZ and require security group rules to allow inbound 443 from your Lambda/ECS task), and CloudTrail data events for Kinesis are not enabled by default (management events — CreateStream, DeleteStream — are logged by default, but PutRecord, GetRecords, and GetShardIterator are data-plane events that require explicit CloudTrail data event configuration, adding cost per 100K API calls).

TL;DR

Encrypt the stream with a customer-managed KMS CMK (not aws/kinesis) so cross-account consumers can be granted decrypt access via the key policy. Deploy a VPC interface endpoint for Kinesis in each AZ where your Lambda/ECS tasks run so stream API calls never traverse the public internet. Separate producer and consumer IAM roles: producers get only kinesis:PutRecord and kinesis:PutRecords; consumers get kinesis:GetRecords, kinesis:GetShardIterator, kinesis:DescribeStream, and (for enhanced fan-out) kinesis:SubscribeToShard. Enable CloudTrail data events on high-sensitivity streams where you need API-level audit trail.

IAM least-privilege for producers and consumers

Kinesis IAM permissions follow the standard AWS pattern of resource ARN scoping plus condition keys. The most useful condition key is kinesis:StreamARN — it restricts a permission to a specific stream ARN, preventing a compromised producer role from publishing to a different stream in the same account.

// CDK — separate producer and consumer IAM roles with least-privilege
import { PolicyStatement, Effect, Role, ServicePrincipal } from "aws-cdk-lib/aws-iam";
import { Stream } from "aws-cdk-lib/aws-kinesis";

const stream = new Stream(this, "McpEventStream", {
  streamName: "mcp-tool-events",
  shardCount: 2,
  encryption: StreamEncryption.KMS,
  encryptionKey: cmk,  // customer-managed CMK (see below)
  retentionPeriod: Duration.days(7),
});

// Producer role — MCP server Lambda/ECS task
const producerRole = new Role(this, "McpProducerRole", {
  assumedBy: new ServicePrincipal("lambda.amazonaws.com"),
});
producerRole.addToPolicy(new PolicyStatement({
  effect: Effect.ALLOW,
  actions: ["kinesis:PutRecord", "kinesis:PutRecords"],
  resources: [stream.streamArn],
}));
// KMS permission to encrypt records before PutRecord
cmk.grantEncrypt(producerRole);

// Consumer role — analytics Lambda / KCL worker
const consumerRole = new Role(this, "McpConsumerRole", {
  assumedBy: new ServicePrincipal("lambda.amazonaws.com"),
});
consumerRole.addToPolicy(new PolicyStatement({
  effect: Effect.ALLOW,
  actions: [
    "kinesis:GetRecords",
    "kinesis:GetShardIterator",
    "kinesis:DescribeStream",
    "kinesis:DescribeStreamSummary",
    "kinesis:ListShards",
    "kinesis:ListStreams",
  ],
  resources: [stream.streamArn],
}));
// Enhanced fan-out: add SubscribeToShard + RegisterStreamConsumer
consumerRole.addToPolicy(new PolicyStatement({
  effect: Effect.ALLOW,
  actions: [
    "kinesis:SubscribeToShard",
    "kinesis:RegisterStreamConsumer",
    "kinesis:DescribeStreamConsumer",
  ],
  resources: [
    stream.streamArn,
    // Enhanced fan-out consumer ARN is a child resource of the stream
    `${stream.streamArn}/consumer/*`,
  ],
}));
// KMS permission to decrypt records on GetRecords
cmk.grantDecrypt(consumerRole);

KMS server-side encryption — CMK vs aws/kinesis

Kinesis Data Streams supports server-side encryption using either the AWS managed key (aws/kinesis) or a customer-managed CMK. The managed key is the easier option but has a critical limitation: it cannot be used for cross-account access because other accounts cannot reference your account's managed key. If any consumer of your stream runs in a different AWS account — a separate analytics account, a partner integration, a security tooling account — you must use a CMK.

A second difference: with a CMK you get KMS CloudTrail events for every Encrypt and Decrypt call, giving you record-level access audit. With aws/kinesis you get no KMS-level audit trail — only Kinesis data events if you enable CloudTrail data events for Kinesis separately.

// CDK — customer-managed CMK for Kinesis encryption with cross-account access
import { Key, KeySpec, KeyUsage } from "aws-cdk-lib/aws-kms";
import { AccountPrincipal, ArnPrincipal, PolicyStatement, Effect } from "aws-cdk-lib/aws-iam";

const cmk = new Key(this, "McpKinesisKey", {
  description: "CMK for mcp-tool-events Kinesis stream encryption",
  keySpec: KeySpec.SYMMETRIC_DEFAULT,
  keyUsage: KeyUsage.ENCRYPT_DECRYPT,
  enableKeyRotation: true,
  policy: new PolicyDocument({
    statements: [
      // Admin — key management (not data access)
      new PolicyStatement({
        effect: Effect.ALLOW,
        principals: [new AccountPrincipal(this.account)],
        actions: ["kms:*"],
        resources: ["*"],
      }),
      // Cross-account consumer in analytics account — decrypt only
      new PolicyStatement({
        effect: Effect.ALLOW,
        principals: [new ArnPrincipal("arn:aws:iam::444455556666:role/AnalyticsConsumerRole")],
        actions: ["kms:Decrypt", "kms:GenerateDataKey"],
        resources: ["*"],
        conditions: {
          StringEquals: {
            "kms:ViaService": `kinesis.${this.region}.amazonaws.com`,
            "kms:CallerAccount": "444455556666",
          },
        },
      }),
    ],
  }),
});

// Stream with CMK encryption
const stream = new Stream(this, "McpEventStream", {
  streamName: "mcp-tool-events",
  shardCount: 2,
  encryption: StreamEncryption.KMS,
  encryptionKey: cmk,
});
aws/kinesis managed keyCustomer-managed CMK
Cost Free (no key cost) $1/month/key + $0.03/10K API calls
Cross-account access Not supported Supported via key policy
Key rotation AWS manages Configurable (annual automatic or manual)
KMS audit trail No Yes — CloudTrail logs every Encrypt/Decrypt
Key deletion Cannot delete Schedulable (7–30 day waiting period)

VPC interface endpoints — keeping Kinesis traffic off the public internet

By default, SDK calls to Kinesis resolve to public endpoints and route through the public internet even when made from a Lambda inside a VPC. To keep Kinesis API traffic within AWS's network fabric, create a VPC interface endpoint for Kinesis. Unlike S3 and DynamoDB gateway endpoints (free), Kinesis uses an interface endpoint (PrivateLink) that costs $0.01/endpoint-hour per AZ.

# Create VPC interface endpoint for Kinesis Data Streams
aws ec2 create-vpc-endpoint \
  --vpc-id vpc-0abc12345 \
  --service-name com.amazonaws.us-east-1.kinesis-streams \
  --vpc-endpoint-type Interface \
  --subnet-ids subnet-0111 subnet-0222 subnet-0333 \
  --security-group-ids sg-0endpoint \
  --private-dns-enabled  # resolves kinesis.us-east-1.amazonaws.com to private IP

# Security group for the endpoint — allow inbound 443 from Lambda/ECS task SG
aws ec2 authorize-security-group-ingress \
  --group-id sg-0endpoint \
  --protocol tcp \
  --port 443 \
  --source-group sg-0lambda-tasks \
  --description "Kinesis endpoint access from Lambda/ECS tasks"

# Verify private DNS resolution (should return 10.x.x.x, not 52.x.x.x)
nslookup kinesis.us-east-1.amazonaws.com

A common misconfiguration: creating the endpoint without enabling private DNS. Without private DNS, Lambda functions still resolve kinesis.amazonaws.com to a public IP and the endpoint goes unused. Always set --private-dns-enabled when creating the endpoint.

Endpoint policy: you can attach a resource policy to the VPC endpoint to restrict which streams can be accessed through it. This adds a defense-in-depth layer — even if a Lambda function has IAM permissions to access all streams, the endpoint policy limits it to only streams in the approved list.

# VPC endpoint policy — restrict to specific stream ARNs only
cat > endpoint-policy.json <<'EOF'
{
  "Statement": [{
    "Effect": "Allow",
    "Principal": "*",
    "Action": [
      "kinesis:PutRecord",
      "kinesis:PutRecords",
      "kinesis:GetRecords",
      "kinesis:GetShardIterator",
      "kinesis:DescribeStream",
      "kinesis:ListShards"
    ],
    "Resource": [
      "arn:aws:kinesis:us-east-1:123456789012:stream/mcp-tool-events",
      "arn:aws:kinesis:us-east-1:123456789012:stream/mcp-anomalies"
    ]
  }]
}
EOF

aws ec2 modify-vpc-endpoint \
  --vpc-endpoint-id vpce-0abc12345 \
  --policy-document file://endpoint-policy.json

CloudTrail data events for Kinesis API audit

CloudTrail management events (CreateStream, DeleteStream, UpdateShardCount) are logged automatically with no configuration. Data events (PutRecord, PutRecords, GetRecords, GetShardIterator) must be explicitly enabled and cost $0.10 per 100K API calls — significant for high-throughput MCP streams. Enable data events only on streams that carry sensitive data or where you have a compliance requirement to track who read what.

# Enable CloudTrail data events for specific Kinesis streams
aws cloudtrail put-event-selectors \
  --trail-name mcp-audit-trail \
  --advanced-event-selectors '[
    {
      "Name": "KinesisDataEventsForMcpStreams",
      "FieldSelectors": [
        { "Field": "eventCategory", "Equals": ["Data"] },
        { "Field": "resources.type", "Equals": ["AWS::Kinesis::Stream"] },
        { "Field": "resources.ARN", "StartsWith": [
          "arn:aws:kinesis:us-east-1:123456789012:stream/mcp-"
        ]}
      ]
    }
  ]'

# Query recent GetRecords calls from CloudTrail Lake (faster than S3 Athena for recent events)
aws cloudtrail start-query \
  --query-statement "
    SELECT userIdentity.arn, eventTime, requestParameters
    FROM events
    WHERE eventName = 'GetRecords'
      AND eventSource = 'kinesis.amazonaws.com'
      AND eventTime > '2026-09-20 00:00:00'
    ORDER BY eventTime DESC
    LIMIT 50
  " \
  --event-data-store arn:aws:cloudtrail:us-east-1:123456789012:eventdatastore/EXAMPLE

Cross-account stream access

Kinesis Data Streams does not support resource-based policies on the stream itself (unlike S3 or SQS). Cross-account access requires the IAM principal in the other account to assume a role in the stream's account. The typical pattern for cross-account consumers: the consumer account's role assumes a cross-account role in the stream's account that has Kinesis read permissions, and that cross-account role trusts the consumer role via its trust policy.

// In the stream's account — cross-account consumer role
const crossAccountConsumerRole = new Role(this, "CrossAccountKinesisConsumerRole", {
  roleName: "mcp-kinesis-cross-account-consumer",
  assumedBy: new ArnPrincipal("arn:aws:iam::444455556666:role/AnalyticsWorkerRole"),
  description: "Allows analytics account to consume mcp-tool-events stream",
});
crossAccountConsumerRole.addToPolicy(new PolicyStatement({
  effect: Effect.ALLOW,
  actions: [
    "kinesis:GetRecords",
    "kinesis:GetShardIterator",
    "kinesis:DescribeStream",
    "kinesis:DescribeStreamSummary",
    "kinesis:ListShards",
    "kinesis:SubscribeToShard",
    "kinesis:RegisterStreamConsumer",
    "kinesis:DescribeStreamConsumer",
  ],
  resources: [
    stream.streamArn,
    `${stream.streamArn}/consumer/*`,
  ],
}));
// Allow decrypt of CMK-encrypted records
cmk.grantDecrypt(crossAccountConsumerRole);

// In the consumer account — assume the cross-account role
// (set AWS_ROLE_ARN env var to the cross-account role ARN)
const stsClient = new STSClient({ region: "us-east-1" });
const assumed = await stsClient.send(new AssumeRoleCommand({
  RoleArn: process.env.KINESIS_CROSS_ACCOUNT_ROLE_ARN!,
  RoleSessionName: "analytics-consumer",
  DurationSeconds: 3600,
}));
const credentials = {
  accessKeyId: assumed.Credentials!.AccessKeyId!,
  secretAccessKey: assumed.Credentials!.SecretAccessKey!,
  sessionToken: assumed.Credentials!.SessionToken!,
};
const kinesisClient = new KinesisClient({ region: "us-east-1", credentials });

Security observability — monitoring for stream access anomalies

Beyond access control, use CloudWatch and CloudTrail to detect anomalous stream access patterns: unexpected consumers, unusually high GetRecords rates from a single role, or PutRecords from an IP outside your VPC.

# CloudWatch metric filter for Kinesis GetRecords from unexpected IAM roles
# (Requires CloudTrail data events → CloudWatch Logs delivery enabled)
aws logs put-metric-filter \
  --log-group-name CloudTrail/KinesisDataEvents \
  --filter-name unexpected-kinesis-consumer \
  --filter-pattern '{ ($.eventName = "GetRecords") && ($.userIdentity.arn != "*mcp-analytics*") }' \
  --metric-transformations \
    metricName=UnexpectedKinesisGetRecords,metricNamespace=MCP/Security,metricValue=1

aws cloudwatch put-metric-alarm \
  --alarm-name mcp-unexpected-kinesis-consumer \
  --namespace MCP/Security \
  --metric-name UnexpectedKinesisGetRecords \
  --statistic Sum \
  --period 300 \
  --evaluation-periods 1 \
  --threshold 1 \
  --comparison-operator GreaterThanOrEqualToThreshold \
  --alarm-description "Kinesis stream accessed by unexpected IAM role"

Security configuration reference

ControlDefaultRecommended for MCP streamsCost impact
Encryption at rest None CMK (not aws/kinesis) $1/mo/key + KMS API calls
Network path Public internet VPC interface endpoint per AZ $0.01/endpoint-hour/AZ
IAM separation Single role with full kinesis:* Separate producer/consumer roles None
CloudTrail management events Enabled (free) Already enabled None
CloudTrail data events Disabled Enable on sensitive streams only $0.10/100K API calls
Enhanced monitoring Disabled Enable WriteProvisionedThroughputExceeded $0.015/shard-hour/metric
Endpoint policy Allow all Restrict to specific stream ARNs None

Common failure modes reference

Error / symptomRoot causeFix
Cross-account consumer can't decrypt records Stream encrypted with aws/kinesis (account-scoped) Migrate to CMK; add cross-account principal to key policy
Lambda in VPC still routes Kinesis calls to public endpoint VPC endpoint created without private DNS enabled Delete and recreate endpoint with --private-dns-enabled
AccessDeniedException on GetRecords from VPC Lambda Endpoint security group not allowing inbound 443 from Lambda SG Add inbound TCP 443 rule from Lambda security group to endpoint SG
KMS ThrottlingException on high-throughput stream KMS request rate limit (default 5,500/s per account) Request KMS quota increase; use data key caching in SDKs
CloudTrail data events not appearing for Kinesis Advanced event selectors use wrong resource type Resource type must be AWS::Kinesis::Stream (not ::DataStream)