Cloud Platforms No. 174Editor's pick

AWS IAM Least Privilege skill for Claude Code

Exact permissions, and AccessDenied solved by evidence. Claude maps each API call to the exact IAM actions, ARNs and condition keys it needs instead of reaching for wildcards. AccessDenied errors are traced through caller identity, CloudTrail, decoded messages and the evaluation order to the policy type that denied. Policies are validated with Access Analyzer and a bundled stdlib linter that flags PassRole, escalation paths and open OIDC trusts.

  • nameaws-iam
  • size204 lines · ≈2,350 tokens
  • files2
  • licenseMIT
  • updated

What Claude sees before loading it

This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.

description: Writes least-privilege AWS IAM policies and debugs AccessDenied errors - identity, resource and trust policies, roles for services and CI (GitHub Actions OIDC), condition keys, iam:PassRole scoping, permission boundaries, SCPs, the policy evaluation order, IAM Access Analyzer validation and policy generation, the policy simulator, and CloudTrail-based diagnosis. Use when creating or reviewing IAM policies or roles, when an AWS call fails with AccessDenied or UnauthorizedOperation, or when replacing long-lived access keys.

Try asking

  • Our Lambda gets AccessDenied on s3:PutObject. Find out why
  • Write a least-privilege role for GitHub Actions to deploy this stack
  • Review these IAM policies for wildcards and escalation risks

Use it when

  • Writing or reviewing IAM policies, roles or trust relationships
  • An AWS call fails with AccessDenied or UnauthorizedOperation
  • Replacing access keys with roles or OIDC federation
  • Adding SCPs, permission boundaries or data perimeter conditions

Skip it when

  • You need a broad security review of application code — use owasp-review
  • The task is S3 bucket design beyond its policy — use s3-object-storage

How to check the result

Access Analyzer validate-policy reports no errors or warnings, the bundled linter exits 0, the original call succeeds, and an out-of-scope call still returns AccessDenied.

The common mistake it prevents

Fixing AccessDenied by widening to s3:* or Resource * when the real cause was the wrong role, a missing KMS permission or an SCP deny.

~/.claude/skills/aws-iam/SKILL.md

--- name: aws-iam description: Writes least-privilege AWS IAM policies and debugs AccessDenied errors - identity, resource and trust policies, roles for services and CI… license: MIT ---

A good IAM change grants only the actions a workload actually calls, on the specific resource ARNs it touches, under conditions that pin who and from where; uses roles with temporary credentials instead of access keys; and is validated by Access Analyzer before it ships. An AccessDenied is diagnosed by evidence (which policy type denied), not by widening permissions until it works.

Workflow: writing a policy

- [ ] 1. List the exact API calls the workload makes (code, SDK calls, CloudTrail history)
- [ ] 2. Map each call to IAM actions, resource ARNs and supported condition keys
- [ ] 3. Choose where permissions live: identity policy, resource policy, or both
- [ ] 4. Write the policy with specific ARNs and conditions; add explicit denies for guardrails
- [ ] 5. Validate: Access Analyzer validate-policy + scripts/policy_lint.py
- [ ] 6. Test with the simulator or a real call in a non-prod account
- [ ] 7. Review unused access later and trim

1-2. From calls to actions

  • The Service Authorization Reference lists, per service, every action, its resource types, and supported condition keys. Some actions do not support resource-level permissions and require "Resource": "*"; that is fine for those actions only (e.g. ec2:DescribeInstances, s3:ListAllMyBuckets).
  • Some calls need several actions (e.g. S3 multipart uploads, KMS-encrypted objects need kms:GenerateDataKey/kms:Decrypt on the key, CloudWatch Logs needs logs:CreateLogStream and logs:PutLogEvents).
  • For existing workloads, generate a starting policy from CloudTrail with Access Analyzer policy generation (console: role > “Generate policy”), then tighten it.

3. Where permissions live

SituationUse
A workload (Lambda, ECS task, EC2) calls AWSRole with identity policy; service trust policy
CI/CD deploys to AWSOIDC federation role (GitHub Actions, GitLab), no access keys
HumansIAM Identity Center permission sets, not IAM users
Cross-account accessRole in the target account trusted by the source account principal, or resource policy (S3, KMS, SQS, SNS)
Guardrails for a whole org or OUSCPs (cap principals) and RCPs (cap resources)
Delegating role creation to developers safelyPermission boundaries

4. Policy patterns

Scoped identity policy:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "ReadWriteUploadsPrefix",
      "Effect": "Allow",
      "Action": ["s3:GetObject", "s3:PutObject"],
      "Resource": "arn:aws:s3:::acme-prod-uploads/tenants/*"
    },
    {
      "Sid": "ListOnlyThatPrefix",
      "Effect": "Allow",
      "Action": "s3:ListBucket",
      "Resource": "arn:aws:s3:::acme-prod-uploads",
      "Condition": { "StringLike": { "s3:prefix": ["tenants/*"] } }
    },
    {
      "Sid": "PassOnlyTheWorkerRoleToEcs",
      "Effect": "Allow",
      "Action": "iam:PassRole",
      "Resource": "arn:aws:iam::123456789012:role/orders-worker-task",
      "Condition": { "StringEquals": { "iam:PassedToService": "ecs-tasks.amazonaws.com" } }
    }
  ]
}

GitHub Actions OIDC trust policy:

{
  "Version": "2012-10-17",
  "Statement": [{
    "Effect": "Allow",
    "Principal": { "Federated": "arn:aws:iam::123456789012:oidc-provider/token.actions.githubusercontent.com" },
    "Action": "sts:AssumeRoleWithWebIdentity",
    "Condition": {
      "StringEquals": {
        "token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
        "token.actions.githubusercontent.com:sub": "repo:acme/orders:environment:production"
      }
    }
  }]
}

Without the sub condition any GitHub repository can assume the role. Prefer environment:<name> or ref:refs/heads/main over repo:acme/*.

Condition keys worth knowing:

KeyUse
aws:PrincipalOrgIDResource policies: allow only principals in your AWS Organization
aws:ResourceOrgIDIdentity policies/SCPs: only touch resources in your org (data perimeter)
aws:SourceArn, aws:SourceAccountService-to-service calls (S3 to SNS, EventBridge to Lambda): prevent confused deputy
aws:SecureTransportDeny non-TLS access in bucket/queue policies
aws:RequestedRegionRestrict regions in SCPs
aws:PrincipalTag/..., aws:ResourceTag/...ABAC: match principal and resource tags
aws:SourceVpce, aws:SourceVpcOnly via a VPC endpoint
sts:ExternalIdThird-party cross-account roles (confused deputy)
aws:MultiFactorAuthPresentRequire MFA for sensitive actions (human sessions)

Condition operators: StringEquals/StringLike, ArnLike, Bool, IpAddress, ForAnyValue:/ForAllValues: for multivalued keys (ForAllValues is true when the key is absent; pair with a Null check). Use ...IfExists deliberately.

Evaluation order (for diagnosis)

  1. Explicit Deny in any applicable policy wins.
  2. Organizations SCPs (principal side) and RCPs (resource side) must allow.
  3. Resource-based policy can grant access (within the same account, it can allow even without an identity policy for most services; KMS key policies and IAM role trust policies must explicitly allow).
  4. Permission boundary on the principal must allow.
  5. Session policy (assumed-role session policies) must allow.
  6. Identity-based policy must allow.

Cross-account: both the identity policy in the caller’s account and the resource policy (or role trust) in the target account must allow.

Workflow: debugging AccessDenied

- [ ] 1. Read the full error: action, resource ARN, principal, and "because no ... policy allows"/"with an explicit deny in a ... policy"
- [ ] 2. Confirm who is calling: aws sts get-caller-identity (in the same environment)
- [ ] 3. Decode encoded messages (EC2 and some others)
- [ ] 4. Find the CloudTrail event for the failed call
- [ ] 5. Check each policy type in evaluation order; simulate
- [ ] 6. Fix the narrowest policy; re-test the same call
aws sts get-caller-identity
aws sts decode-authorization-message --encoded-message "$MSG" --query DecodedMessage --output text | jq .
aws cloudtrail lookup-events --lookup-attributes AttributeKey=EventName,AttributeValue=PutObject \
  --max-results 20 --query 'Events[].CloudTrailEvent' --output text | jq -c 'select(.errorCode != null) | {eventTime, errorCode, errorMessage, userIdentity: .userIdentity.arn}'
aws iam simulate-principal-policy \
  --policy-source-arn arn:aws:iam::123456789012:role/orders-api \
  --action-names s3:PutObject --resource-arns arn:aws:s3:::acme-prod-uploads/tenants/t1/a.png \
  --query 'EvaluationResults[].{action:EvalActionName,decision:EvalDecision}'

Common causes, in order of frequency:

  • Wrong principal: the code runs with a different role than expected (local profile, missing task role, Lambda execution role vs deploy role).
  • Missing companion permission: KMS key policy/grant, s3:ListBucket on the bucket ARN (needed to get 404 instead of 403 for missing objects), iam:PassRole.
  • Resource ARN mismatch: bucket ARN vs object ARN (bucket vs bucket/*), wrong region or account, a path in the role ARN.
  • Explicit deny from an SCP, permission boundary, bucket policy (aws:SecureTransport, VPC endpoint conditions), or VPC endpoint policy. The error message names the policy type.
  • Condition not met: missing tag, wrong s3:prefix, request without encryption header.
  • Propagation: new IAM changes are eventually consistent; retry after a few seconds before concluding.

Validate

aws accessanalyzer validate-policy --policy-type IDENTITY_POLICY \
  --policy-document file://policy.json --query 'findings[].[findingType,issueCode,findingDetails]' --output table
python3 scripts/policy_lint.py policy.json trust.json      # wildcards, PassRole, OIDC sub, public principals

validate-policy catches invalid actions, ARN formats, and security warnings; scripts/policy_lint.py (stdlib) flags least-privilege smells and exits 1 on high findings, so it fits in CI next to Terraform/CDK synth output. Access Analyzer custom policy checks (check-no-new-access, check-access-not-granted) can gate PRs against a reference policy or forbidden actions.

Hard rules

  • No "Action": "*" or service:* for workloads. No NotAction with Allow.
  • No long-lived access keys for workloads or CI; use roles and OIDC. Rotate or remove any that exist (aws iam list-access-keys, check LastUsedDate).
  • iam:PassRole always scoped to specific role ARNs and iam:PassedToService.
  • Never grant iam:*Policy*, iam:CreateAccessKey, iam:UpdateAssumeRolePolicy to workloads: each is a privilege-escalation path.
  • One role per workload; do not share roles across services.
  • Changes to production trust policies, SCPs or admin roles require explicit user confirmation.

Verification

  1. Access Analyzer validate-policy returns no errors or security warnings.
  2. scripts/policy_lint.py exits 0 at the agreed fail level.
  3. The original failing call succeeds; a call outside the intended scope (another bucket prefix, another table) still fails with AccessDenied.
  4. After 30-90 days, Access Analyzer unused-access findings or last-accessed data (aws iam generate-service-last-accessed-details) confirm nothing granted is unused.

Bundled files

Loaded only when a step in the skill needs them. Review them here before installing.

scripts/policy_lint.py 142 lines · 6.9 KB
#!/usr/bin/env python3
"""Static least-privilege lint for AWS IAM policy JSON files (identity, resource, or trust).

Flags wildcards, Allow + NotAction/NotResource, unscoped iam:PassRole, privilege-escalation
actions, public principals without conditions, and OIDC trust policies missing a `sub`
condition. It is a fast pre-check, not a replacement for IAM Access Analyzer
`validate-policy`, which knows every service's actions and condition keys.

Usage:
  python3 policy_lint.py policy.json [more.json ...] [--fail-on high|medium|low]

Exit codes: 0 no findings at or above the fail level, 1 findings, 2 unreadable input.
Standard library only.
"""
from __future__ import annotations

import argparse
import json
import re
import sys

SEVERITY_ORDER = {"low": 0, "medium": 1, "high": 2}

ESCALATION_ACTIONS = {
    "iam:createpolicyversion", "iam:setdefaultpolicyversion", "iam:attachuserpolicy",
    "iam:attachrolepolicy", "iam:attachgrouppolicy", "iam:putuserpolicy", "iam:putrolepolicy",
    "iam:putgrouppolicy", "iam:createaccesskey", "iam:createloginprofile",
    "iam:updateloginprofile", "iam:updateassumerolepolicy", "iam:addusertogroup",
    "iam:putuserpermissionsboundary", "iam:deleteuserpermissionsboundary",
    "iam:putrolepermissionsboundary", "iam:deleterolepermissionsboundary",
}
WRITE_VERBS = re.compile(
    r"^(put|delete|create|update|attach|detach|modify|remove|add|set|terminate|run|start|stop|"
    r"invoke|publish|send|write|replace|restore|revoke|authorize|associate|disassociate|tag|untag)",
    re.I,
)


def as_list(v):
    if v is None:
        return []
    return v if isinstance(v, list) else [v]


def lint(policy: dict) -> list[tuple[str, str, str]]:
    findings: list[tuple[str, str, str]] = []
    statements = as_list(policy.get("Statement"))
    if not statements:
        findings.append(("high", "-", "policy has no Statement"))
    if policy.get("Version") != "2012-10-17":
        findings.append(("medium", "-", 'Version should be "2012-10-17" (older versions disable policy variables)'))

    for i, st in enumerate(statements):
        sid = st.get("Sid") or f"#{i}"
        effect = st.get("Effect")
        actions = [a.lower() for a in as_list(st.get("Action"))]
        resources = as_list(st.get("Resource"))
        principal = st.get("Principal")
        condition = st.get("Condition") or {}
        if effect not in ("Allow", "Deny"):
            findings.append(("high", sid, f"Effect must be Allow or Deny, got {effect!r}"))
            continue
        if effect != "Allow":
            continue

        if "NotAction" in st:
            findings.append(("high", sid, "Allow with NotAction grants every action not listed, including future ones"))
        if "NotResource" in st:
            findings.append(("medium", sid, "Allow with NotResource grants access to every other resource"))
        if "*" in actions:
            findings.append(("high", sid, 'Action "*" grants full access'))
        for a in actions:
            if a.endswith(":*") and a != "*":
                findings.append(("high", sid, f"service wildcard {a} grants every action in the service"))
            elif "*" in a and a != "*":
                findings.append(("low", sid, f"partial wildcard {a}; confirm it cannot match new sensitive actions"))
            if a in ESCALATION_ACTIONS:
                findings.append(("high", sid, f"{a} enables privilege escalation; scope Resource tightly or remove"))

        if "iam:passrole" in actions or "iam:*" in actions:
            if "*" in resources:
                findings.append(("high", sid, "iam:PassRole on Resource * lets the principal hand any role to a service"))
            if "iam:PassedToService" not in json.dumps(condition):
                findings.append(("medium", sid, "iam:PassRole without an iam:PassedToService condition"))

        if "*" in resources and principal is None:
            writes = [a for a in actions if ":" in a and (a.split(":", 1)[1].startswith("*") or a.endswith(":passrole") or WRITE_VERBS.match(a.split(":", 1)[1]))]
            if writes:
                findings.append(("medium", sid, f"write actions on Resource *: {', '.join(sorted(set(writes))[:6])}"))
            elif actions and "*" not in actions:
                findings.append(("low", sid, "Resource * for read actions; scope to ARNs where the service supports it"))

        if principal is not None:
            public = principal == "*" or "*" in as_list(principal.get("AWS") if isinstance(principal, dict) else None)
            if public and not condition:
                findings.append(("high", sid, "Principal * without Condition makes this resource public"))
            elif public:
                findings.append(("low", sid, "Principal * restricted only by Condition; verify the condition keys (aws:PrincipalOrgID, aws:SourceArn, aws:SourceVpce)"))
            fed = principal.get("Federated") if isinstance(principal, dict) else None
            if fed and any("token.actions.githubusercontent.com" in f for f in as_list(fed)):
                cond_text = json.dumps(condition)
                if "token.actions.githubusercontent.com:sub" not in cond_text:
                    findings.append(("high", sid, "GitHub OIDC trust without a :sub condition lets any repository assume this role"))
                if "token.actions.githubusercontent.com:aud" not in cond_text:
                    findings.append(("medium", sid, "GitHub OIDC trust should require :aud = sts.amazonaws.com"))
            svc = principal.get("Service") if isinstance(principal, dict) else None
            if svc and "sts:assumerole" in actions and not condition:
                findings.append(("low", sid, "service trust without aws:SourceAccount/aws:SourceArn (confused deputy)"))
    return findings


def main() -> int:
    ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    ap.add_argument("files", nargs="+")
    ap.add_argument("--fail-on", choices=list(SEVERITY_ORDER), default="high")
    args = ap.parse_args()
    threshold = SEVERITY_ORDER[args.fail_on]
    failed = False
    for path in args.files:
        try:
            with open(path) as fh:
                doc = json.load(fh)
        except (OSError, json.JSONDecodeError) as e:
            print(f"{path}: cannot read policy: {e}", file=sys.stderr)
            return 2
        # Accept raw policies or wrappers like {"PolicyDocument": {...}} from CLI output.
        policy = doc.get("PolicyDocument", doc.get("Policy", doc)) if isinstance(doc, dict) else {}
        if isinstance(policy, str):
            policy = json.loads(policy)
        findings = sorted(lint(policy), key=lambda f: -SEVERITY_ORDER[f[0]])
        if not findings:
            print(f"{path}: OK")
            continue
        for sev, sid, msg in findings:
            print(f"{path}: {sev.upper():6} [{sid}] {msg}")
            if SEVERITY_ORDER[sev] >= threshold:
                failed = True
    return 1 if failed else 0


if __name__ == "__main__":
    sys.exit(main())

About this skill

Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.

esc