Storing Sensitive Information in DynamoDB with KMS
Source: Dev.to
Introduction
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 an 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:
| Issue | Details |
|---|---|
| Cost | $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.
Analogy
- 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
- 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
When storing sensitive data in DynamoDB, you have two main approaches:
- Store references in DynamoDB – Encrypt secrets, store them in AWS Secrets Manager (or SSM Parameter Store), 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
Below is a minimal CDK stack that creates a KMS key and a DynamoDB table, then grants a Lambda function permission to encrypt/decrypt data.
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
It will not work for larger payloads like:
- PEM certificates
- Large JSON credentials
- Multi‑field configuration blobs
Envelope Encryption for Larger Payloads
(The original content cuts off here; you would continue with an explanation of envelope encryption, generating a data key, encrypting the payload with the data key, and storing the encrypted data key alongside the ciphertext.)
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
Why envelope encryption?
- Scales to arbitrarily large secrets
- Minimises KMS API calls
- 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.
Encrypting and decrypting sensitive data with 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');
}
// Store as base64
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'),
// 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
- Centralised secret management
- Built‑in versioning
- Free tier: up to 10 000 parameters
- Integrates with many AWS services
Cons
- Extra API calls (SSM + DynamoDB)
- Additional latency
- Two services to manage
- 10 000‑parameter limit can be restrictive at scale
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 must implement it)
- Less visibility in the AWS console
- Manual rotation handling
When to Use Which
- Need versioning – You’ll need to implement your own versioning logic if required.
- Customer‑provided secrets that are owned/rotated by the customer – Direct KMS encryption in DynamoDB is usually sufficient because rotation is handled externally.
Key takeaway: Choose the right tool for the job.
- Secrets Manager shines for application secrets that need rotation.
- 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.