AWS Identity

Engineering Least-Privilege IAM at Scale with Permission Boundaries and Access Analyzer

Least privilege is easy to say and hard to operate. The failure mode is always the same: a central team owns IAM, becomes a bottleneck, and ships * policies to stop the ticket queue from exploding. This guide shows the opposite pattern — delegate IAM to teams safely, bound what they can grant, and let machines, not reviewers, find the over-permissioning. Everything here assumes a multi-account org with AWS IAM Identity Center for human access and IAM roles for workloads.

The IAM evaluation chain: one mental model

Before any tooling, you have to hold the full evaluation logic in your head, because every control below plugs into a specific slot. For a request inside a single account, AWS evaluates these policy types together:

Request -> is there an explicit Deny anywhere?  -> DENY
        -> does an SCP (org) allow the action?   -> if not, DENY
        -> does a resource control policy allow?  -> if not, DENY (where applicable)
        -> does the identity policy allow it?     -> needed for IAM principals
        -> does the permissions boundary allow it? -> if attached, intersect
        -> (cross-account) does the resource policy allow it?
        -> session policy further narrows the result
        => ALLOW only if every required gate is open and nothing denies

The non-obvious parts that trip up experienced people:

Hold this picture the whole way through: SCPs are the org guardrail, boundaries are the delegation guardrail, identity policies are the grant, and Access Analyzer is the feedback loop that tells you the grant is too wide.

Step 1 — Safe delegation: roles inside an inescapable boundary

The goal: let a product team create and manage their own IAM roles, but guarantee that nothing they create can exceed a boundary, and that they cannot detach or weaken that boundary. This is the single highest-leverage IAM pattern in a large org.

It works in two halves. First, the boundary policy itself — the ceiling every team-created role must wear:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "AllowedServices",
      "Effect": "Allow",
      "Action": [
        "s3:*",
        "dynamodb:*",
        "logs:*",
        "sqs:*",
        "lambda:*"
      ],
      "Resource": "*"
    },
    {
      "Sid": "DenyBoundaryAndOrgTampering",
      "Effect": "Deny",
      "Action": [
        "iam:CreateUser",
        "iam:DeleteUserPermissionsBoundary",
        "iam:DeleteRolePermissionsBoundary",
        "organizations:*",
        "account:*"
      ],
      "Resource": "*"
    }
  ]
}

Second, the delegation policy attached to the team’s own admin role. This is where the real enforcement lives: the team may call iam:CreateRole and iam:PutRolePolicy only if the boundary is attached, using the iam:PermissionsBoundary condition key.

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "CreateRolesWithBoundary",
      "Effect": "Allow",
      "Action": [
        "iam:CreateRole",
        "iam:PutRolePolicy",
        "iam:AttachRolePolicy",
        "iam:DeleteRolePolicy",
        "iam:DetachRolePolicy"
      ],
      "Resource": "arn:aws:iam::*:role/team-app/*",
      "Condition": {
        "StringEquals": {
          "iam:PermissionsBoundary": "arn:aws:iam::ACCOUNT_ID:policy/team-boundary"
        }
      }
    },
    {
      "Sid": "ProtectTheBoundaryItself",
      "Effect": "Deny",
      "Action": [
        "iam:DeleteRolePermissionsBoundary",
        "iam:DeletePolicy",
        "iam:DeletePolicyVersion",
        "iam:CreatePolicyVersion",
        "iam:SetDefaultPolicyVersion"
      ],
      "Resource": "arn:aws:iam::ACCOUNT_ID:policy/team-boundary"
    },
    {
      "Sid": "ScopeAndProtectByPath",
      "Effect": "Deny",
      "Action": "iam:*",
      "NotResource": "arn:aws:iam::*:role/team-app/*"
    }
  ]
}

Three things make this airtight, and all three are required:

  1. The iam:PermissionsBoundary condition means a CreateRole call without the exact boundary ARN is denied. Teams literally cannot make an unbounded role.
  2. The path scoping (role/team-app/* plus the NotResource deny) confines them to their own namespace, so they can’t touch platform or break-glass roles.
  3. The explicit denies on the boundary policy’s own lifecycle stop the classic privilege-escalation move of editing the ceiling.

Without the NotResource deny, a team could create a new role with the boundary, then use that role to act on roles outside their path. Boundary + path scoping must travel together.

Step 2 — ABAC with tags to replace policy sprawl

Boundaries cap what actions are possible. Attribute-based access control (ABAC) controls which resources by matching tags on the principal against tags on the resource, so you stop writing a new policy per team/project/environment. One policy serves everyone.

The pattern: principals carry a Project tag (a session tag from Identity Center, or a tag on the role), and resources carry a matching Project tag. Access is granted only when they’re equal.

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "ABACSameProject",
      "Effect": "Allow",
      "Action": ["s3:GetObject", "s3:PutObject"],
      "Resource": "arn:aws:s3:::shared-data/*",
      "Condition": {
        "StringEquals": {
          "aws:ResourceTag/Project": "${aws:PrincipalTag/Project}"
        }
      }
    },
    {
      "Sid": "RequireProjectTagOnCreate",
      "Effect": "Allow",
      "Action": "ec2:CreateTags",
      "Resource": "*",
      "Condition": {
        "StringEquals": {
          "aws:RequestTag/Project": "${aws:PrincipalTag/Project}"
        }
      }
    }
  ]
}

${aws:PrincipalTag/Project} is resolved at request time from the caller’s tags. For federated users, set these as session tags in the Identity Center permission set or the SAML/OIDC assertion so the value follows the human, not a static role.

ABAC only works if tagging is enforced, not requested. Pair it with an SCP that denies resource creation unless the required tags are present (a Null condition on aws:RequestTag/Project), and deny *:Untag* on governance tags. An ABAC policy over untagged resources silently grants nothing — or, worse, grants everything if you fat-finger the condition.

Step 3 — Generating least-privilege policies from access activity

Stop hand-writing policies from API docs. IAM Access Analyzer reads CloudTrail history for a role and generates a policy containing only the actions it actually used. This is how you replace an over-broad starter policy with a tight one after a few weeks of real traffic.

# Kick off policy generation from the last ~90 days of CloudTrail for one role.
aws accessanalyzer start-policy-generation \
  --policy-generation-details '{"principalArn":"arn:aws:iam::ACCOUNT_ID:role/team-app/orders-service"}' \
  --cloud-trail-details '{
    "trails":[{"cloudTrailArn":"arn:aws:cloudtrail:us-east-1:ACCOUNT_ID:trail/org-trail","allRegions":true}],
    "accessRole":"arn:aws:iam::ACCOUNT_ID:role/AccessAnalyzerCloudTrailRole",
    "startTime":"2026-03-01T00:00:00Z",
    "endTime":"2026-06-01T00:00:00Z"
  }'

# Poll, then fetch the generated policy once status is SUCCEEDED.
aws accessanalyzer get-generated-policy \
  --job-id JOB_ID \
  --include-resource-placeholders

--include-resource-placeholders is the flag worth knowing: where Access Analyzer can infer resource-level scoping, it emits placeholders like ${S3Bucket} instead of *, so you finish the resource scoping by hand instead of starting from scratch.

Treat the output as a strong first draft, never a final answer. It only knows what the role did during the window, so seasonal or rarely-used permissions (a quarterly batch job, a disaster-recovery path) won’t appear. Diff it against the current policy, keep what’s justified, and document any additions the data didn’t capture.

Step 4 — Finding unused access and over-permissive roles

The other half of right-sizing is deleting access nobody uses. Create an unused-access analyzer (this is a distinct analyzer type from external-access) and it continuously flags unused roles, unused IAM users, and — most valuably — unused permissions and unused service access on roles that are active.

aws accessanalyzer create-analyzer \
  --analyzer-name org-unused-access \
  --type ORGANIZATION_UNUSED_ACCESS \
  --configuration '{"unusedAccess":{"unusedAccessAge":90}}'

# List the actionable findings.
aws accessanalyzer list-findings-v2 \
  --analyzer-arn arn:aws:access-analyzer:us-east-1:ACCOUNT_ID:analyzer/org-unused-access \
  --filter '{"status":{"eq":["ACTIVE"]}}'

unusedAccessAge: 90 means “consider access unused if it hasn’t been exercised in 90 days.” Run this at the organization level from a delegated administrator account so one analyzer covers every member account. The unused-permission findings are the gold: they tell you a role is allowed dynamodb:* but has only ever called GetItem and Query, which is exactly the input for tightening the policy you generated in Step 3.

Unused-access analyzers are priced per IAM role and user monitored, billed monthly. At org scale that is a real line item — scope it deliberately and account for it, rather than discovering the bill later.

Step 5 — External and unused findings: catching unintended exposure

The original Access Analyzer capability — the external-access analyzer — uses automated reasoning to prove whether a resource policy grants access to a principal outside your zone of trust (the account, or the whole org). It covers S3 buckets, IAM roles’ trust policies, KMS keys, Lambda functions, SQS queues, Secrets Manager secrets, and more. This is your net for the bucket someone made public or the role anyone can assume.

aws accessanalyzer create-analyzer \
  --analyzer-name org-external-access \
  --type ORGANIZATION

aws accessanalyzer list-findings-v2 \
  --analyzer-arn arn:aws:access-analyzer:us-east-1:ACCOUNT_ID:analyzer/org-external-access \
  --filter '{"status":{"eq":["ACTIVE"]},"resourceType":{"eq":["AWS::S3::Bucket"]}}'

With org-level scope, a trust relationship to another account inside your org is not flagged (it’s in the zone of trust), but a trust to a stranger account or a Principal: * is. Triage every finding to one of three states: archive it with a rule if it’s intended (a known partner integration), fix the policy if it’s a mistake, or escalate. Archive rules keep the dashboard signal honest:

aws accessanalyzer create-archive-rule \
  --analyzer-name org-external-access \
  --rule-name known-partner-bucket \
  --filter '{"resource":{"eq":["arn:aws:s3:::partner-dropzone"]}}'

Step 6 — Validating policies in CI with policy checks

Shift all of the above left. Access Analyzer exposes policy validation and custom policy checks as synchronous APIs, so a pull request that changes a policy fails before merge instead of being caught by a finding days later.

validate-policy runs the same lint/security checks the console shows (overly permissive grants, syntax issues, deprecated globals):

aws accessanalyzer validate-policy \
  --policy-type IDENTITY_POLICY \
  --policy-document file://policy.json \
  --query 'findings[?findingType==`ERROR` || findingType==`SECURITY_WARNING`]'

The more powerful check is check-no-new-access: it uses automated reasoning to prove a proposed policy grants no access beyond a reference policy. This is how you enforce “this PR may not broaden permissions” mechanically, and it’s how you can prove a policy stays within a permission boundary.

aws accessanalyzer check-no-new-access \
  --policy-document file://proposed.json \
  --existing-policy-document file://baseline.json \
  --policy-type IDENTITY_POLICY
# Returns PASS or FAIL with the specific reasons that grant new access.

There is also check-access-not-granted (assert a specific sensitive action like iam:PassRole is never permitted) and check-no-public-access (assert a resource policy grants no public access). Wire the relevant ones into the pipeline as required status checks:

# .github/workflows/iam-policy-check.yml (excerpt)
- name: Block any new access vs baseline
  run: |
    RESULT=$(aws accessanalyzer check-no-new-access \
      --policy-document file://proposed.json \
      --existing-policy-document file://baseline.json \
      --policy-type IDENTITY_POLICY \
      --query 'result' --output text)
    echo "Result: $RESULT"
    [ "$RESULT" = "PASS" ] || { echo "Policy broadens access; failing."; exit 1; }

Federation, sessions, and a credential-exposure runbook

A few patterns that separate a clean IAM estate from a fragile one:

When a credential is exposed (a static key in a commit, a leaked session), the runbook is ordered for a reason:

  1. Revoke the session, don’t just rotate the key. For a role, attach an inline AWSRevokeOlderSessions-style policy that denies all actions where aws:TokenIssueTime is before now — this instantly kills already-issued temporary credentials, which rotation alone does not.
  2. For a leaked IAM user access key, deactivate then delete it (aws iam update-access-key --status Inactive, then delete) and audit iam:CreateAccessKey in CloudTrail for keys the attacker may have minted.
  3. Scope the blast radius from CloudTrail — what did that principal do during the exposure window? This is exactly the access-activity query from Step 3, run with intent.
  4. Re-evaluate the boundary. If the compromised principal could do real damage, the boundary in Step 1 was too generous; tighten it so the next incident is smaller.

Enterprise scenario

A fintech platform team delegated IAM per Step 1, with a boundary that allowed lambda:*. A product team shipped a deploy role that ran fine for months, then a routine check-no-new-access PR started failing with no obvious policy change. The grant hadn’t moved — the baseline had. Their reference policy was generated by Access Analyzer from CloudTrail, and a quarter-end reconciliation job that only runs four times a year had finally fired, so its dynamodb:BatchWriteItem calls now appeared in activity but not in the older baseline the check compared against.

The real gotcha: the boundary allowed the action, the identity policy allowed it, but check-no-new-access correctly flagged it as new access vs the frozen baseline. Treating the generated policy as ground truth had baked the seasonal blind spot from Step 3 straight into the gate. The fix was to stop diffing against a stale snapshot and instead assert the invariant that actually mattered — that nothing exceeds the boundary:

aws accessanalyzer check-no-new-access \
  --policy-document file://proposed.json \
  --existing-policy-document file://team-boundary.json \
  --policy-type IDENTITY_POLICY \
  --query 'result' --output text

They kept the baseline diff as an advisory comment, but made the boundary check the blocking status check. Now a PR fails only when it genuinely escapes the ceiling, not when a rare-but-legitimate path shows up. Lesson: a least-privilege gate is only as good as what it compares to — pin it to a stable invariant, not a moving activity log.

Verify

Confirm each control actually behaves before you trust it:

# 1. Prove the delegation is bounded: this MUST be denied (no boundary attached).
aws iam create-role --role-name escape-test \
  --assume-role-policy-document file://trust.json
# Expected: AccessDenied (the delegation policy requires iam:PermissionsBoundary)

# 2. Confirm effective permissions = identity policy INTERSECT boundary.
aws iam simulate-principal-policy \
  --policy-source-arn arn:aws:iam::ACCOUNT_ID:role/team-app/orders-service \
  --action-names s3:DeleteBucket s3:GetObject
# Expected: DeleteBucket implicitDeny (outside boundary), GetObject allowed

# 3. Verify no external access findings remain unaddressed.
aws accessanalyzer list-findings-v2 \
  --analyzer-arn arn:aws:access-analyzer:us-east-1:ACCOUNT_ID:analyzer/org-external-access \
  --filter '{"status":{"eq":["ACTIVE"]}}' --query 'length(findings)'
# Expected: 0 (everything is fixed or intentionally archived)

simulate-principal-policy is the underused hero here — it evaluates the full chain including the boundary, so you can assert “this role cannot delete buckets” as a test, not a hope.

Checklist

Pitfalls

Next step: codify Steps 1, 4, and 5 as Terraform modules so the boundary policy, the org-level analyzers, and the delegation role ship with every new account from your landing zone — least privilege you provision, not least privilege you police.

AWSIAMPermission BoundariesAccess AnalyzerLeast PrivilegeABAC

Comments

Keep Reading