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.
- name
aws-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.
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:Decrypton the key, CloudWatch Logs needslogs:CreateLogStreamandlogs: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
| Situation | Use |
|---|---|
| A workload (Lambda, ECS task, EC2) calls AWS | Role with identity policy; service trust policy |
| CI/CD deploys to AWS | OIDC federation role (GitHub Actions, GitLab), no access keys |
| Humans | IAM Identity Center permission sets, not IAM users |
| Cross-account access | Role in the target account trusted by the source account principal, or resource policy (S3, KMS, SQS, SNS) |
| Guardrails for a whole org or OU | SCPs (cap principals) and RCPs (cap resources) |
| Delegating role creation to developers safely | Permission 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:
| Key | Use |
|---|---|
aws:PrincipalOrgID | Resource policies: allow only principals in your AWS Organization |
aws:ResourceOrgID | Identity policies/SCPs: only touch resources in your org (data perimeter) |
aws:SourceArn, aws:SourceAccount | Service-to-service calls (S3 to SNS, EventBridge to Lambda): prevent confused deputy |
aws:SecureTransport | Deny non-TLS access in bucket/queue policies |
aws:RequestedRegion | Restrict regions in SCPs |
aws:PrincipalTag/..., aws:ResourceTag/... | ABAC: match principal and resource tags |
aws:SourceVpce, aws:SourceVpc | Only via a VPC endpoint |
sts:ExternalId | Third-party cross-account roles (confused deputy) |
aws:MultiFactorAuthPresent | Require 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)
- Explicit
Denyin any applicable policy wins. - Organizations SCPs (principal side) and RCPs (resource side) must allow.
- 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).
- Permission boundary on the principal must allow.
- Session policy (assumed-role session policies) must allow.
- 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:ListBucketon the bucket ARN (needed to get 404 instead of 403 for missing objects),iam:PassRole. - Resource ARN mismatch: bucket ARN vs object ARN (
bucketvsbucket/*), 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": "*"orservice:*for workloads. NoNotActionwithAllow. - No long-lived access keys for workloads or CI; use roles and OIDC. Rotate or remove any
that exist (
aws iam list-access-keys, checkLastUsedDate). iam:PassRolealways scoped to specific role ARNs andiam:PassedToService.- Never grant
iam:*Policy*,iam:CreateAccessKey,iam:UpdateAssumeRolePolicyto 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
- Access Analyzer
validate-policyreturns no errors or security warnings. scripts/policy_lint.pyexits 0 at the agreed fail level.- The original failing call succeeds; a call outside the intended scope (another bucket prefix, another table) still fails with AccessDenied.
- 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
#!/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.