Skip to content
Technical Guide Security and Reliability

AWS CDK KMS Encryption with IAM Roles: One Stack for EC2, S3, and RDS

Your IAM permissions are a wiki page nobody trusts. This post walks through a CDK TypeScript stack that encodes KMS encryption, IAM roles, and RDS storage security as versioned, reviewable code - and covers the operational traps most tutorials skip entirely.

Best for

Engineers implementing or reviewing a production decision, not readers looking for a demo-level walkthrough.

Updated

6 Apr 2026

What this guide should answer

Use when, why this matters, the implementation path, and the trade-offs that matter once you leave the tutorial happy path.

AWS CDK KMS Encryption with IAM Roles: One Stack for EC2, S3, and RDS

So your security team asked for an IAM audit trail last quarter. You sent them a Confluence page. They were not impressed.

If you've been managing AWS permissions through the console, storing credentials in .env files, and telling yourself "we'll clean this up before the audit" - this post is for you. We're going to walk through a CDK TypeScript stack that sets up aws cdk kms encryption iam roles across EC2, S3, and RDS as actual versioned code, not a wiki page nobody trusts. And more importantly, we're going to cover the operational traps that every other tutorial conveniently skips.


Who Is This For

architecture diagram for aws ec2 s3 iam security as code security
Technical infrastructure map for AWS Ec2 S3 IAM Security As Code Security. This repo-grade view shows the concrete AWS services, regional boundaries, and control-plane components that govern failover, recovery, and auditability across us-east-2.

You're running production workloads on AWS. You've got EC2 instances, an RDS database, probably some S3 buckets. Your IAM story is... let's call it "organic." Some roles were created by the console wizard six months ago. There are access keys in a few Lambda environment variables. Your RDS snapshots may or may not be encrypted - you're honestly not sure.

You've heard of CDK, maybe used it for simple stuff. You're comfortable with TypeScript. You want to understand not just how to write the stack, but why each decision was made - because your future self (or a new teammate) will need to read this code and understand it.

This is not for AWS beginners. I'm going to assume you know what a KMS CMK is, what IAM roles are, and roughly how CDK stacks work. We're going here for the why and the what breaks, not the what is IAM.


Problem and Why It Matters

OK so here's the situation I see constantly. A team builds out their AWS infrastructure over 6-12 months. Permissions accumulate like sediment. Someone needed S3 read access for a one-off script, so they attached a policy directly to a user. That user left. Nobody noticed. The policy is still there.

Classic AWS.

Then the compliance team shows up and asks: "Can you show us every permission change from the last 90 days, who made it, and why?" And you're sitting there staring at CloudTrail logs trying to reconstruct a narrative from raw API calls.

The problem isn't that your team is bad at AWS. The problem is that manual permission grants are undocumented by design. The console doesn't ask you why you're doing something. It just does it. And three months later, nobody remembers.

There's also the encryption story. Unencrypted RDS snapshots are one of the most common compliance findings I see. Not because teams don't care, but because RDS doesn't encrypt by default, and nobody went back to retrofit it. Same with S3 - you can add bucket encryption after the fact, but now you've got a mix of encrypted and unencrypted objects and a migration project nobody budgeted for. Not ideal.

The CDK approach we're building here actually treats your security posture as a pull request. Every permission is TypeScript. Every encryption decision is reviewed in code. If someone wants to change an IAM policy, they open a PR. Your security team can review it. It gets merged. It's in git forever. That's the audit trail they're actually asking for, right? Check out how IAM least privilege patterns on AWS can reinforce this - the CDK structure we build here makes those patterns enforceable rather than aspirational.


When This Approach Fits

This pattern works really well when you have two or three environments (dev, staging, prod) that should be structurally identical but have different data sensitivity levels. It also works well when you have a compliance requirement that needs evidence - SOC 2, ISO 27001, HIPAA, whatever - because "here's the PR where we added that permission" is a genuinely good audit artifact.

But it does not work well if you need break-glass emergency access. I'll talk about this in the trade-offs section, but encoding all permissions in CDK means a permission change requires a deploy cycle. If your on-call engineer needs to grant something at 2 AM, CDK is not your friend.

It also doesn't work well if your team is very small and your infrastructure is simple. If you've got one environment and two engineers, the overhead of CDK context management and KMS key policies might not be worth it yet.


Architecture or Implementation Overview

So basically, the encryption anchor for everything is a single KMS CMK per environment. That one key encrypts S3 bucket objects, RDS storage, and EC2 EBS volumes. [from-code]

Here's a simplified look at the KMS key construct, which is the free-tier part I can show you:

const encryptionKey = new kms.Key(this, 'EnvironmentKey', {
 alias: `alias/myapp-${environmentSuffix}`,
 enableKeyRotation: true,
 removalPolicy: environmentSuffix === 'prod'
 ? cdk.RemovalPolicy.RETAIN
 : cdk.RemovalPolicy.DESTROY,
 description: `CMK for ${environmentSuffix} - S3, RDS, EBS`,
});

Notice enableKeyRotation: true. That's automatic annual key rotation. And notice the removalPolicy conditional - prod retains the key, dev destroys it on cdk destroy. This is the environment suffix pattern at work. [from-code]

The IAM roles are synthesized by CDK constructs, not managed in the console. Every role has an explicit trust policy and a scoped permission boundary. Every policy statement is TypeScript. [from-code]

The environment suffix itself comes from CDK context - you pass it in at synth time with --context environmentSuffix=prod. More on why this matters (and how it can go spectacularly wrong) shortly. For more on how CDK environment-specific deployments should be structured, this guide goes deeper.


Step-by-Step Implementation

1. Set up your CDK app with context reading

Pull the environment suffix from context early, and I mean validate it:

const environmentSuffix = app.node.tryGetContext('environmentSuffix') ?? 'dev';

The ?? 'dev' fallback is both convenient and dangerous. We'll come back to that.

2. Create the KMS CMK

The snippet above is your starting point. The key gets created first because everything else depends on it.

3. Create the S3 bucket with CMK encryption

const dataBucket = new s3.Bucket(this, 'DataBucket', {
 bucketName: `myapp-data-${environmentSuffix}`,
 encryptionKey: encryptionKey,
 encryption: s3.BucketEncryption.KMS,
 blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
 removalPolicy: environmentSuffix === 'prod'
 ? cdk.RemovalPolicy.RETAIN
 : cdk.RemovalPolicy.DESTROY,
});

4. Create the RDS instance with the same CMK

const database = new rds.DatabaseInstance(this, 'Database', {
 engine: rds.DatabaseInstanceEngine.postgres({
 version: rds.PostgresEngineVersion.VER_15,
 }),
 storageEncrypted: true,
 storageEncryptionKey: encryptionKey,
 deletionProtection: environmentSuffix === 'prod',
 // ... rest of config
});

Notice deletionProtection: environmentSuffix === 'prod'. This is actually load-bearing. Without it, a cdk destroy on a misconfigured pipeline could make your encrypted prod snapshots permanently unreadable.

5. IAM roles - the full walkthrough is in the paid tier, but the principle is this

Every role gets an explicit trust policy scoped to the service that needs it. Roles reference the KMS key via a grantDecrypt() or grantEncryptDecrypt() call on the CDK Key construct, which auto-generates the right key policy statement. No hand-rolling key policies. This is actually pretty clever - CDK is doing the tedious bits for you.


Trade-offs

Single CMK vs. per-service keys. I went with one key per environment because honestly, managing separate key policies for S3, RDS, and EBS is tedious and error-prone. But the blast radius problem is real - if you misconfigure that one key policy, you've locked yourself out of everything simultaneously. Per-service keys would isolate failures, but you'd be maintaining three separate key policies per environment. For most teams at this scale, one key is the right call. Just don't get lazy about testing key access. [editorial]

And here's the kicker - key rotation doesn't re-encrypt your data. This blew my mind the first time I looked into it. When AWS rotates your CMK's backing material, existing ciphertexts still decrypt fine - the old material isn't deleted. New encryptions use the new material. So from a compliance standpoint, rotation is fine (most frameworks are happy with annual rotation). From an "I thought my data was being re-encrypted" standpoint - it isn't. [inferred]

CDK deploy cycle for permission changes. Every IAM change is 3-8 minutes of deploy time. There's no break-glass path for emergency access without drifting from IaC state. For most teams this is fine. If you have on-call scenarios where someone needs instant access changes, you need a separate compensating control - maybe an SSM parameter that controls an IAM condition, or a pre-approved escalation role. [editorial]


Failure Modes

OK this is the part I actually care about. Real failure modes I've seen or can reason through clearly.

The 2 AM DR drill failure. You know how you encrypted RDS with a CMK and felt good about it? Right. So disaster happens. You try to restore the snapshot in another account or region. AccessDeniedException. Why? Because the CMK key policy never explicitly granted kms:Decrypt to the target account. Your encrypted backup is unreadable. (I learned this the hard way on a Friday deploy.) This is a genuinely common DR drill failure and it's brutal when it happens for real. Fix: add cross-account key policy grants explicitly, or use AWS Backup with cross-region replication that handles this for you. [editorial]

The silent dev-fallback problem. Your CI pipeline forgets to pass --context environmentSuffix=prod. CDK silently falls back to 'dev'. The stack synthesizes with RemovalPolicy.DESTROY and no deletionProtection. It deploys against your prod account. Nothing fails. Nothing warns you. Until someone runs cdk destroy. [from-code] I did not see this coming the first time it bit a team I was talking to. Fix: validate the context value explicitly and throw if it's not one of ['dev', 'staging', 'prod'].

KMS key scheduled for deletion. The mandatory waiting period is 7-30 days. If someone schedules a key for deletion accidentally, every kms:Decrypt call against data encrypted with that key will throw AccessDeniedException for the entire waiting period. There's no automatic rollback. You can cancel the deletion manually if you catch it in time. Set up a CloudWatch alarm on KMSKeyPendingDeletion - it's a real event. [inferred]

IAM drift. Someone with console access adds an inline policy to a CDK-managed role. CDK doesn't know about it. Your state and reality diverge silently. The fix is either an AWS Config rule (iam-no-inline-policy-check) or an SCP that Denys iam:PutRolePolicy. Without one of these, you'll discover the drift the hard way. [editorial]


Security and Operational Considerations

There's actually something genuinely missing from this stack that I want to flag: there's no CloudTrail or AWS Config integration in the visible code. [inferred] The compliance story is incomplete if KMS key usage and IAM activity aren't being logged and alarmed on. Having encrypted resources is necessary but not sufficient - you also need evidence that the encryption is working and that nobody is misusing their accessr access. AWS Config and CloudTrail for compliance evidence is worth reading alongside this guide - treat it as the monitoring layer that makes this security layer meaningful.

Oh, and another thing: RDS deletion protection is not the same as RDS snapshot retention. Even with deletion protection enabled, you can still delete snapshots. Make sure your backup retention is set explicitly and backed by a Backup plan.


Cost Reality

KMS CMK: $1/month per key. With one key per environment and three environments, that's $3/month. Not a concern.

KMS API calls: $0.03 per 10,000 requests. This adds up if you're hitting KMS for every S3 GET. Use the S3 bucket key feature to reduce API call volume significantly - it generates a data key per object rather than calling KMS per request.

RDS storage encryption: no additional charge for the encryption itself. You pay for storage as normal.

EBS volume encryption: no additional charge.

S3 encryption: no additional charge for SSE-KMS with bucket keys enabled. Without bucket keys, you'll see KMS API costs that scale with request volume.

Total overhead for this stack: roughly $3-5/month in KMS charges if you enable bucket keys. That's it. Seriously.


What I'd Do Differently

Validate the environment suffix at synth time. The silent fallback to 'dev' is the scariest thing in this entire stack and it's a two-line fix (don't ask how long it took me to convince myself this was worth adding):

const validSuffixes = ['dev', 'staging', 'prod'];
if (!validSuffixes.includes(environmentSuffix)) {
 throw new Error(`Invalid environmentSuffix: ${environmentSuffix}`);
}

I'd also add CloudTrail and a KMS key usage alarm from day one. Not after the first security review. Day one. It's maybe 40 lines of CDK and it closes the biggest gap in this compliance story.

And honestly - I'd think harder about the single-CMK decision for anything handling genuinely sensitive data. The operational simplicity is real, but so is the blast radius. Per-service keys with CDK's KeyPolicy constructs are actually more manageable than they look once you've written one. I was avoiding them for the wrong reasons for a while.


Next Step

If you're reading this on your third coffee trying to figure out where to start, here's the move: copy the KMS key construct with rotation, add it to an existing CDK stack you already have, and deploy it to dev. One key, no secrets, nothing breaking. Just see what the key policy looks like in the console and get comfortable with the grantEncryptDecrypt() pattern. That first key is the anchor - everything else slots in around it, right?

free: Everything in this post - the architecture thinking, the failure modes, the KMS key construct, the environment suffix pattern.

paid: Full CDK stack with IAM role and policy constructs, deployment sequence with context flag handling and validation, edge cases around cross-account DR and key deletion recovery, cost breakdown per request volume, and what actually breaks when the single-CMK model hits key policy size limits.

If the CDK angle is clicking but you want to go deeper on the IAM side of this, the IAM least privilege patterns post is the logical next read. That one gets into the iam:PassRole gotchas and permission boundaries that make the roles in this stack actually safe to deploy.

Your security posture should be a pull request. Start there.

Next step

Get new production-grade notes without the feed noise.

InfraTales is built for engineers and technical leaders who prefer a small number of useful deep dives over high-frequency newsletter filler.

Start here

About the author

Rahul Ladumor

Senior AWS Solution Architect, 6x AWS certified including GenAI Developer Professional. 9+ years building production infrastructure. Writes about what actually works — trade-offs, cost realities, and failure modes included.

Related reading

Continue from the same implementation path.