Recently I faced an issue with AWS EventBridge Connections. It's a managed AWS service that handles secrets for you—you configure authentication for an API (either for yourself or your customers, like webhooks), and EventBridge Connections handles the rest when attached to EventBridge API Destination or Step Functions HTTP invoke tasks.
Both services seem great at first glance, but reveal limitations once you move beyond simple use cases. In my case, the lack of customization and control became a blocker. This led me to research alternatives: Where can I store customer-provided secrets or sensitive data securely?
The Obvious Choice: AWS Secrets Manager
For most people, the first solution that comes to mind is AWS Secrets Manager. Secrets Manager is a fully managed service designed specifically for storing and rotating secrets like database credentials, API keys, and OAuth tokens.
What is Secrets Manager?
AWS Secrets Manager helps you protect access to your applications, services, and IT resources without upfront investment and ongoing maintenance costs. It enables you to rotate, manage, and retrieve database credentials, API keys, and other secrets throughout their lifecycle.
Key features include:
- Automatic secret rotation
- Fine-grained access control via IAM
- Audit and compliance through CloudTrail logging
- Integration with RDS, DocumentDB, and other AWS services
The Downsides
While Secrets Manager is powerful, it's not always necessary:
- Cost: Secrets Manager charges $0.40 per secret per month, plus $0.05 per 10,000 API calls. For applications managing many customer secrets, this adds up quickly.
- Overkill for simple use cases: If you don't need automatic rotation or the advanced features, you're paying for functionality you won't use.
- Complexity: For straightforward encryption needs, the service adds unnecessary overhead.
This is where AWS Key Management Service (KMS) becomes an attractive alternative.
A Better Fit: AWS KMS
What is KMS?
AWS Key Management Service (KMS) is a managed service that makes it easy to create and control cryptographic keys used to encrypt your data. Unlike Secrets Manager, KMS doesn't store your secrets—it stores encryption keys that you use to encrypt and decrypt data yourself.
Think of it this way:
- Secrets Manager: A secure vault that stores your secrets
- KMS: A key custodian that holds the keys you use to lock/unlock your own vault
Why KMS for DynamoDB?
DynamoDB Encryption vs Application-Level Encryption
It's important to clarify that DynamoDB already encrypts all data at rest by default using AWS-managed KMS keys. This protects your data against physical disk access and AWS infrastructure-level threats.
However, server-side encryption (SSE) alone is often not sufficient when dealing with customer-provided secrets.
Application-level encryption (encrypting data before storing it in DynamoDB) provides additional guarantees:
- Protects against overly permissive IAM policies
- Limits exposure in case of accidental data access
- Keeps data encrypted in exports, backups, and logs
- Enables fine-grained access control at the application boundary
In this guide, we’re focusing on application-level encryption, where sensitive values are encrypted using KMS before being written to DynamoDB, rather than relying solely on DynamoDB’s built-in encryption at rest.
When storing sensitive data in DynamoDB, you have two main approaches:
- Store references in DynamoDB: Encrypt secrets, store them in AWS Secret Manager (SSM), then store the reference in DynamoDB
- Store encrypted data directly in DynamoDB: Encrypt the sensitive data with KMS and store the encrypted value directly in your DynamoDB table
The second approach is simpler and more cost-effective for many use cases. Let's explore how to implement it.
Setting Up KMS with AWS CDK
Here's how to create a KMS key and configure it for use with DynamoDB:
import * as kms from 'aws-cdk-lib/aws-kms';
import * as dynamodb from 'aws-cdk-lib/aws-dynamodb';
import * as iam from 'aws-cdk-lib/aws-iam';
import { Stack, StackProps, RemovalPolicy } from 'aws-cdk-lib';
import { Construct } from 'constructs';
export class SecureStorageStack extends Stack {
constructor(scope: Construct, id: string, props?: StackProps) {
super(scope, id, props);
// Create a KMS key for encrypting sensitive data
const encryptionKey = new kms.Key(this, 'SensitiveDataKey', {
description: 'Key for encrypting customer secrets in DynamoDB',
enableKeyRotation: true, // Automatically rotate key every year
removalPolicy: RemovalPolicy.RETAIN, // Keep key even if stack is deleted
});
// Create DynamoDB table
const secretsTable = new dynamodb.Table(this, 'SecretsTable', {
partitionKey: { name: 'customerId', type: dynamodb.AttributeType.STRING },
sortKey: { name: 'secretId', type: dynamodb.AttributeType.STRING },
billingMode: dynamodb.BillingMode.PAY_PER_REQUEST,
});
// Grant your Lambda function access to the key
// (Assuming you have a Lambda function defined)
encryptionKey.grantEncryptDecrypt(yourLambdaFunction);
secretsTable.grantReadWriteData(yourLambdaFunction);
// Add key ARN to Lambda environment variables
yourLambdaFunction.addEnvironment('KMS_KEY_ID', encryptionKey.keyId);
yourLambdaFunction.addEnvironment('SECRETS_TABLE_NAME', secretsTable.tableName);
}
}
Encrypting and Decrypting in TypeScript
Important Limitation: KMS Encrypt Size Limit
AWS KMS Encrypt has a maximum plaintext size of 4 KB. This works well for small secrets such as:
- API keys
- Webhook secrets
- Short OAuth tokens
However, it will not work for larger payloads like:
- PEM certificates
- Large JSON credentials
- Multi-field configuration blobs
Envelope Encryption for Larger Secrets
For secrets larger than 4 KB, you should use envelope encryption:
- Use KMS to generate a data encryption key (DEK)
- Encrypt the secret locally using a symmetric algorithm (e.g. AES-256-GCM)
- Store the encrypted secret and the encrypted data key together in DynamoDB
- Decrypt the data key with KMS only when needed
This approach:
- Scales to arbitrarily large secrets
- Minimizes KMS API calls
- Is the recommended best practice by AWS
In this article, we focus on the direct Encrypt / Decrypt approach for simplicity and small secrets. For production systems handling larger payloads, envelope encryption should be used instead.
Here's how to encrypt and decrypt sensitive data using the AWS SDK for JavaScript v3:
import { KMSClient, EncryptCommand, DecryptCommand } from '@aws-sdk/client-kms';
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
import { DynamoDBDocumentClient, PutCommand, GetCommand } from '@aws-sdk/lib-dynamodb';
const kmsClient = new KMSClient({});
const dynamoClient = DynamoDBDocumentClient.from(new DynamoDBClient({}));
const KMS_KEY_ID = process.env.KMS_KEY_ID!;
const TABLE_NAME = process.env.SECRETS_TABLE_NAME!;
interface CustomerSecret {
customerId: string;
secretId: string;
encryptedValue: string;
createdAt: string;
}
/**
* Encrypt a sensitive value using KMS
*/
async function encryptSecret(plaintext: string): Promise<string> {
const command = new EncryptCommand({
KeyId: KMS_KEY_ID,
Plaintext: Buffer.from(plaintext, 'utf-8'),
});
const response = await kmsClient.send(command);
if (!response.CiphertextBlob) {
throw new Error('Encryption failed: no ciphertext returned');
}
// Convert to base64 for storage
return Buffer.from(response.CiphertextBlob).toString('base64');
}
/**
* Decrypt a KMS-encrypted value
*/
async function decryptSecret(encryptedValue: string): Promise<string> {
const command = new DecryptCommand({
CiphertextBlob: Buffer.from(encryptedValue, 'base64'),
// Note: KeyId is optional for decrypt - KMS knows which key was used
});
const response = await kmsClient.send(command);
if (!response.Plaintext) {
throw new Error('Decryption failed: no plaintext returned');
}
return Buffer.from(response.Plaintext).toString('utf-8');
}
/**
* Store an encrypted secret in DynamoDB
*/
async function storeSecret(
customerId: string,
secretId: string,
plainSecret: string
): Promise<void> {
const encryptedValue = await encryptSecret(plainSecret);
const item: CustomerSecret = {
customerId,
secretId,
encryptedValue,
createdAt: new Date().toISOString(),
};
await dynamoClient.send(new PutCommand({
TableName: TABLE_NAME,
Item: item,
}));
}
/**
* Retrieve and decrypt a secret from DynamoDB
*/
async function getSecret(
customerId: string,
secretId: string
): Promise<string | null> {
const response = await dynamoClient.send(new GetCommand({
TableName: TABLE_NAME,
Key: { customerId, secretId },
}));
if (!response.Item) {
return null;
}
const secret = response.Item as CustomerSecret;
return await decryptSecret(secret.encryptedValue);
}
// Example usage
async function example() {
// Store a customer's API key
await storeSecret('customer-123', 'api-key', 'super-secret-api-key-xyz');
// Retrieve and decrypt it later
const apiKey = await getSecret('customer-123', 'api-key');
console.log('Decrypted API key:', apiKey);
}
SSM Parameter Store vs KMS: The Trade-offs
You might wonder: should I use SSM Parameter Store with KMS encryption, or encrypt data directly with KMS and store it in DynamoDB?
SSM Parameter Store Approach
Pros:
- Centralized secret management
- Built-in versioning
- Free tier: up to 10,000 parameters
- Parameter Store integrates with many AWS services
Cons:
- Extra API calls (SSM + DynamoDB)
- Additional latency
- Two services to manage
- 10,000 parameter limit may be restrictive for large-scale applications
Example:
// Store in SSM, reference in DynamoDB
const paramName = `/customers/${customerId}/secrets/${secretId}`;
await ssm.putParameter({
Name: paramName,
Value: plainSecret,
Type: 'SecureString', // Uses KMS encryption
KeyId: KMS_KEY_ID,
});
// Store reference in DynamoDB
await dynamodb.putItem({
TableName: 'Customers',
Item: {
customerId: { S: customerId },
secretRef: { S: paramName }, // Just the reference
},
});
Direct KMS Encryption in DynamoDB
Pros:
- Single service (DynamoDB)
- Lower latency (one API call instead of two)
- No parameter count limits
- Simpler architecture
Cons:
- No built-in versioning (you'd implement it yourself)
- Less visibility in AWS Console
- Manual rotation handling
When to use which:
- Use SSM Parameter Store if you need versioning, have < 10,000 secrets, or want integration with other AWS services
- Use direct KMS encryption for high-scale applications, lower latency requirements, or when secrets are tightly coupled with your DynamoDB data model
Pricing Comparison
Let's compare costs for storing 50,000 customer secrets:
Secrets Manager
- Storage: 50,000 secrets × $0.40/month = $20,000/month
- API calls: Assuming 1M calls/month = 100 × $0.05 = $5/month
- Total: ~$20,005/month
KMS + DynamoDB
- KMS key: 1 key × $1/month = $1/month
- KMS API calls: Encrypt + Decrypt requests are billed separately at $0.03 per 10,000 requests (pricing varies slightly by region). Assuming ~1M total requests per month: ~$3/month (Note: KMS also includes a small free tier for requests)
- DynamoDB storage: 50,000 items × ~1KB = ~50MB × $0.25/GB = $0.01/month
- DynamoDB reads/writes: Varies by usage, let's say $10/month for moderate traffic
- Total: ~$14/month
The cost difference is dramatic: $20,005 vs $14 per month for the same number of secrets.
SSM Parameter Store + DynamoDB
- SSM: First 10,000 free, then $0.05 per parameter/month
- For 50,000 params: 40,000 × $0.05 = $2,000/month
- Plus KMS and DynamoDB costs: ~$14/month
- Total: ~$2,014/month
Conclusion
While AWS Secrets Manager is excellent for managing application secrets with automatic rotation, it's often overkill—and expensive—for storing customer-provided secrets or sensitive data at scale.
For most use cases involving customer secrets in DynamoDB, encrypting data directly with KMS offers the best balance of:
- Security: Strong encryption with managed keys
- Performance: Single API call to retrieve data
- Cost: Dramatically lower than Secrets Manager
- Simplicity: Fewer moving parts
The trade-off is that you’ll need to implement your own versioning logic if required. In practice, most customer-provided secrets cannot and should not be rotated automatically by your system. API keys, webhook secrets, and OAuth credentials are typically owned and rotated by the customer or an external provider, making Secrets Manager’s rotation features largely irrelevant for these use cases. But for many applications, this is a worthwhile exchange for the cost savings and performance benefits.
Key takeaway: Choose the right tool for the job. Secrets Manager shines for application secrets with rotation needs. KMS excels for high-volume, customer-specific data encryption.
Have you dealt with similar challenges managing secrets at scale? I'd love to hear about your approach in the comments below.
Top comments (0)