<< BACK

KMS Least Privilege in CDK

scoping decrypt permissions from Resource "*" to specific key ARNs — how to map roles to KMS keys, apply the CDK fixes, and handle the cases where broad access is legitimately required.

DATE:
MAR.19.2026
READ:
12 MIN

Security Hub’s KMS.2 finding is blunt: any IAM role with an inline policy granting kms:Decrypt on Resource: "*" gets flagged. The reasoning is equally blunt — if a role only needs to decrypt secrets encrypted with one specific KMS key, granting it permission to decrypt anything in your account violates least privilege. An attacker who compromises that role gains decryption access to data they were never meant to see.

In our AWS account, Security Hub flagged 8 IAM roles with this pattern — all defined in CDK stacks. This post covers how we mapped each role to the specific keys it actually needed, applied the CDK fixes, and handled the cases where broad KMS access is legitimately required.


Why Resource: "*" happens so easily

The natural CDK code for reading a secret looks like this:

++
role.add_to_policy(iam.PolicyStatement(
    actions=["kms:Decrypt"],
    resources=["*"],  # "I'll fix this later"
))
++

CDK L2 constructs make it worse. When you call secret.grant_read(role), the construct correctly scopes secretsmanager:GetSecretValue to that specific secret ARN — but the kms:Decrypt permission often ends up as Resource: "*" because the construct doesn’t know at synthesis time which KMS key encrypts the secret. The CDK team has improved this for secrets with explicit CMKs, but for secrets using the default aws/secretsmanager key, the wildcard persists as of CDK v2.140.

The problem compounds in multi-stack architectures. A Lambda in Stack A reads a secret defined in Stack B, encrypted by a CMK in Stack C. Without careful cross-stack references, developers take the shortcut.


Step 1: map roles to their actual KMS keys

Before changing any policies, answer the concrete question: which KMS keys does this role actually need to decrypt?

Finding the key for a Secrets Manager secret

++
aws secretsmanager describe-secret 
    --secret-id my-secret-name 
    --query 'KmsKeyId'

# If null, the secret uses the default aws/secretsmanager key:
aws kms describe-key 
    --key-id alias/aws/secretsmanager 
    --query 'KeyMetadata.KeyId'
++

Finding the key for SSM SecureString parameters

++
aws ssm describe-parameters 
    --parameter-filters "Key=Name,Values=/my/parameter" 
    --query 'Parameters[0].KeyId'

# If it returns "alias/aws/ssm", resolve to the actual key ID:
aws kms describe-key 
    --key-id alias/aws/ssm 
    --query 'KeyMetadata.{KeyId:KeyId,Arn:Arn}'
++

Finding the key for CloudWatch Log Groups

++
aws logs describe-log-groups 
    --log-group-name-prefix /ecs/my-service 
    --query 'logGroups[0].kmsKeyId'
++

Using CloudTrail to discover usage empirically

When you’re unsure which keys a role uses, CloudTrail’s Decrypt events give you empirical data:

++
aws cloudtrail lookup-events 
    --lookup-attributes AttributeKey=EventName,AttributeValue=Decrypt 
    --start-time 2026-03-01 
    --end-time 2026-04-01 
    --query 'Events[].{Time:EventTime,User:Username,Key:Resources[?Type==`AWS::KMS::Key`].ARN | [0]}'
++

This “observe then restrict” pattern works especially well in brownfield environments where the original key assignments are undocumented.


Step 2: apply the CDK fixes

After auditing every flagged role, we had this mapping:

+--------------------+--------------------+--------------------+
| Stack              | Role               | Keys Needed        |
+--------------------+--------------------+--------------------+
| SecretsRotationSta | SecretsRotation    | Production CMK +   |
| ck                 | Lambda             | Development CMK    |
+--------------------+--------------------+--------------------+
| LogGroupManagerSta | LogGroupManager    | CloudWatch Logs    |
| ck                 | Lambda             | CMK                |
+--------------------+--------------------+--------------------+
| LivsytSupersetStac | Superset Task Role | aws/ssm managed    |
| k                  |                    | key                |
+--------------------+--------------------+--------------------+

SecretsRotationStack — two specific CMKs

The rotation Lambda accesses secrets across production and development environments. Both keys must be listed:

++
# BEFORE
rotation_lambda_role.add_to_policy(iam.PolicyStatement(
    actions=["kms:Decrypt", "kms:GenerateDataKey", "kms:DescribeKey"],
    resources=["*"],
))

# AFTER
PRODUCTION_KMS_KEY_ARN = (
    "arn:aws:kms:us-east-1:ACCOUNT_ID:key/141a7669-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
)
DEVELOPMENT_KMS_KEY_ARN = (
    "arn:aws:kms:us-east-1:ACCOUNT_ID:key/6070c2cd-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
)

rotation_lambda_role.add_to_policy(iam.PolicyStatement(
    actions=["kms:Decrypt", "kms:GenerateDataKey", "kms:DescribeKey"],
    resources=[
        PRODUCTION_KMS_KEY_ARN,
        DEVELOPMENT_KMS_KEY_ARN,
    ],
))
++

The key ARNs are defined as constants at the top of the stack file. KMS key IDs don’t change, and hardcoding them makes the policy immediately auditable without tracing cross-stack references.

LogGroupManagerStack — one key

The LogGroupManager Lambda creates and configures CloudWatch Log Groups. CloudWatch Logs requires kms:Decrypt (not just encrypt) for the principal associating the key with a log group:

++
# AFTER
CLOUDWATCH_LOGS_KMS_KEY_ARN = (
    "arn:aws:kms:us-east-1:ACCOUNT_ID:key/088cfa2f-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
)

log_manager_role.add_to_policy(iam.PolicyStatement(
    actions=["kms:Decrypt", "kms:Encrypt", "kms:GenerateDataKey*", "kms:DescribeKey", "kms:CreateGrant"],
    resources=[CLOUDWATCH_LOGS_KMS_KEY_ARN],
))
++

LivsytSupersetStack — AWS-managed SSM key

Superset reads database credentials from SSM SecureString parameters encrypted with the default aws/ssm managed key:

++
# AFTER
AWS_SSM_MANAGED_KEY_ARN = (
    "arn:aws:kms:us-east-1:ACCOUNT_ID:key/71b8a221-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
)

superset_task_role.add_to_policy(iam.PolicyStatement(
    actions=["kms:Decrypt"],
    resources=[AWS_SSM_MANAGED_KEY_ARN],
))
++

Prefer CDK key grants over manual policy statements

Where you control the KMS key object in CDK, use the built-in grant method instead of a raw policy statement:

++
# Cleaner — automatically scopes to the correct ARN
key = kms.Key.from_key_arn(self, "SecretKey", key_arn)
key.grant_decrypt(rotation_lambda_role)
++

Roles we couldn’t fix — and why suppression is correct

CDK bootstrap roles

The CDK bootstrap process creates roles like cdk-hnb659fds-cfn-exec-role-ACCOUNT_ID-us-east-1. These roles need broad KMS permissions by design — they deploy any stack that references any KMS key.

These roles are protected by the CDK deployment pipeline itself and are not assumable by application code. Re-bootstrapping with a customized template is possible but introduces maintenance burden that outweighs the security benefit.

Resolution: Suppress the Security Hub finding for these specific roles with a documented justification.

CI/CD federation roles

Our deployment pipeline uses an OIDC federation role that assumes permissions to run cdk deploy. This role has kms:Decrypt on * because deployments may reference any KMS key in the account.

The role is constrained by its OIDC trust policy to only be assumable from our specific CI/CD workspace. The broad KMS permission is a known and accepted trade-off for deployment roles.

Resolution: Suppress with documentation.


Verification

After deploying the scoped policies, verify in three ways:

1. Functional testing — trigger secret rotation manually, run the log group manager, confirm Superset can start and read its SSM parameters.

2. Security Hub re-evaluation — wait for the next evaluation cycle or trigger it manually. The KMS.2 findings for the fixed roles resolve to PASSED.

3. IAM Access Analyzer policy validation:

++
aws accessanalyzer validate-policy 
    --policy-type IDENTITY_POLICY 
    --policy-document file://scoped-kms-policy.json
++

The broader picture

The 2024 Datadog State of Cloud Security report found that 18% of AWS accounts had at least one IAM role with unrestricted KMS decrypt permissions. The industry consensus is that achieving least privilege is an incremental journey:

  1. Identify overly broad policies via Security Hub or Config Rules
  2. Map each role to its actual resource dependencies using CloudTrail
  3. Scope policies to specific ARNs where dependencies are known and stable
  4. Suppress findings for deployment roles where broad access is architecturally justified
  5. Monitor for drift with periodic Security Hub checks

One underappreciated aspect: true parameter-level isolation requires encrypting different secrets with different CMKs. Our Superset stack used the aws/ssm managed key, which is shared across all SSM SecureString parameters in the account. Scoping to that key still grants access to decrypt any SSM parameter in the account using the default key. For high-security workloads, separate CMKs per sensitivity tier is worth the additional key management overhead (~$1/month per CMK).