With cdk-nag v3, the most popular checking tool for AWS CDK projects has changed fundamentally: the rules now run through the CDK’s built-in policy validation framework, and the familiar NagSuppressions class is gone. If you simply bump the version, the next build mostly shows you red compiler errors.
I like cdk-nag because it catches exactly the mistakes that tend to slip through code review: the queue without a dead-letter queue, the bucket that does not enforce TLS, the role with s3:*. In this article I show you how to set up the new version, document exceptions properly and lock it all in with a Vitest test. The example is a fictional bike rental with a queue, a Lambda function, a DynamoDB table and a bucket for receipts.
In short
cdk-nag v3 plugs into the AWS CDK policy validation via Validations.of(app).addPlugins(), and you set exceptions with Validations.of(construct).acknowledge() including a reason. For tests, validateScope() runs the rules directly against the stack, no synth needed. The biggest pitfall: in my test even plain warnings stopped the synth, and every wildcard finding needs its own acknowledgment.
What you will learn in this article
- What changes from cdk-nag v2 to v3 and why your old code no longer builds
- How to register rule packs and acknowledge findings with a reason
- How to check cdk-nag rules in Vitest with validateScope()
- How to write and test your own house rule as a NagPack
What changed in cdk-nag v3
A quick bit of context: cdk-nag is an open-source library from AWS’s cdklabs. It checks the resources of your CDK app against rule packs, for example AwsSolutions (general best practices), Serverless, HIPAA, NIST 800-53 or PCI DSS. Version 3.0.0 was released on June 12, 2026, and the current version is 3.0.2. The rewrite is big enough to deserve a closer look.
From aspect to validation plugin
Up to v2, every rule pack was an Aspect. Aspects visit every node in the construct tree during synth, and cdk-nag reported its findings as annotations, meaning warnings and errors attached to individual constructs. In v3, a rule pack is an IPolicyValidationPlugin instead. The CDK calls such plugins itself after the templates have been generated, collects the results and writes a shared report. Other checking tools use the same interface, for example the CloudFormation Guard validator.
Same job, new mechanics: v3 uses the AWS CDK policy validation instead of its own aspect.
NagSuppressions becomes acknowledge
The second big change concerns exceptions. Instead of NagSuppressions.addResourceSuppressions() you now write Validations.of(construct).acknowledge({ id, reason }). This is a CDK API, and it applies to the given construct and everything below it. You can pass several acknowledgments per call, each with its own reason.
One detail matters for rules with several findings, such as AwsSolutions-IAM5 for wildcards in IAM policies. According to the migration guide, an acknowledgment without a finding suffix no longer suppresses all hits of the rule in v3. You have to confirm each hit separately, for example AwsSolutions-IAM5[Resource::*]. I think that is a good decision, because this is exactly where most blanket exceptions came from in v2.
| Topic | cdk-nag v2 | cdk-nag v3 |
|---|---|---|
| Register | Aspects.of(app).add(new AwsSolutionsChecks()) | Validations.of(app).addPlugins(new AwsSolutionsChecks(app)) |
| Exception | NagSuppressions.addResourceSuppressions(...) | Validations.of(construct).acknowledge({ id, reason }) |
| Single finding | appliesTo: ['Action::s3:*'] | id: 'AwsSolutions-IAM5[Action::s3:*]' |
| Report | CSV/JSON files from cdk-nag | CDK report in the cloud assembly |
| Custom rules | visit(node) | checkResource(node) plus name |
| Minimum version | aws-cdk-lib 2.176.0 | aws-cdk-lib 2.257.0 |
Prerequisites
- Node.js 22 or newer (I tested with Node 22)
- Basic knowledge of the AWS CDK with TypeScript
- You do not need an AWS account for tests and synth. Only
cdk deployneeds credentials. - Tested versions: aws-cdk-lib 2.275.0, cdk-nag 3.0.2, aws-cdk (CLI) 2.1148.0, Vitest 5.1.0, TypeScript 7.0.2
If CDK tests are new to you, read my article Testing a CDK stack with Vitest first. It covers template assertions, this one covers rule checks. The two complement each other well.
Setting up cdk-nag v3 with Vitest step by step
Step 1: Create the project
Create an empty project and install the packages. I use tsx so the CDK CLI can run TypeScript directly.
mkdir cdk-nag-v3-vitest && cd cdk-nag-v3-vitest npm init -y npm install aws-cdk-lib constructs cdk-nag npm install --save-dev aws-cdk typescript tsx vitest @types/node echo '{ "app": "npx tsx bin/app.ts" }' > cdk.json Add three scripts to your package.json so you don’t have to memorize anything later:
{ "scripts": { "test": "vitest run", "typecheck": "tsc --noEmit", "synth": "cdk synth --quiet" } } Step 2: Register the rule packs in the app
In the app file you register the packs once for the whole app. New in v3: the pack takes the scope as its first argument. Besides AwsSolutions I also add the Serverless pack and a custom house rule that we build in step 7.
// bin/app.ts import { App, Validations } from 'aws-cdk-lib'; import { AwsSolutionsChecks, ServerlessChecks } from 'cdk-nag'; import { RentalChecks } from '../lib/rental-rules'; import { RentalStack } from '../lib/rental-stack'; const app = new App(); new RentalStack(app, 'BikeRental'); // Every check runs automatically on cdk synth and cdk deploy. Validations.of(app).addPlugins( new AwsSolutionsChecks(app), new ServerlessChecks(app), new RentalChecks(app), ); From now on the check runs with every cdk synth and cdk deploy. If a pack finds something, the synth stops and the CLI shows you where the problem was introduced. The output looks like this:
lib/rental-stack.ts:39:19 ERROR The SQS queue does not require requests to use SSL. (AwsSolutions) BikeRental/BookingQueue/Resource aws-cdk-lib.aws_sqs.CfnQueue Acknowledge with 'AwsSolutions::AwsSolutions-SQS4' Synthesis finished with errors I find it handy that the message points to the line in your own code and also gives you the ID for a possible acknowledgment. The CDK also stores the full report as a JSON file in the cdk.out folder.
🎬 How cdk-nag v3 checks during synth – this animated diagram only runs on my site: watch it on sascha-ms.de
Step 3: The example stack
The bike rental stack is deliberately small, but it contains the typical spots where cdk-nag fires. Bookings arrive through an SQS queue, a Lambda function writes them to DynamoDB and stores receipts as PDFs in S3. Read the code once in full; I explain the acknowledgments at the end in the next step.
// lib/rental-stack.ts import { Duration, RemovalPolicy, Stack, Validations, type StackProps } from 'aws-cdk-lib'; import * as dynamodb from 'aws-cdk-lib/aws-dynamodb'; import * as iam from 'aws-cdk-lib/aws-iam'; import * as lambda from 'aws-cdk-lib/aws-lambda'; import * as s3 from 'aws-cdk-lib/aws-s3'; import * as sqs from 'aws-cdk-lib/aws-sqs'; import { SqsEventSource } from 'aws-cdk-lib/aws-lambda-event-sources'; import { Construct } from 'constructs'; export class RentalStack extends Stack { constructor(scope: Construct, id: string, props?: StackProps) { super(scope, id, props); // Target bucket for the receipt bucket's access logs const accessLogs = new s3.Bucket(this, 'AccessLogBucket', { enforceSSL: true, blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL, }); Validations.of(accessLogs).acknowledge({ id: 'AwsSolutions-S1', reason: 'Target bucket for access logs; logging the logs adds no value.', }); const receipts = new s3.Bucket(this, 'ReceiptBucket', { enforceSSL: true, blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL, serverAccessLogsBucket: accessLogs, serverAccessLogsPrefix: 'receipts/', }); // Bookings flow through a queue with a dead-letter queue const dlq = new sqs.Queue(this, 'BookingDlq', { enforceSSL: true, retentionPeriod: Duration.days(14), }); Validations.of(dlq).acknowledge({ id: 'Serverless-SQSRedrivePolicy', reason: 'The DLQ is the final stop; an alarm on its depth replaces another redrive level.', }); const queue = new sqs.Queue(this, 'BookingQueue', { enforceSSL: true, visibilityTimeout: Duration.seconds(60), deadLetterQueue: { queue: dlq, maxReceiveCount: 3 }, }); const table = new dynamodb.Table(this, 'BookingTable', { partitionKey: { name: 'pk', type: dynamodb.AttributeType.STRING }, billingMode: dynamodb.BillingMode.PAY_PER_REQUEST, pointInTimeRecoverySpecification: { pointInTimeRecoveryEnabled: true }, removalPolicy: RemovalPolicy.RETAIN, }); const fn = new lambda.Function(this, 'BookingHandler', { runtime: lambda.Runtime.NODEJS_24_X, handler: 'index.handler', code: lambda.Code.fromInline('exports.handler = async () => {};'), timeout: Duration.seconds(10), memorySize: 512, tracing: lambda.Tracing.ACTIVE, }); fn.addEventSource(new SqsEventSource(queue)); table.grantWriteData(fn); // Only PutObject below receipts/ instead of grantPut with s3:Abort* and friends fn.addToRolePolicy(new iam.PolicyStatement({ actions: ['s3:PutObject'], resources: [receipts.arnForObjects('receipts/*')], })); // Acknowledgments apply to the function and everything below it (role, policy, mapping) Validations.of(fn).acknowledge( { id: 'AwsSolutions-IAM4[Policy::arn:<AWS::Partition>:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole]', reason: 'The AWS basic role only allows writing its own CloudWatch logs.', }, { id: `AwsSolutions-IAM5[Resource::<${this.getLogicalId(receipts.node.defaultChild as s3.CfnBucket)}.Arn>/receipts/*]`, reason: 'Receipts are stored per booking as receipts/<bookingId>.pdf; the prefix limits access.', }, { id: 'AwsSolutions-IAM5[Resource::*]', reason: 'X-Ray only: PutTraceSegments and PutTelemetryRecords do not support resource ARNs.', }, { id: 'Serverless-LambdaDLQ', reason: 'The function is only invoked synchronously by SQS; failed attempts end up in the queue DLQ.', }, { id: 'Serverless-LambdaEventSourceMappingDestination', reason: 'SQS mappings have no on-failure destination; the queue redrive policy covers this.', }, ); } } Some settings are direct answers to rules. enforceSSL satisfies AwsSolutions-S10 and AwsSolutions-SQS4, the dead-letter queue satisfies AwsSolutions-SQS3, point-in-time recovery satisfies AwsSolutions-DDB3. In addition, AwsSolutions-L1 asks for the latest runtime: with Node.js 22 my test reported an error, with Node.js 24 it did not. On top of that, the Serverless pack wanted more than the default memory size and active X-Ray tracing.
By the way, instead of bucket.grantPut(fn) I only grant s3:PutObject on the receipts/ prefix. grantPut includes actions like s3:Abort*, which cdk-nag reports as a wildcard. A tighter permission is the better fix here than an acknowledgment.
Step 4: Acknowledge findings with a reason
Not every finding is a mistake. The bucket for access logs does not need an access log itself, and X-Ray actions like xray:PutTraceSegments do not support resource ARNs, so Resource: * is unavoidable there. This is what acknowledge is for, with a reason that someone will still understand a year from now.
About the ID: the CLI suggests 'AwsSolutions::AwsSolutions-SQS4', while the cdk-nag README uses the short form 'AwsSolutions-S1'. Both worked in my test. I stick with the short form because it matches the rule IDs in the documentation.
It gets tricky with findings that contain generated names. The finding for the receipt bucket is called AwsSolutions-IAM5[Resource::<ReceiptBucket14EDEAB6.Arn>/receipts/*], and the part with the hash is the logical ID from the template. You should not hard-code this ID. With this.getLogicalId(...) the stack computes it itself, so the acknowledgment still fits after a rename.
Step 5: A test helper with validateScope
Now comes the part I like best in v3. Every rule pack has a validateScope(scope) method. According to the documentation it is the intended entry point for tests: it checks a construct tree directly and returns a report instead of stopping the synth. Acknowledgments set with acknowledge are taken into account. A small helper turns the report into a flat list that is easy to compare:
// test/helpers.ts import type { NagPack } from 'cdk-nag'; import type { IConstruct } from 'constructs'; export interface Finding { rule: string; severity: string; path: string; } /** Runs a rule pack and returns all violations as a flat list. */ export function findings(pack: NagPack, scope: IConstruct): Finding[] { const report = pack.validateScope(scope); return report.violations.flatMap((v) => v.violatingResources.map((r) => ({ rule: v.ruleName, severity: v.severity ?? 'error', path: r.constructPath ?? r.resourceLogicalId ?? '?', })), ); } Step 6: Write the tests
The tests check three things. First: the stack has no open findings in the three packs. Second: every acknowledgment has a reason with a minimum length, so nobody writes 'ok'. Third: the rules really fire when something is missing. I consider this negative test especially important. Without it you won’t notice when a misconfigured check simply stops reporting anything.
// test/rental-nag.test.ts import { App, Duration, Stack, Validations } from 'aws-cdk-lib'; import * as lambda from 'aws-cdk-lib/aws-lambda'; import * as sqs from 'aws-cdk-lib/aws-sqs'; import { AwsSolutionsChecks, ServerlessChecks } from 'cdk-nag'; import { describe, expect, test } from 'vitest'; import { RentalChecks } from '../lib/rental-rules'; import { RentalStack } from '../lib/rental-stack'; import { findings } from './helpers'; function buildStack(): Stack { return new RentalStack(new App(), 'BikeRental'); } describe('cdk-nag rules for the bike rental', () => { test('AwsSolutions reports no open violations', () => { expect(findings(new AwsSolutionsChecks(), buildStack())).toEqual([]); }); test('Serverless pack reports neither errors nor warnings', () => { expect(findings(new ServerlessChecks(), buildStack())).toEqual([]); }); test('custom house rules are satisfied', () => { expect(findings(new RentalChecks(), buildStack())).toEqual([]); }); test('every acknowledgment has a real reason', () => { const stack = buildStack(); const reasons: Array<[string, string]> = []; for (const construct of stack.node.findAll()) { for (const entry of construct.node.metadata) { if (entry.type !== Validations.ACKNOWLEDGED_RULES_METADATA_KEY) continue; for (const [rule, reason] of Object.entries(entry.data as Record<string, string>)) { if (rule.includes('AwsSolutions') || rule.includes('RentalChecks')) { reasons.push([rule, reason]); } } } } expect(reasons.length).toBeGreaterThan(0); for (const [, reason] of reasons) { expect(reason.length).toBeGreaterThanOrEqual(30); } }); }); describe('the rules really fire', () => { test('queue without dead-letter queue is caught by SQS3', () => { const stack = new Stack(new App(), 'Broken'); new sqs.Queue(stack, 'Loose', { enforceSSL: true }); const rules = findings(new AwsSolutionsChecks(), stack).map((f) => f.rule); expect(rules).toContain('AwsSolutions-SQS3'); }); test('timeout above 30 seconds violates the house rule', () => { const stack = new Stack(new App(), 'Slow'); new lambda.Function(stack, 'SlowHandler', { runtime: lambda.Runtime.NODEJS_24_X, handler: 'index.handler', code: lambda.Code.fromInline('exports.handler = async () => {};'), timeout: Duration.seconds(60), }); expect(findings(new RentalChecks(), stack)).toEqual([ expect.objectContaining({ rule: 'RentalChecks-L1', path: 'Slow/SlowHandler/Resource' }), ]); }); }); test('synth with all plugins succeeds', () => { const app = new App(); new RentalStack(app, 'BikeRental'); Validations.of(app).addPlugins( new AwsSolutionsChecks(app), new ServerlessChecks(app), new RentalChecks(app), ); expect(() => app.synth())...

