DEV Community

Sebastian
Sebastian

Posted on

Storing Sensitive Information in DynamoDB with KMS

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:

  1. 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.
  2. 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.
  3. 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:

  1. Store references in DynamoDB: Encrypt secrets, store them in AWS Secret Manager (SSM), then store the reference in DynamoDB
  2. 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);
  }
}
Enter fullscreen mode Exit fullscreen mode

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:

  1. Use KMS to generate a data encryption key (DEK)
  2. Encrypt the secret locally using a symmetric algorithm (e.g. AES-256-GCM)
  3. Store the encrypted secret and the encrypted data key together in DynamoDB
  4. 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);
}
Enter fullscreen mode Exit fullscreen mode

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
  },
});
Enter fullscreen mode Exit fullscreen mode

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)