Skip to content
← Writing
Technical guideAWS CDK10 min

AWS CDK IAM and VPC Security Enforced as Code: KMS, WAFv2, and Security Hub in One Stack

Security drift starts the moment someone opens the AWS console. This post walks through a production CDK TypeScript stack that enforces KMS encryption, least-privilege IAM, VPC Endpoint routing for secrets, WAFv2 on CloudFront and API Gateway, and Security Hub, all as versioned, testable code.

By Rahul Ladumor

This goes deeper than the usual "enable Security Hub and call it done" advice. You get the full architecture walkthrough, the failure modes I have actually hit, and a decision matrix for where VPC Endpoints stop saving money and NAT wins. The premise: when your security controls are console clicks, they drift; when they are CDK constructs, they have version history, code review, and tests.


Who This Is For

awsAWS Cloud · security as codeRequestResponseTelemetryEDGE / WAFPUBLIC SUBNETPRIVATE + ISOLATED12345Internetpublic requestWAFv2managed rule groupsCloudFrontedge + TLSALBpublic subnetApp / EC2private subnetRDSisolated subnetIAMleast-privilege rolesKMSencryption at restDEFENSE-IN-DEPTH: WAFv2 at the edge · KMS CMK encryption at rest · least-privilege IAM per role

You are probably here because a security audit came back with findings and at least half of them are for resources nobody remembers creating. This is for senior engineers and platform teams who already know what AWS security controls exist and are trying to figure out how to enforce them without trusting that humans will remember to check boxes.

If you have never touched CDK, this will be rough going. If you are comfortable with CDK TypeScript and want to move from "we have Security Hub enabled" to "our security posture is versioned code with tests," that is the audience.


Problem Framing

Here is how security drift actually happens. Someone needs to test something in prod, real quick, five minutes, so they open the console, create an S3 bucket, set it public "just for now," and forget about it. Six months later your audit flags it. Nobody knows why it exists. The person who created it left the company. You have 14 findings like this.

And the usual response is to tell people to be more careful. That has never worked once. aws cdk iam and vpc security as code is not just a nicer workflow, it is the approach that survives at scale, because the controls carry their own history and review.

Most of the real knowledge here is not the architecture itself - five services, one state machine of controls, fine. It is the operational detail that only accumulates through production incidents: what breaks when a CMK is scheduled for deletion, how Security Hub buries you in noise on day one, and where VPC Endpoints actually pay for themselves. That is what I want to spend the words on.

The architecture wires together six things that usually get set up independently and then drift apart: KMS-encrypted S3 with CloudTrail, a segmented VPC with VPC Endpoints for credential fetching, WAFv2 on both CloudFront and API Gateway, RDS with compliance-locked parameter groups, least-privilege Lambda IAM roles, and Security Hub aggregating findings from GuardDuty, Config, and Inspector. One CDK TypeScript stack.

The non-obvious decision is routing Secrets Manager and Parameter Store access through VPC Endpoints instead of NAT Gateway. Your Lambda functions live in private subnets. Without the endpoint, fetching a secret means traversing NAT to the public internet and back. With the endpoint, it stays inside the AWS network. It sounds obvious. Most stacks still do not do it, because nobody traces where those API calls go until the NAT bill makes them look.


Annotated Implementation

Here is what the stack looks like, walking through the decisions that matter.

The VPC layer first. Public subnets get the NAT Gateway and load balancers. Private subnets get Lambda, RDS, and nothing else reachable from outside. VPC Endpoints sit in the private subnets for S3 and Secrets Manager. Secrets Manager API calls without an endpoint go through NAT. At scale, with dozens of Lambda functions fetching secrets on cold start, those calls add up on your NAT bill and they traverse the public internet for no reason. I found this the way most people do: tracing where our NAT costs were actually coming from, months after the fact.

If you are trying to work out whether VPC Endpoints beat NAT on cost, the VPC Endpoint cost vs NAT Gateway break-even analysis is worth reading first. Short version: for S3 and Secrets Manager at any real volume, Endpoints win. For third-party API calls, NAT has no substitute.

The KMS layer. Every S3 bucket, CloudWatch Logs group, RDS instance, and CloudTrail trail uses a customer-managed key, not an AWS-managed one. CMKs give you key policy control, you can restrict who decrypts, and you get an audit trail per key. The trade-off is real: every Lambda cold start that decrypts makes a KMS API call. At $0.03 per 10,000 requests, plus $1/month per key and 20,000 free requests/month (KMS pricing, checked June 2026), this shows up in your cost report once you have high-traffic Lambdas.

Here is the CDK snippet for the encrypted S3 bucket with CloudTrail:

import * as cdk from 'aws-cdk-lib';
import * as s3 from 'aws-cdk-lib/aws-s3';
import * as kms from 'aws-cdk-lib/aws-kms';
import * as cloudtrail from 'aws-cdk-lib/aws-cloudtrail';
import * as logs from 'aws-cdk-lib/aws-logs';
import { Construct } from 'constructs';

export class SecurityStack extends cdk.Stack {
 constructor(scope: Construct, id: string, props?: cdk.StackProps) {
 super(scope, id, props);

 // CMK with rotation enabled
 const trailKey = new kms.Key(this, 'TrailKey', {
 enableKeyRotation: true,
 description: 'CMK for CloudTrail and S3 audit logs',
 removalPolicy: cdk.RemovalPolicy.RETAIN, // never let CDK delete this
 });

 // CloudTrail log group - encrypted, retention pinned
 // THIS is what most teams miss in audits
 const trailLogGroup = new logs.LogGroup(this, 'TrailLogGroup', {
 encryptionKey: trailKey,
 retention: logs.RetentionDays.ONE_YEAR,
 removalPolicy: cdk.RemovalPolicy.RETAIN,
 });

 // Audit bucket - block all public access, versioning on
 const auditBucket = new s3.Bucket(this, 'AuditBucket', {
 encryptionKey: trailKey,
 encryption: s3.BucketEncryption.KMS,
 blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
 versioned: true,
 enforceSSL: true,
 removalPolicy: cdk.RemovalPolicy.RETAIN,
 });

 // CloudTrail with log integrity validation
 const trail = new cloudtrail.Trail(this, 'AuditTrail', {
 bucket: auditBucket,
 cloudWatchLogGroup: trailLogGroup,
 enableFileValidation: true, // the config flag people set but never alert on
 encryptionKey: trailKey,
 sendToCloudWatchLogs: true,
 });
 }
}

See removalPolicy: cdk.RemovalPolicy.RETAIN on the CMK. That is not optional. CDK's default is to delete the key when you tear down the stack. If your CloudTrail logs are encrypted with that key and the key disappears, the logs are gone, non-recoverable. This is the single setting I check first on any security stack review.

And notice enableFileValidation: true on the trail. Validation is enabled, good. But if nothing monitors for integrity failures in CloudWatch, you have a config flag that generates no alerts. Wire a metric filter to it, or it is decoration.

The IAM layer. No wildcard principals anywhere. Every Lambda function gets its own IAM role, scoped to exactly the actions it needs. In CDK that means creating the role explicitly rather than letting the Lambda construct auto-create it, because auto-created roles accumulate permissions as people add things and nobody removes the old ones. For IAM patterns that scale across many functions without turning into copy-paste, the IAM least-privilege patterns in CDK post covers the factory pattern I use.


Decision Matrix

Deciding whether to go full CMK everywhere versus mixing CMK and AWS-managed keys. Here is how I think it through:

Control CMK AWS-managed key Console (please don't)
Key policy control Yes - you write it No - AWS owns it N/A
Cross-account decrypt Possible Not possible N/A
Audit trail per key CloudTrail per operation CloudTrail per operation No trail
Cost per 10k requests $0.03 Free Free
Rotation control You set schedule AWS handles it N/A
Accidental deletion risk High - 7-30 day wait Low High

My recommendation: CMK for anything with compliance requirements (CloudTrail, RDS, audit S3 buckets), AWS-managed for everything else. Do not CMK your Lambda deployment package bucket; the cost and complexity are not justified. I spent more time than I would like getting this balance wrong before landing here.

For VPC Endpoint vs NAT, the break-even works out roughly like this: above about 10 million S3 or Secrets Manager API calls per month from private subnets, VPC Endpoints pay for themselves over NAT data processing fees. Below that, the endpoint cost ($0.01/hr/AZ, about $7.30/month/AZ) is the bigger line.


Failure and Edge Cases

The KMS deletion scenario is the one that keeps me up. The mandatory waiting period for CMK deletion is 7 to 30 days. If someone runs cdk destroy on the wrong stack, CDK schedules the key for deletion. Your encrypted S3 buckets, CloudTrail logs, and RDS snapshots are still there, but unreadable, because the key is pending deletion. You get no alert unless you have explicitly set a CloudWatch alarm on KMSScheduledForDeletion. Most teams have not.

The WAFv2 drift problem is subtle but real. When you deploy WAF rules on CloudFront (global, us-east-1) and on API Gateway (regional, your region), you are managing two separate WebACLs. Update CloudFront for a new threat and forget API Gateway, or the reverse, and you have inconsistent protection. The architecture handles this with a shared rule group construct, but you still need a deployment process that enforces both get updated together.

The NAT Gateway surprise bill is one I have watched hit twice. A Lambda with a retry bug or a misconfigured VPC route starts hammering NAT. No throughput alarm, no spend alert. The bill that month is, say, $1,200 instead of $80 (illustrative, but the shape is real). NAT Gateway has no built-in throttle and no "you are spending way more than usual" alarm. You wire that yourself.

And Security Hub on day one generates hundreds of findings, most of them informational. The CIS benchmark alone flags things like "MFA not enabled on root account" (valid) alongside "CloudTrail not enabled in ap-northeast-3" (you do not operate there). Without suppression rules pre-built, your team spends the first week triaging noise instead of fixing real issues. The CloudTrail integrity monitoring with CloudWatch post has a metric-filter pattern that cuts through some of it.


Operational Guidance

A few things I would put in the runbook from day one.

Set a CloudWatch alarm on KMSScheduledForDeletion events in CloudTrail. This is a five-minute CDK task that prevents the worst case above. Do it.

Set a NAT Gateway bytes-processed alarm. If BytesProcessed exceeds, say, 50GB in a day, page someone. It costs almost nothing and has caught surprise bills for me more than once.

Wire Security Hub findings to an SNS topic with a Lambda that auto-suppresses known informational findings. Not auto-remediates, that is a separate conversation with real blast radius. Just suppress the noise so the team sees real findings. Without this, Security Hub becomes a dashboard nobody trusts after week two.

Review VPC Endpoint policies quarterly. The endpoint policy controls which principals in your account can use the endpoint. An overly permissive policy allowing "Principal": "*" means any Lambda in the account, not just your app, can use your private S3 endpoint. Scope it to the specific IAM roles that need it.

For splitting this monolithic stack, and eventually you will want to, the CDK stack splitting and deployment sequencing post covers the dependency ordering. Short version: KMS keys before buckets, buckets before CloudTrail, VPC before Lambdas, Lambdas before WAF associations. Get that wrong in a nested stack setup and you spend a day debugging CREATE_FAILED on a resource that depends on something that does not exist yet.


Closing Recommendation

Where I land after running this in a few production environments: the monolithic stack is fine to start. It gives you atomic deployments and no cross-stack ordering headaches. The moment more than one team touches it, or you run more than two environments, split it into at least a foundation stack (KMS, VPC, IAM roles) and an application stack.

The controls to get right before anything else: CMK retention set to RETAIN, VPC Endpoints for Secrets Manager and S3 in private subnets, and Security Hub suppression rules written before you enable all standards. In that order. Everything else you can iterate on. And enable a NAT Gateway spend alarm before you deploy, not after the bill.

Next step

Have the same problem on your stack?

Send the architecture, AWS bill concern, deploy pain, or GenAI reliability issue. I'll find the first real bottleneck and propose a small, reversible fix.

Related reading

#AWS CDK#IAM#KMS#WAFv2#Security Hub#VPC
Rahul Ladumor

Rahul Ladumor

Principal Cloud & AI Platform Architect. AWS Professional certified, 4x AWS Community Builder. I work with teams that have real users, real AWS bills, and real production pressure.

About Rahul →