Photo by Michael Chacon on Unsplash
CloudWatch log group KMS encryption is off when DescribeLogGroups returns no kmsKeyId for a log group. The data is still encrypted at rest, with the CloudWatch Logs default AES-GCM encryption, but you don’t control the key. To use your own key, give the logs.<region>.amazonaws.com service principal access in the key policy, then call AssociateKmsKey. Only data ingested after that uses the key.
An auditor’s “unencrypted log groups” finding usually means “no customer managed key”. CloudWatch Logs always encrypts log data at rest. A KMS key adds control on top: the key policy decides who can decrypt, and disabling the key cuts off access to the data it protected.
This example is for engineers who need to show which log groups carry sensitive data and prove they’re protected by a key they own. The script lists every log group in a Region with its KMS key, size and retention, puts the ones with sensitive-looking names first, and with --apply associates a key with the groups you name. It checks the key before touching anything, because a wrong key policy is the most common way this change breaks logging.
What does KMS encryption change for a CloudWatch log group?
The table sums up what the CloudWatch Logs guide to encrypting log data with AWS KMS says about each state.
| Situation | What happens |
|---|---|
| No key associated | Data is encrypted with 256-bit AES-GCM by CloudWatch Logs. No key policy to manage, and no key-level access control or audit trail. |
| Key associated | All newly ingested data is encrypted with your key and stays that way for its whole retention period. Events stored before the association aren’t re-encrypted. |
| Key disassociated later | New data goes back to the default method. Data already encrypted with the key stays encrypted with it and is still readable while the key is enabled. |
| Key disabled, deleted or access revoked | CloudWatch Logs can no longer read the data encrypted with that key. |
Four more rules shape the script. CloudWatch Logs supports only symmetric KMS keys. The association can take up to 5 minutes to take effect. You can’t associate a key with an existing log group in the CloudWatch console, only through the API or CLI. And a key on a log group doesn’t encrypt Logs Insights query results, which need their own AssociateKmsKey call with a query-result resource.
Which log groups should get a key first?
Not every log group needs one. Start with groups that can hold personal data or secrets: Lambda function logs that print request bodies, API Gateway execution logs, database audit and slow query logs, and anything named after payments or authentication. The OWASP Logging Cheat Sheet warns that logs often end up containing users’ personal data and technical secrets such as passwords, which is exactly the data a key policy should gate. The script’s --sensitive pattern marks those names for review; tune it to your naming. DNS query logs belong on that list too, since they show every domain a workload contacted; the script to check Route 53 Resolver query logging for every VPC shows which VPCs send them to a log group.
A key doesn’t stop secrets reaching logs in the first place. If a Lambda function logs its environment, the example to find secrets in Lambda environment variables fixes the source.
What does the script do?
- Lists log groups
paginateDescribeLogGroupsreads every log group in the Region, optionally under a--prefix. - Flags missing keysA group without
kmsKeyIdgetsno CMK, orNO CMK: review firstwhen its name matches the sensitive pattern. Groups with no retention period are flagged too. - Checks the keyWith
--apply,DescribeKeyconfirms the key isSYMMETRIC_DEFAULT,Enabledand in the same Region. - Associates the key
AssociateKmsKeyruns for each log group in--groups. Nothing else is changed.
Prerequisites
- Node.js 18 or later with
tsx, plus@aws-sdk/client-cloudwatch-logsand@aws-sdk/client-kms. - For
--apply, a symmetric customer managed key in the same Region whose key policy already allows CloudWatch Logs (next section). If you’re weighing the monthly cost of extra keys, the example to find unused KMS keys and what they cost shows where spare keys hide. - Automatic rotation turned on for that key. The example to find KMS keys without automatic rotation reports which of your keys still have it off.
Which key policy and IAM permissions does it need?
Two policies are involved. The key policy must let the CloudWatch Logs service principal for the key’s Region use the key. AWS recommends scoping it with the kms:EncryptionContext:aws:logs:arn condition, because CloudWatch Logs puts the log group ARN in the encryption context. This statement limits the key to log groups in one account and Region:
{
"Sid": "AllowCloudWatchLogsForThisAccount",
"Effect": "Allow",
"Principal": { "Service": "logs.us-east-1.amazonaws.com" },
"Action": ["kms:Encrypt", "kms:Decrypt", "kms:ReEncrypt*", "kms:GenerateDataKey*", "kms:Describe*"],
"Resource": "*",
"Condition": {
"ArnLike": { "kms:EncryptionContext:aws:logs:arn": "arn:aws:logs:us-east-1:111122223333:log-group:*" }
}
}
For one key per log group, which AWS recommends, use ArnEquals with the exact log group ARN instead. The identity running the script needs the policy below. kms:DescribeKey is required twice over: the script calls it, and AWS documents that the caller of AssociateKmsKey must have it or the call fails with AccessDeniedException.
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "ListLogGroups",
"Effect": "Allow",
"Action": "logs:DescribeLogGroups",
"Resource": "*"
},
{
"Sid": "AssociateOnlyWithApply",
"Effect": "Allow",
"Action": "logs:AssociateKmsKey",
"Resource": "arn:aws:logs:us-east-1:111122223333:log-group:*"
},
{
"Sid": "CheckTheKey",
"Effect": "Allow",
"Action": "kms:DescribeKey",
"Resource": "arn:aws:kms:us-east-1:111122223333:key/1234abcd-12ab-34cd-56ef-1234567890ab"
}
]
}
Drop the second and third statements for a report-only role. To derive policies like this from your own SDK code, the IAM policy generator for TypeScript code drafts one, and the guide to reviewing an IAM policy for least privilege covers tightening it.
The script to check CloudWatch log group KMS encryption
// find-unencrypted-cloudwatch-log-groups.ts
// Lists CloudWatch Logs log groups in a Region and shows which ones have no customer managed KMS key
// (kmsKeyId absent). Their data is still encrypted at rest with the CloudWatch Logs default method.
// Groups whose names match a "sensitive" pattern are marked for review first.
// --apply associates a KMS key with the log groups you name. Only data ingested after that is encrypted with it.
// Usage:
// npx tsx find-unencrypted-cloudwatch-log-groups.ts [--region us-east-1] [--prefix /aws/lambda/] [--sensitive "payments|auth"]
// npx tsx find-unencrypted-cloudwatch-log-groups.ts --region us-east-1 --apply \
// --key-arn arn:aws:kms:us-east-1:111122223333:key/1234abcd-12ab-34cd-56ef-1234567890ab --groups /aws/lambda/checkout
import {
CloudWatchLogsClient,
AssociateKmsKeyCommand,
paginateDescribeLogGroups,
} from "@aws-sdk/client-cloudwatch-logs";
import { KMSClient, DescribeKeyCommand } from "@aws-sdk/client-kms";
const args = process.argv.slice(2);
const flag = (name: string): string | undefined => {
const i = args.indexOf(name);
return i >= 0 ? args[i + 1] : undefined;
};
const region = flag("--region") ?? process.env.AWS_REGION ?? "us-east-1";
const prefix = flag("--prefix");
const apply = args.includes("--apply");
const keyArn = flag("--key-arn");
const groups = (flag("--groups") ?? "").split(",").map((g) => g.trim()).filter(Boolean);
// Log groups that often carry request payloads, personal data or credentials. Adjust to your naming.
const sensitive = new RegExp(
flag("--sensitive") ?? "API-Gateway-Execution-Logs|/aws/lambda/|/aws/rds/|/aws/codebuild/|/aws/states/|auth|payment|audit",
"i",
);
const logs = new CloudWatchLogsClient({ region });
const kms = new KMSClient({ region });
interface Row {
LogGroup: string;
Class: string;
StoredMB: number;
RetentionDays: string;
KmsKey: string;
Finding: string;
}
const errText = (err: unknown): string => (err instanceof Error ? `${err.name}: ${err.message}` : String(err));
// CloudWatch Logs supports only symmetric KMS keys, and the key must be enabled and in the same Region.
async function checkKey(arn: string): Promise<void> {
const out = await kms.send(new DescribeKeyCommand({ KeyId: arn }));
const meta = out.KeyMetadata;
if (!meta) throw new Error(`DescribeKey returned no metadata for ${arn}`);
if (meta.KeySpec !== "SYMMETRIC_DEFAULT") throw new Error(`${arn} is ${meta.KeySpec}; CloudWatch Logs needs a symmetric key`);
if (meta.KeyState !== "Enabled") throw new Error(`${arn} is ${meta.KeyState}, not Enabled`);
if (meta.Arn && meta.Arn.split(":")[3] !== region) throw new Error(`${arn} is not in ${region}`);
}
async function main(): Promise<void> {
const rows: Row[] = [];
const paginator = paginateDescribeLogGroups({ client: logs }, prefix ? { logGroupNamePrefix: prefix } : {});
for await (const page of paginator) {
for (const g of page.logGroups ?? []) {
if (!g.logGroupName) continue;
const findings: string[] = [];
if (!g.kmsKeyId) {
findings.push(sensitive.test(g.logGroupName) ? "NO CMK: review first (sensitive name)" : "no CMK");
}
if (g.retentionInDays === undefined) findings.push("never expires");
rows.push({
LogGroup: g.logGroupName,
Class: g.logGroupClass ?? "STANDARD",
StoredMB: Math.round(((g.storedBytes ?? 0) / 1024 / 1024) * 10) / 10,
RetentionDays: g.retentionInDays === undefined ? "never" : String(g.retentionInDays),
KmsKey: g.kmsKeyId ? (g.kmsKeyId.split("/").pop() ?? g.kmsKeyId) : "-",
Finding: findings.join("; ") || "ok",
});
}
}
rows.sort((a, b) => Number(b.Finding.startsWith("NO CMK")) - Number(a.Finding.startsWith("NO CMK")) || b.StoredMB - a.StoredMB);
console.table(rows);
const noKey = rows.filter((r) => r.KmsKey === "-");
const review = noKey.filter((r) => r.Finding.startsWith("NO CMK"));
console.log(
`${rows.length} log groups in ${region}: ${noKey.length} without a customer managed KMS key ` +
`(${review.length} with a sensitive-looking name). All of them are still encrypted at rest by CloudWatch Logs.`,
);
if (!apply) {
console.log("Report only: nothing was changed. Use --apply --key-arn <arn> --groups <a,b> to associate a key.");
return;
}
if (!keyArn || !groups.length) {
console.error("--apply needs --key-arn and --groups");
process.exit(1);
}
await checkKey(keyArn);
const known = new Set(rows.map((r) => r.LogGroup));
for (const name of groups) {
if (!known.has(name)) {
console.error(`Skipping ${name}: not found in ${region}${prefix ? ` under prefix ${prefix}` : ""}`);
continue;
}
try {
await logs.send(new AssociateKmsKeyCommand({ logGroupName: name, kmsKeyId: keyArn }));
console.log(`Associated ${name} with the key. It can take up to 5 minutes before new log events use it.`);
} catch (err) {
console.error(`Could not associate ${name}: ${errText(err)}`);
process.exitCode = 1;
}
}
}
main().catch((err) => {
console.error(errText(err));
process.exit(1);
});
How do you run it?
npm install @aws-sdk/client-cloudwatch-logs @aws-sdk/client-kms
npm install --save-dev tsx typescript @types/node
# Report only
AWS_PROFILE=readonly npx tsx find-unencrypted-cloudwatch-log-groups.ts --region us-east-1
# Associate a key with one log group after updating the key policy
AWS_PROFILE=platform-admin npx tsx find-unencrypted-cloudwatch-log-groups.ts --region us-east-1 --apply \
--key-arn arn:aws:kms:us-east-1:111122223333:key/1234abcd-12ab-34cd-56ef-1234567890ab \
--groups /aws/lambda/checkout
Sample output
┌─────────┬──────────────────────────────────────────────┬────────────┬──────────┬───────────────┬────────────────────────────────────────┬─────────────────────────────────────────┐
│ (index) │ LogGroup │ Class │ StoredMB │ RetentionDays │ KmsKey │ Finding │
├─────────┼──────────────────────────────────────────────┼────────────┼──────────┼───────────────┼────────────────────────────────────────┼─────────────────────────────────────────┤
│ 0 │ '/aws/lambda/checkout' │ 'STANDARD' │ 4005.4 │ '30' │ '-' │ 'NO CMK: review first (sensitive name)' │
│ 1 │ 'API-Gateway-Execution-Logs_a1b2c3d4e5/prod' │ 'STANDARD' │ 209.8 │ '14' │ '-' │ 'NO CMK: review first (sensitive name)' │
│ 2 │ '/aws/rds/instance/orders/audit' │ 'STANDARD' │ 1239.8 │ '365' │ '1234abcd-12ab-34cd-56ef-1234567890ab' │ 'ok' │
│ 3 │ '/ecs/web' │ 'STANDARD' │ 867.8 │ 'never' │ '-' │ 'no CMK; never expires' │
└─────────┴──────────────────────────────────────────────┴────────────┴──────────┴───────────────┴────────────────────────────────────────┴─────────────────────────────────────────┘
4 log groups in us-east-1: 3 without a customer managed KMS key (2 with a sensitive-looking name). All of them are still encrypted at rest by CloudWatch Logs.
Report only: nothing was changed. Use --apply --key-arn <arn> --groups <a,b> to associate a key.
Names are illustrative. /aws/lambda/checkout is the one to fix first: 4 GB of checkout logs with a sensitive-looking name and no key of your own. The API Gateway execution log group is next. /ecs/web has a different problem, since it never expires; the script to set CloudWatch Logs retention for all log groups fixes that, and the one to delete empty CloudWatch log groups clears groups nobody writes to before you spend a key on them.
Remember that the 4 GB already stored in /aws/lambda/checkout stays under the default encryption after you associate a key. If the old data must be under your key too, you’d have to export it and store it elsewhere under your key; otherwise it ages out with the retention period.
Troubleshooting
InvalidParameterExceptionfromAssociateKmsKey. The API reference says this is what you get when the key doesn’t exist or is disabled. Check the key ARN, its Region and its state; the script’sDescribeKeycheck catches most of these first.AccessDeniedExceptionfromAssociateKmsKey. The caller lackskms:DescribeKeyon the key, through IAM or the key policy. The guide to troubleshooting AWS IAM access denied errors walks through finding which policy is missing it.- Applications stop writing logs, or queries fail, after the change. AWS documents that principals calling
PutLogEvents,GetLogEvents,FilterLogEventsorStartQueryon a log group with a customer managed key need KMS permissions too, typically granted with akms:ViaServicecondition forlogs.<region>.amazonaws.com. Grant those before you associate the key. - The service principal is in another Region. The principal in the key policy must be in the same Region as the key, and the key must be in the log group’s Region.
- New events still don’t show the key. Allow up to 5 minutes, then run the report again.
Ask ChatWithCloud instead
For a quick answer without installing anything, ask ChatWithCloud “Which CloudWatch log groups in us-east-1 have no KMS key, and how much data do they store?” It writes AWS SDK for JavaScript v2 code, runs it on your machine with your AWS profile and summarizes the JSON it gets back. Use a read-only profile: changes such as AssociateKmsKey would run without a confirmation step, which the ChatWithCloud security model explains along with what leaves your machine. The guide to analyzing AWS security posture with an AI CLI shows more questions of this kind.
Frequently asked questions
Are CloudWatch logs encrypted by default?
Yes. Log group data is always encrypted at rest, with 256-bit AES-GCM managed by CloudWatch Logs. A KMS key is optional and gives you control over who can decrypt the data.
Does associating a KMS key encrypt existing log data?
No. Only log events ingested after the association are encrypted with the key. Earlier events stay encrypted with the default method.
Can I use an asymmetric or multi-purpose key for CloudWatch Logs?
CloudWatch Logs supports only symmetric KMS keys. Use a key with the SYMMETRIC_DEFAULT key spec in the same Region as the log group.
What happens to my logs if I delete the KMS key?
Log events encrypted with that key can no longer be read. Disassociate the key and wait until the encrypted data has aged out of retention before you schedule the key for deletion.
Related guides
Ask your AWS account in plain English
Your first 15 runs are free, with no OpenAI key needed.
npx chatwithcloud
