Find KMS Keys With a Wildcard Key Policy Principal or Open Grants

Rows of brass keys hanging on hooks on a wooden board

Photo by Kathyryn Tripp on Unsplash

A KMS key policy principal wildcard ("Principal": "*" or {"AWS": "*"}) in an Allow statement gives every identity in every AWS account the listed permissions on the key, unless a condition narrows it. To find them, call ListKeys, keep customer managed keys from DescribeKey, read each policy with GetKeyPolicy and check grants with ListGrants.

This example is for security and platform engineers who need to prove that no KMS key in an account is usable by strangers. KMS works differently from most AWS services: every key has a key policy, and IAM policies only work when that key policy allows them. So a key policy statement with a wildcard principal and no condition makes the key usable from any AWS account that grants itself permission.

The TypeScript script for the AWS SDK for JavaScript v3 checks every customer managed key in a Region for a KMS key policy principal wildcard, for statements that name other accounts, and for grants whose grantee is in another account. It’s report only. Fixing a key policy is a careful, manual edit, and the script prints enough to make it.

What does a KMS key policy principal wildcard allow?

The AWS KMS guide to creating a key policy is direct about it: an asterisk such as "AWS": "*" represents all AWS identities in all accounts, and you shouldn’t set the principal to * in any statement that allows permissions unless you use conditions to limit it. Users in other accounts can use your key whenever they have matching permissions in their own account.

Conditions are what make a wildcard acceptable. Common ones are aws:PrincipalOrgID (only principals in your organization), kms:CallerAccount (only a named account) and kms:ViaService (only requests that come through a named AWS service). The script marks a wildcard with conditions as REVIEW and one without conditions as HIGH. It prints the condition keys so you can judge them; a condition on aws:SourceIp alone, for example, is much weaker than one on your organization ID.

It also flags Allow statements that use NotPrincipal, which allow everyone except the listed principals.

Is the account root principal a wildcard?

No. The default key policy has one statement with the principal arn:aws:iam::111122223333:root and action kms:*. According to the KMS default key policy documentation, that account principal doesn’t give any IAM principal permission by itself. It lets the account use IAM policies to delegate access, and it keeps the key manageable if every other admin is deleted. The script ignores your own account in principals and only reports other accounts.

Other accounts in a key policy aren’t wrong by default. Cross-account use needs permission in the key policy and in an IAM policy in the other account, so a statement naming a partner account is only half of the access. Still, each one should be on a list someone can explain. The same review for IAM roles is covered by the script to find IAM roles trusted by external accounts.

What are KMS grants and why check them?

A grant is a second way to give access to a KMS key, outside the key policy. Each grant names a GranteePrincipal, the Operations it allows (such as Decrypt) and optionally a RetiringPrincipal that can retire it. AWS services create grants all the time: when you attach an encrypted EBS volume, for example, EC2 uses a grant to use the key on your behalf. That’s why most keys used by AWS services have grants, and why the script only reports grants whose grantee is an IAM principal in another account.

Grants don’t show up in the key policy, so a review that reads only policies misses them. Also keep in mind that key administrators with kms:CreateGrant or kms:PutKeyPolicy can give themselves and others permissions that aren’t in the policy you reviewed. The KMS docs warn about exactly that.

What does the script do?

  1. Identifies your accountGetCallerIdentity returns the account ID, so principals in your own account aren’t reported.
  2. Maps aliasespaginateListAliases so rows show alias/shared-backups instead of only a key ID.
  3. Keeps customer managed keyspaginateListKeys then DescribeKey; keys with KeyManager: AWS are skipped because you can’t edit their policies.
  4. Reads the key policyGetKeyPolicy with PolicyName: "default", the only valid name. Each Allow statement is checked for *, NotPrincipal and other-account principals.
  5. Lists grantspaginateListGrants per key, reporting grantees in other accounts with their operations and retiring principal. Use --skip-grants for a faster policy-only pass.

Every list call goes through the SDK’s paginators, explained in the guide to paginate AWS SDK v3 results with async iterators.

Prerequisites

  • Node.js 18 or later, npm, tsx, @aws-sdk/client-kms and @aws-sdk/client-sts.
  • A read-only role with the permissions below. Key policies also have to allow the role: a key whose policy doesn’t include the account principal statement ignores IAM policies, and the script reports it as could not read key.
  • One run per Region. KMS keys are Regional, including each replica of a multi-Region key. To audit several accounts, run it under a role in each; the guide to assume an IAM role in another account with AWS SDK v3 shows how.

Which IAM permissions does it need?

kms-key-policy-audit-policy.json

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "ListKeysAndAliases",
      "Effect": "Allow",
      "Action": [
        "kms:ListKeys",
        "kms:ListAliases"
      ],
      "Resource": "*"
    },
    {
      "Sid": "ReadKeyPoliciesAndGrants",
      "Effect": "Allow",
      "Action": [
        "kms:DescribeKey",
        "kms:GetKeyPolicy",
        "kms:ListGrants"
      ],
      "Resource": "arn:aws:kms:*:111122223333:key/*"
    }
  ]
}

GetCallerIdentity needs no permission. Before you give any role a broader KMS policy, the guide to review an IAM policy for least privilege is a useful checklist.

The script to find a KMS key policy principal wildcard

find-kms-keys-with-open-key-policies.ts

// find-kms-keys-with-open-key-policies.ts
// Checks every customer managed KMS key in a Region for key policy statements that allow a wildcard
// principal ("*" or {"AWS": "*"}), statements that allow other AWS accounts, and grants whose grantee
// is in another account. Report only: it never changes a key policy or revokes a grant.
// Usage:
//   npx tsx find-kms-keys-with-open-key-policies.ts [--region eu-west-1] [--skip-grants]
import {
  DescribeKeyCommand,
  GetKeyPolicyCommand,
  KMSClient,
  paginateListAliases,
  paginateListGrants,
  paginateListKeys,
} from "@aws-sdk/client-kms";
import { GetCallerIdentityCommand, STSClient } from "@aws-sdk/client-sts";

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 skipGrants = args.includes("--skip-grants");
const kms = new KMSClient({ region });

interface Statement {
  Sid?: string;
  Effect?: string;
  Principal?: unknown;
  NotPrincipal?: unknown;
  Action?: string | string[];
  Condition?: Record<string, Record<string, unknown>>;
}

interface Finding {
  Key: string;
  Alias: string;
  Severity: "HIGH" | "REVIEW";
  Finding: string;
  Principal: string;
  Detail: string;
}

const list = (v: unknown): string[] => (v === undefined ? [] : Array.isArray(v) ? v.map(String) : [String(v)]);

// Returns the AWS principals of a statement; "*" stands for everyone.
function awsPrincipals(p: unknown): string[] {
  if (p === "*") return ["*"];
  if (p && typeof p === "object" && "AWS" in p) return list((p as { AWS: unknown }).AWS);
  return []; // Service or Federated principals are left out.
}

// 12-digit account ID from "111122223333" or "arn:aws:iam::111122223333:role/x"; undefined for "*".
function accountOf(principal: string): string | undefined {
  if (/^\d{12}$/.test(principal)) return principal;
  return /^arn:aws[\w-]*:(?:iam|sts)::(\d{12}):/.exec(principal)?.[1];
}

const conditionKeys = (s: Statement): string =>
  Object.values(s.Condition ?? {})
    .flatMap((c) => Object.keys(c))
    .join(", ");

async function checkKey(keyId: string, base: { Key: string; Alias: string }, me: string | undefined): Promise<Finding[]> {
  const out: Finding[] = [];
  const { Policy } = await kms.send(new GetKeyPolicyCommand({ KeyId: keyId, PolicyName: "default" }));
  const doc = JSON.parse(Policy ?? "{}") as { Statement?: Statement | Statement[] };
  const statements = Array.isArray(doc.Statement) ? doc.Statement : doc.Statement ? [doc.Statement] : [];
  for (const s of statements) {
    if (s.Effect !== "Allow") continue;
    const sid = s.Sid ?? "(no Sid)";
    const actions = list(s.Action).join(", ");
    if (s.NotPrincipal !== undefined) {
      out.push({ ...base, Severity: "HIGH", Finding: "Allow with NotPrincipal", Principal: "everyone except listed", Detail: `${sid}: ${actions}` });
      continue;
    }
    for (const p of awsPrincipals(s.Principal)) {
      const conds = conditionKeys(s);
      if (p === "*") {
        out.push({
          ...base,
          Severity: conds ? "REVIEW" : "HIGH",
          Finding: conds ? "wildcard principal with conditions" : "wildcard principal, no conditions",
          Principal: "*",
          Detail: `${sid}: ${actions}${conds ? ` | conditions: ${conds}` : ""}`,
        });
        continue;
      }
      const account = accountOf(p);
      if (account && account !== me) {
        out.push({ ...base, Severity: "REVIEW", Finding: "other account in key policy", Principal: p, Detail: `${sid}: ${actions}` });
      }
    }
  }

  if (skipGrants) return out;
  for await (const gPage of paginateListGrants({ client: kms }, { KeyId: keyId })) {
    for (const g of gPage.Grants ?? []) {
      const grantee = g.GranteePrincipal ?? "";
      const account = accountOf(grantee);
      if (account && account !== me) {
        out.push({
          ...base,
          Severity: "REVIEW",
          Finding: "grant to other account",
          Principal: grantee,
          Detail: `${g.GrantId?.slice(0, 12)}...: ${(g.Operations ?? []).join(", ")}${g.RetiringPrincipal ? ` | retiring: ${g.RetiringPrincipal}` : ""}`,
        });
      }
    }
  }
  return out;
}

async function main(): Promise<void> {
  const { Account: me } = await new STSClient({ region }).send(new GetCallerIdentityCommand({}));
  const aliases = new Map<string, string>();
  for await (const page of paginateListAliases({ client: kms }, {})) {
    for (const a of page.Aliases ?? []) if (a.TargetKeyId && a.AliasName) aliases.set(a.TargetKeyId, a.AliasName);
  }

  const findings: Finding[] = [];
  let customerKeys = 0;
  for await (const page of paginateListKeys({ client: kms }, {})) {
    for (const k of page.Keys ?? []) {
      if (!k.KeyId) continue;
      const base = { Key: k.KeyId, Alias: aliases.get(k.KeyId) ?? "-" };
      try {
        const { KeyMetadata: meta } = await kms.send(new DescribeKeyCommand({ KeyId: k.KeyId }));
        if (meta?.KeyManager !== "CUSTOMER") continue; // AWS managed keys have policies you can't edit.
        customerKeys++;
        findings.push(...(await checkKey(k.KeyId, base, me)));
      } catch (err) {
        // A key policy that doesn't let your role read it is itself worth a look.
        findings.push({ ...base, Severity: "REVIEW", Finding: "could not read key", Principal: "-", Detail: err instanceof Error ? err.name : String(err) });
      }
    }
  }

  console.log(`Account ${me}, ${region}: ${customerKeys} customer managed keys, ${findings.length} findings`);
  findings.sort((a, b) => a.Severity.localeCompare(b.Severity));
  if (findings.length) console.table(findings);
  console.log("Report only. Change key policies with PutKeyPolicy and remove grants with RevokeGrant after review.");
}

main().catch((err) => {
  console.error(err);
  process.exit(1);
});

How do you run it?

Terminal

npm install @aws-sdk/client-kms @aws-sdk/client-sts
npm install --save-dev tsx typescript @types/node

AWS_PROFILE=security-audit npx tsx find-kms-keys-with-open-key-policies.ts --region us-east-1

# Key policies only, skip grants
AWS_PROFILE=security-audit npx tsx find-kms-keys-with-open-key-policies.ts --region eu-west-1 --skip-grants

Sample output

Output

Account 111122223333, us-east-1: 2 customer managed keys, 5 findings
┌─────────┬────────────────────────────────────────┬────────────────────────┬──────────┬──────────────────────────────────────┬──────────────────────────────────────┬───────────────────────────────────────────────────────────────────────────┐
│ (index) │ Key                                    │ Alias                  │ Severity │ Finding                              │ Principal                            │ Detail                                                                    │
├─────────┼────────────────────────────────────────┼────────────────────────┼──────────┼──────────────────────────────────────┼──────────────────────────────────────┼───────────────────────────────────────────────────────────────────────────┤
│ 0       │ '1234abcd-12ab-34cd-56ef-1234567890ab' │ 'alias/shared-backups' │ 'HIGH'   │ 'wildcard principal, no conditions'  │ '*'                                  │ 'Allow use: kms:Decrypt, kms:DescribeKey'                                 │
│ 1       │ '1234abcd-12ab-34cd-56ef-1234567890ab' │ 'alias/shared-backups' │ 'REVIEW' │ 'other account in key policy'        │ 'arn:aws:iam::444455556666:root'     │ 'Partner: kms:Decrypt'                                                    │
│ 2       │ '1234abcd-12ab-34cd-56ef-1234567890ab' │ 'alias/shared-backups' │ 'REVIEW' │ 'other account in key policy'        │ '555566667777'                       │ 'Partner: kms:Decrypt'                                                    │
│ 3       │ '0b7c6d5e-4f3a-4b2c-8d1e-9f0a1b2c3d4e' │ '-'                    │ 'REVIEW' │ 'wildcard principal with conditions' │ '*'                                  │ 'Org: kms:Decrypt | conditions: aws:PrincipalOrgID'                       │
│ 4       │ '0b7c6d5e-4f3a-4b2c-8d1e-9f0a1b2c3d4e' │ '-'                    │ 'REVIEW' │ 'grant to other account'             │ 'arn:aws:iam::444455556666:role/etl' │ '6f1f2a8b9c0d...: Decrypt | retiring: arn:aws:iam::444455556666:role/etl' │
└─────────┴────────────────────────────────────────┴────────────────────────┴──────────┴──────────────────────────────────────┴──────────────────────────────────────┴───────────────────────────────────────────────────────────────────────────┘
Report only. Change key policies with PutKeyPolicy and remove grants with RevokeGrant after review.

Keys and accounts are illustrative. alias/shared-backups has an unconditioned wildcard that allows kms:Decrypt, the finding to fix first, plus a statement for two partner accounts. The second key allows * only within an organization and has one grant to a role in account 444455556666.

How do you fix each finding?

  • Wildcard, no conditions. Replace * with the specific accounts or roles that need the key, or add a condition such as aws:PrincipalOrgID. Edit the full policy with PutKeyPolicy and keep the account principal statement so the key doesn’t become unmanageable.
  • Wildcard with conditions. Check that the condition actually limits who can call the key. Organization, account and service conditions are strong; network-only conditions usually aren’t.
  • Other account in the key policy. Confirm the account is still a partner, and narrow the actions to what it needs, often just kms:Decrypt and kms:DescribeKey.
  • Grant to another account. If nobody can explain it, a key administrator can remove it with RevokeGrant; the retiring principal can use RetireGrant.

Keys that are open but unused are easier to schedule for deletion than to fix; the script to find unused KMS keys and their monthly cost helps decide. For ongoing detection, turn on the analyzer with the script to check IAM Access Analyzer in every Region, and make sure PutKeyPolicy and CreateGrant calls are logged by the check that confirms CloudTrail is enabled in all Regions.

Troubleshooting

  • could not read key with AccessDeniedException. The key policy doesn’t let your role read it. Ask the key owner, or check it as a principal the policy names.
  • NotFoundException. A key or grant was deleted between the list call and the read. Run the report again.
  • No findings on a key you know is shared. Sharing may be done through an IAM role in your account that the partner assumes. That’s an IAM trust question, not a key policy one.
  • Many grants on EBS or RDS keys. Expected: those services create grants for your own resources. The script only lists grantees in other accounts.

Ask ChatWithCloud instead

Ask ChatWithCloud “Do any of my customer managed KMS keys allow a wildcard principal?” It writes AWS SDK for JavaScript v2 code, runs it on your machine with your profile and explains the statements it finds, in one Region per session; the guide to connect ChatWithCloud to a read-only AWS profile covers the setup. It runs generated code without a confirmation step, so use a read-only profile and make key policy changes yourself. The ChatWithCloud security model explains that the policy JSON the calls return is sent for processing. To use a key from your own code, see the guide to encrypt and decrypt with KMS in AWS SDK v3.

Frequently asked questions

Is “Principal”: “*” in a KMS key policy always a problem?

Not always. With a condition such as aws:PrincipalOrgID or kms:CallerAccount it can be a deliberate pattern. Without a condition it lets any AWS account use the key for the allowed actions.

How do I read a KMS key policy from the CLI?

Run aws kms get-key-policy --key-id <key-id> --policy-name default --output text. default is the only valid policy name.

How do I list grants on a KMS key?

Run aws kms list-grants --key-id <key-id>. Each grant shows the grantee principal, operations and retiring principal.

Can I change the key policy of an AWS managed key?

No. The KMS docs say you can view the key policies of AWS managed keys such as aws/ebs but can’t change them, which is why the script skips them.

Related guides

Ask your AWS account in plain English

Your first 15 runs are free, with no OpenAI key needed.

npx chatwithcloud