Everyone can recite the least-privilege catechism: don't use root, enable MFA, rotate keys. That advice is correct and roughly useless for the actual hard part, which is deciding what a specific role should be allowed to do and being confident you got it right. Getting that right requires understanding how AWS actually evaluates a request — because "least privilege" is not a setting, it is the emergent result of several policy types intersecting. This is the deep version.
How a request is actually decided
When a principal makes an API call, AWS runs it through an evaluation that combines every policy that applies. Simplified to the parts that matter:
- Start from an implicit deny. Nothing is allowed by default.
- Evaluate all applicable policies for an explicit
Deny. If any policy — identity, resource, SCP, permission boundary, session policy — saysDeny, the request is denied. Full stop. An explicit deny always wins. - Look for an
Allow. With no explicit deny in the way, the request is permitted only if something explicitly allows it and every guardrail (boundary, SCP) also permits it. - Otherwise, the implicit deny stands.
The order to internalize: explicit deny beats allow beats implicit deny. The subtlety is step three, because an allow in your identity policy is necessary but not sufficient. The action must survive every layer:
- Identity policies — attached to the user or role. These grant.
- Resource policies — attached to the resource (an S3 bucket policy, a KMS key policy). These can grant access across accounts and, importantly, can allow a call that the identity policy alone would not, when the two are in the same account and combine.
- Service control policies (SCPs) — org-level. These never grant anything; they only set the ceiling of what identity policies in an account can grant.
- Permission boundaries — attached to a principal. Also a ceiling: the effective permissions of that principal are the intersection of its identity policies and its boundary.
Think of it as a series of gates. The identity policy opens a gate; the SCP, the boundary, and any explicit deny can each keep it shut. A permission is real only when every gate is open. This is why "I gave the role s3:* and it still gets AccessDenied" is almost always an SCP or a boundary quietly holding the line — which is exactly what they are for.
The confused deputy, and the conditions that stop it
A confused deputy is a privileged service tricked into using its privileges on an attacker's behalf. The canonical AWS case: you let a third-party SaaS assume a role in your account so it can, say, read your billing data. The SaaS assumes roles for all its customers. If your role's trust policy only checks "is this call coming from the SaaS's account," an attacker who is also a customer of that SaaS can ask it to assume your role using their configuration, and the SaaS — the deputy — obliges. It is confused about whose behalf it is acting on.
The fix is a pair of condition keys that pin the request to the specific transaction that should be allowed:
aws:SourceArn— the exact resource on the calling side that is permitted to trigger this. Scope it to the specific SaaS resource tied to your account.aws:SourceAccount— the account ID that the call must originate from.
An external-ID pattern (a shared secret the third party must present, via sts:ExternalId) plays the same role for cross-account role assumption. The principle generalizes: whenever service A can act on resource B on behalf of some caller, constrain which caller, using conditions, not just which service.
Permission boundaries for safe delegation
Permission boundaries solve a specific organizational problem: you want to let a team create their own IAM roles (for their Lambdas, their CI, whatever) without letting them mint an admin role and escalate. You cannot just deny them iam:CreateRole, because then they file a ticket for every function.
Instead, grant them iam:CreateRole and iam:PutRolePolicy with a condition that any role they create must have a specific permission boundary attached. Now they are free to self-serve, but everything they create is capped at the boundary's ceiling no matter how permissive a policy they write into it. Delegation without escalation. The boundary is the leash; the length of the leash is your call.
SCPs as org-wide guardrails
SCPs are the coarse, account-spanning version of the same idea. They live in AWS Organizations and apply to every principal in the accounts (or organizational units) they are attached to — including, unlike almost everything else, roles that would otherwise be admin. Because they only cap and never grant, they are the right tool for statements you want to be true everywhere, unconditionally:
- Deny the ability to disable CloudTrail or GuardDuty.
- Deny leaving the organization or modifying the SCPs themselves.
- Restrict resource creation to approved regions.
- Deny the root user of member accounts from doing anything at all.
An SCP is a fence around the whole property. It does not decide who gets into which building; it decides that nobody, however privileged, drives off the edge of the cliff.
Access Analyzer: finding external access and generating policies
IAM Access Analyzer does two jobs that matter for least privilege. First, it continuously analyzes resource policies and tells you which resources are reachable from outside your account or organization — the bucket you accidentally made public, the role a foreign account can assume. This is the "what did we leak" report, and it should be zero-surprises.
Second, and underused: it will generate a least-privilege policy from CloudTrail history. Point it at a role's activity over the last N days and it produces a policy scoped to the actions that role actually used. You almost never guess the right policy by hand — you either over-grant out of caution or under-grant and field AccessDenied tickets. Generating from observed behavior gets you a tight starting draft that a human then reviews and trims. It turns "least privilege" from an act of imagination into an act of measurement.
Wildcards and PassRole: the classic escalation vectors
Two things show up in nearly every real privilege-escalation writeup.
Wildcards. "Action": "*" or "Resource": "*" is where least privilege goes to die, but the insidious ones are narrower. iam:* on a role lets that role rewrite its own permissions — game over. s3:* on * is read-and-write to every bucket including your logs. Prefer explicit action lists, or at least service-scoped wildcards constrained by resource and condition. Every * you write is a promise that you understand everything it currently and will ever expand to include, since AWS adds actions to services over time.
iam:PassRole. This is the one people miss. To attach a role to a service — hand a role to an EC2 instance, a Lambda, an ECS task — a principal needs iam:PassRole for that role. If a principal can pass a highly privileged role to a service they control, they can escalate: pass the admin role to a Lambda they can invoke, and now their code runs as admin. The defense is to scope PassRole tightly with a Resource naming the exact roles that may be passed, and ideally an iam:PassedToService condition restricting which service can receive it. A broad iam:PassRole on * is functionally close to admin even when it looks innocuous.
Stop shipping long-lived keys
The last mile of least privilege is credential lifetime. A long-lived access key is a permanent secret that leaks the same whether it is one hour or one year old, and the 2025 supply-chain worms proved how efficiently a leaked key gets found and used. The answer is to stop issuing them:
- Human access through role assumption with short-lived STS credentials, ideally behind identity-center SSO.
- Workloads on EC2/ECS/Lambda use their attached roles — no keys, credentials rotate automatically.
- CI/CD authenticates via OIDC federation instead of stored keys.
The GitHub Actions pattern is the one to copy. You register GitHub's OIDC provider as a trusted identity provider in your account, then create a role whose trust policy allows sts:AssumeRoleWithWebIdentity only for tokens from your specific repository and branch — the token.actions.githubusercontent.com:sub claim scoped to repo:your-org/your-repo:ref:refs/heads/main. The workflow requests an OIDC token, exchanges it for short-lived AWS credentials, and does its job. No access key ever exists to leak. A stolen token is bounded to minutes and to that one repo and branch.
The practice, not the poster
Least privilege is not a checklist you complete; it is a posture you maintain. Understand how the gates combine, pin cross-service trust with conditions, cap delegation with boundaries and SCPs, measure real usage with Access Analyzer instead of guessing, treat wildcards and PassRole as escalation until proven otherwise, and issue credentials that expire on their own. Do those, and "least privilege" stops being a slogan on a slide and becomes a property your account actually has.