CoursesAWS security engineeringThe IAM evaluation model

The IAM evaluation model

Deny wins, ceilings are conjunctive, grants additive.

Advanced35 min · lesson 1 of 15

Every action you take in AWS runs through the same silent question. Is this exact caller allowed to do this exact thing, to this exact resource, right now? The service that answers it is IAM, short for Identity and Access Management, and it answers on every single API call, meaning every request a program or a person makes to AWS, in a few milliseconds. Most people assume the answer comes from one policy saying yes. It doesn't. It comes from several policies arguing at once, and there are strict rules for who wins that argument.

One request, several locked doors

AWS starts every request at no. That's the default. Nothing happens until something explicitly grants the action. But a grant isn't the finish line, because the request still has to get past a run of gates. Hold it in your head like this: the caller is walking a corridor lined with locked doors, and to reach the room at the end, every door has to open. One door is the caller's own permissions. Another is a ceiling their team set that they can't rise above. Another is a ceiling the whole account can't rise above. If a single door stays shut, they don't get in.

There's also a guard in that corridor, and he has special authority. He can throw anyone out on sight, even with every door standing open. That guard is an explicit Deny, and he outranks everyone else in the building. A Deny written into any policy at all, the caller's own, the resource's, the account ceiling, or the identity cap, ends the decision on the spot. Nothing overrides it. That's what makes a Deny such a clean safety tool. You can hand out generous permissions and still guarantee that certain actions never happen.

Two more ideas make the rest click. Grants stack up. Attach five policies that each allow a little, and the caller ends up with the sum of all five. Ceilings work the other way, narrowing things down, so the caller's real power is only the overlap of what every ceiling permits. Add another ceiling and you can take permissions away, never add them. Two kinds of ceiling are worth naming now. An SCP, short for Service Control Policy, is a rule the organization above an account clamps onto the whole account. It grants nothing by itself. It only sets the maximum of what anyone in that account is even allowed to attempt. A permission boundary is the same idea pointed at a single user or role: a cap saying this identity can never do more than this, no matter which policies someone bolts on later.

Stop guessing. Make IAM show its work.

You don't have to run all of this in your head and hope you got it right. IAM ships a simulator that runs the real evaluation engine against a call you describe, then hands back the verdict along with the exact statement that decided it. It turns "I'm pretty sure this role is safe" into something you can check and paste into a review.

simulate the deployer doing its actual job
aws iam simulate-principal-policy \
--policy-source-arn arn:aws:iam::123456789012:role/ci-deployer \
--action-names s3:PutObject \
--resource-arns arn:aws:s3:::acme-artifacts/build-4711.tar.gz
{
"EvaluationResults": [
{
"EvalActionName": "s3:PutObject",
"EvalResourceName": "arn:aws:s3:::acme-artifacts/build-4711.tar.gz",
"EvalDecision": "allowed",
"MatchedStatements": [
{
"SourcePolicyId": "ci-deployer-permissions",
"SourcePolicyType": "IAM Policy",
"StartPosition": { "Line": 3, "Column": 17 },
"EndPosition": { "Line": 8, "Column": 6 }
}
],
"MissingContextValues": []
}
],
"IsTruncated": false
}

Read that output like a verdict sheet. EvalDecision is the ruling, and it comes in three flavours. allowed means a statement granted the call and nothing blocked it. explicitDeny means a Deny fired. implicitDeny means nobody said yes, so the default no held. MatchedStatements points straight at the statement responsible, down to the line and column in the policy document, which is gold when you're hunting for why something is permitted. And if MissingContextValues comes back with entries, the decision leaned on a condition you never supplied, like whether MFA (multi-factor authentication, the second login step beyond a password) was present. Feed that value in and the live answer could flip.

Where a boundary actually bites

Deployment roles are a favourite target for attackers, because they pile up permissions nobody ever prunes. Take a role called ci-deployer, the automated account your build pipeline uses to ship code. Its real job is small: push build artifacts to S3 (Amazon's file storage) and update a couple of database tables. Then, over time, someone attaches a policy far broader than that, including full IAM access. Hunting down every over-grant one by one is a losing game. Better to bolt a hard ceiling onto the role, so even a careless policy can't become account takeover. That ceiling is a permission boundary.

ci-deployer-boundary.json, then register it as a managed policy
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "DataPlaneWork",
"Effect": "Allow",
"Action": ["s3:*", "dynamodb:*", "logs:*", "cloudwatch:*", "ecr:*"],
"Resource": "*"
},
{
"Sid": "NeverTouchIdentityOrOrg",
"Effect": "Deny",
"Action": ["iam:*", "organizations:*", "account:*", "sso:*"],
"Resource": "*"
}
]
}
aws iam create-policy \
--policy-name ci-deployer-boundary \
--policy-document file://ci-deployer-boundary.json
{
"Policy": {
"PolicyName": "ci-deployer-boundary",
"PolicyId": "ANPA1EXAMPLEBOUNDARY7",
"Arn": "arn:aws:iam::123456789012:policy/ci-deployer-boundary",
"DefaultVersionId": "v1",
"AttachmentCount": 0,
"IsAttachable": true,
"CreateDate": "2026-07-16T09:14:52Z"
}
}

With the boundary registered, the simulator can prove the cap actually holds. Point it at the same role, ask about the escalation moves you're worried about, like minting new IAM users or attaching policies to them, and feed in the boundary as the ceiling to test against.

the same simulator, now with the boundary applied
aws iam simulate-principal-policy \
--policy-source-arn arn:aws:iam::123456789012:role/ci-deployer \
--action-names iam:CreateUser iam:AttachUserPolicy \
--resource-arns "arn:aws:iam::123456789012:user/*" \
--permissions-boundary-policy-input-list file://ci-deployer-boundary.json
{
"EvaluationResults": [
{
"EvalActionName": "iam:CreateUser",
"EvalResourceName": "arn:aws:iam::123456789012:user/*",
"EvalDecision": "explicitDeny",
"MatchedStatements": [
{
"SourcePolicyId": "PermissionsBoundaryPolicyInputList.1",
"SourcePolicyType": "IAM Policy",
"StartPosition": { "Line": 10, "Column": 5 },
"EndPosition": { "Line": 15, "Column": 6 }
}
],
"MissingContextValues": [],
"PermissionsBoundaryDecisionDetail": {
"AllowedByPermissionsBoundary": false
}
},
{
"EvalActionName": "iam:AttachUserPolicy",
"EvalResourceName": "arn:aws:iam::123456789012:user/*",
"EvalDecision": "explicitDeny",
"MatchedStatements": [
{
"SourcePolicyId": "PermissionsBoundaryPolicyInputList.1",
"SourcePolicyType": "IAM Policy",
"StartPosition": { "Line": 10, "Column": 5 },
"EndPosition": { "Line": 15, "Column": 6 }
}
],
"MissingContextValues": [],
"PermissionsBoundaryDecisionDetail": {
"AllowedByPermissionsBoundary": false
}
}
],
"IsTruncated": false
}

Notice what happened. The role's own policy allows every IAM action, and the call still came back explicitDeny, because the boundary's Deny fired first and AllowedByPermissionsBoundary came back false. That's the entire model in one call. All four policy types have to agree before a single action is allowed, and any one of them can veto the rest. A Deny is the loudest voice in the room; it ends the argument no matter who else said yes. The same logic scales up to a whole fleet of accounts. Put a boundary on every workload role, or an SCP across the organization, and a single over-broad grant in one account can't climb past the ceiling. One mistake stays one mistake instead of turning into ten. That's what people mean by shrinking the blast radius, the amount of damage a single slip can cause. The actions worth denying in that ceiling are the ones that let a principal rewrite its own permissions, and the sharpest of those is iam:PassRole, which is where most real escalation paths begin.

How AWS decides one API call
A caller makes an API request
s3:PutObject, iam:CreateUser, anything at all. The starting answer is always no.
explicit Deny anywhere?
Denied, full stop
Identity, resource, SCP, or boundary. One Deny ends it, no matter what else allows.
blocked by an SCP?
Denied
The account was never permitted to attempt it, so grants inside it don't matter.
outside the boundary?
Denied
The identity can't exceed its cap, even with a generous policy attached.
no identity/resource Allow?
Denied (implicit)
Nothing granted it, so the default no holds.
every door open?
Allowed
Only when no Deny fired and every ceiling and grant agreed.
Read top to bottom. Any single door can say no on its own. Only the last row, where nothing objected, gets a yes.
Quick check
01A CI deploy role's attached policy accidentally includes iam:* on everything. The role also carries a permission boundary that allows S3, DynamoDB and logs but explicitly denies iam:*. The role calls iam:CreateAccessKey to mint keys for another user. What does AWS do?
Incorrect — grants only stack against other grants. They can't overrule a ceiling or a Deny, so the boundary still applies here.
Correct — the boundary caps the identity no matter what its attached policy says, and an explicit Deny anywhere ends the decision. simulate-principal-policy returns explicitDeny with AllowedByPermissionsBoundary set to false.
Incorrect — a boundary limits the very identity it's attached to. Capping that identity is the entire purpose of it.
Incorrect — there's no built-in MFA requirement on iam:CreateAccessKey. The denial here comes purely from the boundary's Deny statement.
02An account already sits under one Service Control Policy (SCP), the organization-level ceiling that caps what anyone in the account may attempt. Your org attaches a second SCP to the same account. What can that second SCP do to the permissions available in the account?
Incorrect — additive union is how identity grants stack; SCPs are ceilings and combine by intersection, never union.
Incorrect — a new SCP does not supersede an existing one; every SCP in the chain applies at once and all of them must permit the action.
Correct — ceilings are conjunctive, so adding an SCP can only narrow the account to the intersection of what every SCP permits; it never widens it.
Incorrect — an SCP grants nothing on its own; it only caps the maximum an account may attempt, and an identity policy must still allow the action.
03You run aws iam simulate-principal-policy against a role for s3:DeleteBucket and it returns EvalDecision allowed. When the role actually makes that call in production, AWS denies it, and nothing changed between the two runs. What is the most likely explanation?
Incorrect — the simulator runs the real IAM evaluation engine; its explicitDeny is trustworthy and its allowed is meaningful, it just has specific blind spots.
Correct — simulate-principal-policy does not evaluate SCPs at all, so it can return allowed for a call that an organization SCP denies in the live account; confirm an allowed result against production.
Incorrect — the simulator does read the principal's attached identity policies, so a Deny written there would have surfaced as explicitDeny, not allowed.
Incorrect — implicitDeny and allowed are separate, clearly labelled outcomes; the engine does not silently mislabel one as the other.
The simulator can lie by omission
simulate-principal-policy runs the real IAM engine, but it has blind spots. It doesn't evaluate SCPs at all, and it won't consider a resource-based policy unless you pass it in yourself as a string. So it can cheerfully return allowed for a call that production actually denies, because an SCP two levels up in your organization blocks it. Trust it fully in one direction only: if it says explicitDeny, that Deny is real. Before you lean on an allowed result, confirm it against the live account.

Try this

Work through “Where a boundary actually bites” yourself on a sandbox you can throw away, following the commands above in order. Then break one step deliberately and re-run, so you have seen the failure before it finds you.

Takeaway

The trap worth remembering here: the simulator can lie by omission. Check that on your own systems before you need to, because it is cheaper to find on a quiet afternoon than during an incident.

Related