---
name: aws-iam-least-privilege
description: >-
  AWS IAM least-privilege patterns: starting from zero permissions, one role
  per workload, scoping Resource fields, AssumeRole over long-lived keys,
  and reviewing policy changes like code. Use when writing or reviewing IAM
  policies.
argument-hint: "[role or policy]"
license: MIT
metadata:
  author: John Felix Lim
  version: "1.0.0"
---
# IAM Least-Privilege Patterns

Apply when writing or reviewing an IAM policy. Start from what the workload demonstrably needs, not from a broad managed policy narrowed down later — the narrowing rarely happens.

## Rules

1. **Start from zero permissions, add what's proven necessary** — The instinct to attach a broad managed policy 'to unblock this' and narrow it later almost never gets revisited. Start with an empty policy and add actions as CloudTrail or Access Analyzer evidence shows they're actually used.
2. **One role per service, not one role for everything** — A shared role used by three Lambda functions means the blast radius of any one function's compromise is all three functions' permissions combined. Scope roles to the single workload that assumes them.
3. **Wildcards in `Resource` are usually wider than intended** — `"Resource": "*"` on an otherwise-scoped action grants that action against every resource of that type in the account, not just the ones the workload actually touches. Scope to specific ARNs or ARN patterns.
4. **`AssumeRole` with a condition beats a long-lived access key every time** — Cross-account and cross-service access via STS `AssumeRole` (with an external ID or source-account condition) is time-limited and revocable. A long-lived access key embedded anywhere is a permanent liability from the moment it's created.
5. **Policy changes need the same review as code changes** — An IAM policy is executable authorization logic. Reviewing it only for JSON syntax and not for what it actually grants is how an overly broad action slips into production.

## Patterns

### Wildcard resource on a scoped action

**Avoid** — The action is scoped, but the resource isn't — this grants read access to every object in every bucket in the account, not just the one this workload needs.

```json
{
  "Effect": "Allow",
  "Action": "s3:GetObject",
  "Resource": "*"
}
```

**Prefer** — Both the action and the resource are scoped — this workload can read objects from exactly the bucket it needs and nothing else.

```json
{
  "Effect": "Allow",
  "Action": "s3:GetObject",
  "Resource": "arn:aws:s3:::app-uploads-bucket/*"
}
```

### Long-lived access key for cross-service access

**Avoid** — The key is valid until someone remembers to rotate or revoke it — often years, and it grants whatever that IAM user can do, indefinitely.

```bash
# An IAM user's long-lived access key, embedded in an env var,
# used by a CI job to deploy to another AWS account.
AWS_ACCESS_KEY_ID=AKIA... AWS_SECRET_ACCESS_KEY=... terraform apply
```

**Prefer** — Credentials are issued per CI run, scoped to exactly this workflow, and expire automatically — there's no long-lived secret to leak or rotate.

```bash
# The CI job assumes a role via OIDC federation instead of
# holding a static credential at all — see aws_iam_role with
# an assume_role_policy trusting the CI provider's OIDC issuer.
```

### One shared role for multiple Lambda functions

**Avoid** — A vulnerability in the least-trusted of the three functions grants an attacker the combined permissions of all three.

```hcl
resource "aws_iam_role" "lambda_shared" {
  # Attached to process-orders, send-emails, and generate-reports —
  # each function ends up with the union of all three functions' needs.
}
```

**Prefer** — Each function's blast radius is limited to exactly its own permissions — compromising one doesn't grant access to the others' resources.

```hcl
resource "aws_iam_role" "process_orders"    { /* only what process-orders needs */ }
resource "aws_iam_role" "send_emails"       { /* only what send-emails needs */ }
resource "aws_iam_role" "generate_reports"  { /* only what generate-reports needs */ }
```

## Review checklist

- [ ] New IAM policies start from an empty permission set, with actions added based on demonstrated need, not copied from a broad managed policy.
- [ ] Each workload (Lambda function, service, CI job) has its own role, not a role shared across unrelated workloads.
- [ ] `Resource` fields are scoped to specific ARNs or ARN patterns; a wildcard resource is justified explicitly when unavoidable.
- [ ] Cross-account or cross-service access uses `AssumeRole` with a condition (external ID, source ARN), not a long-lived access key.
- [ ] IAM policy changes go through the same review process as application code, evaluated for what they actually grant.
- [ ] Unused permissions are periodically pruned using IAM Access Analyzer or CloudTrail-based last-accessed data.

---

From the John Felix Lim Frontend Engineering Playbook — https://github.com/JohnFelixLim