CoursesAWS security for DevOps engineersPermission boundaries in practice

Permission boundaries in practice

Delegate admin without handing over the kingdom.

Intermediate25 min · lesson 4 of 13

Your new office manager needs to buy chairs, laptops and coffee without asking you to sign off on every receipt, so you hand over a company card. The bank has stamped a hard limit on that card: nothing above 2,000, and no cash withdrawals, ever. The card buys nothing by itself. It sits in a wallet doing nothing at all until somebody spends with it. What it does is refuse. Write yourself a memo saying 'unlimited spending approved' and the terminal still declines at 2,001.

A permissions boundary works the same way. It is a managed policy (a reusable JSON document, short for JavaScript Object Notation, the plain-text format AWS writes permissions in) that you attach to exactly one IAM user or one IAM role. IAM stands for Identity and Access Management, the service that decides whether a given identity may make a given API call (application programming interface, the machine-to-machine way software asks AWS to do something). The boundary hands out nothing at all. It caps what that identity's other policies are able to hand it. Attach AdministratorAccess to a role whose boundary forbids IAM writes, and the role still cannot create a user.

The problem this solves shows up the moment a platform team gets popular. Twelve product teams each need roles for their Lambda functions, their build jobs and their containers, and every one of those roles lands in your ticket queue. You want to give role creation away. You cannot, because the first move available to anyone holding iam:CreateRole is to create a role carrying AdministratorAccess and assume it. Giving away role creation is giving away the account. A boundary is what makes the hand-off survivable: teams create whatever roles they need, provided every one of those roles carries the cap you wrote.

The Intersection and the Hole in It

The headline rule is one line. For an identity that has a boundary, an action goes through only if the identity's own policies allow it and the boundary allows it. The overlap of two sets, nothing more. Anything the boundary never mentions is refused by silence, which AWS calls an implicit deny. Anything a policy refuses on purpose is an explicit deny, and an explicit deny beats every allow, no matter which layer wrote it.

The hole sits in the word identity. A boundary caps identity-based policies, the ones stapled to your user or role. Its grip on resource-based policies is weaker. Resource-based policies are the ones stapled to the thing being touched: an S3 bucket policy (Simple Storage Service, AWS object storage), a KMS key policy (Key Management Service, where encryption keys live), a Secrets Manager secret policy. AWS documents three separate cases here, and they do not behave alike.

If a resource policy names a role's ARN (Amazon Resource Name, the unique address of any AWS thing), the boundary's implicit deny still caps it. Ordinary behaviour. If the resource policy names the role session ARN instead, something shaped like arn:aws:sts::111122223333:assumed-role/checkout-api/build-4471, the grant lands on that session directly, and an implicit deny in the boundary does not stop it. Same story for a resource policy naming an IAM user's ARN. So a boundary that lists S3, DynamoDB and CloudWatch Logs while staying quiet about Secrets Manager will not stop a role from reading a secret whose policy names its session. AWS spells out that exact scenario in its own documentation, so it is not folklore.

An explicit Deny statement does stop it, every time. That single fact should change the shape of the boundaries you write. Allow the ceiling you want, then Deny by name the handful of things you genuinely care about, and never treat silence as protection.

Which gate actually blocks the call
1One principal, one action, one resource
e.g. the checkout-api role session calling iam:CreateUser
2Explicit Deny anywhere?
SCP, RCP, resource policy, identity policy, boundary or session policy. One Deny ends it with no appeal
3Do the organization policies allow it?
Service control policies and resource control policies cap the account from outside; a boundary lives inside and cannot loosen them
4Does a resource policy name this exact session or user?
A grant straight to a role session ARN or an IAM user ARN skips the boundary's implicit deny
5Does the boundary allow it?
Otherwise you get 'because no permissions boundary allows the action'
6Does a session policy allow it?
Only in play if somebody passed one when the role was assumed
7Does an identity policy allow it?
Boundary and identity policy must both say yes; the overlap is what the role can really do
8Call runs
Every gate said yes
A boundary is one gate among several. It caps identity policies, and it sits below the organization-level ceiling.

Two Policies, Not One

A working delegation needs two boundary policies, and confusing them is the usual reason these setups fail an audit. The first caps the app roles that teams create. The second caps the thing doing the creating, whether that is a human platform engineer or, more often, a pipeline role in your infrastructure repository. Both belong in Git with security listed as a code owner, because a change to either one moves the ceiling of everything underneath it.

app-team-boundary.json
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "DataPlaneCeiling",
"Effect": "Allow",
"Action": [
"s3:*",
"sqs:*",
"dynamodb:*",
"logs:*",
"kms:Decrypt",
"kms:GenerateDataKey",
"cloudwatch:PutMetricData",
"rds:Describe*"
],
"Resource": "*"
},
{
"Sid": "NoIdentityWrites",
"Effect": "Deny",
"Action": [
"iam:*",
"organizations:*",
"account:*",
"sso:*"
],
"Resource": "*"
},
{
"Sid": "HandsOffProdKeys",
"Effect": "Deny",
"Action": "kms:*",
"Resource": "arn:aws:kms:eu-west-1:111122223333:key/9d2f7a41-6c33-4b8e-a1f0-3e5b7c9d2a11"
},
{
"Sid": "NoBlindingTheAuditors",
"Effect": "Deny",
"Action": [
"cloudtrail:StopLogging",
"cloudtrail:DeleteTrail",
"cloudtrail:PutEventSelectors",
"guardduty:DeleteDetector",
"guardduty:UpdateDetector",
"config:StopConfigurationRecorder"
],
"Resource": "*"
}
]
}

DataPlaneCeiling sets the maximum reach: storage, queues, tables, logs, one metrics call, and read-only visibility into RDS (Relational Database Service, AWS-managed databases). A team role can never climb above this line, whatever policy someone later attaches to it. Leaving KMS out of that Allow list is a mistake people make exactly once: reading an object from a bucket encrypted with a customer managed key needs kms:Decrypt as well as s3:GetObject, and a boundary that never mentions KMS caps the decrypt no matter how good the role's own policy looks.

The three Deny statements are the part that survives a friendly resource policy. NoIdentityWrites means a compromised app role cannot mint itself a second identity. HandsOffProdKeys takes one production key back out of the ceiling by ARN, and it is doing work the Allow list cannot do on its own: an explicit Deny also beats a key policy that names this role's session directly, where an implicit deny would have folded. NoBlindingTheAuditors covers the calls an attacker reaches for early, including cloudtrail:PutEventSelectors, which can strip a trail down until it records nothing while the console still shows it switched on. Notice what is missing: Secrets Manager appears nowhere in this document. That silence caps nothing if a secret owner writes the role session into their resource policy, which is why a boundary protecting anything valuable eventually grows an explicit Deny for it.

delegated-role-admin-boundary.json
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "CreateOnlyWithBoundary",
"Effect": "Allow",
"Action": [
"iam:CreateRole",
"iam:PutRolePolicy",
"iam:DeleteRolePolicy",
"iam:AttachRolePolicy",
"iam:DetachRolePolicy",
"iam:PutRolePermissionsBoundary"
],
"Resource": "arn:aws:iam::111122223333:role/app/*",
"Condition": {
"StringEquals": {
"iam:PermissionsBoundary":
"arn:aws:iam::111122223333:policy/AppTeamBoundary"
}
}
},
{
"Sid": "ReadIAMAndSimulate",
"Effect": "Allow",
"Action": ["iam:Get*", "iam:List*", "iam:SimulatePrincipalPolicy"],
"Resource": "*"
},
{
"Sid": "TouchAppRolesOnly",
"Effect": "Allow",
"Action": [
"iam:DeleteRole",
"iam:UpdateAssumeRolePolicy",
"iam:TagRole",
"iam:UntagRole"
],
"Resource": "arn:aws:iam::111122223333:role/app/*"
},
{
"Sid": "NeverEditTheBoundaryPolicies",
"Effect": "Deny",
"Action": [
"iam:CreatePolicyVersion",
"iam:DeletePolicy",
"iam:DeletePolicyVersion",
"iam:SetDefaultPolicyVersion"
],
"Resource": [
"arn:aws:iam::111122223333:policy/AppTeamBoundary",
"arn:aws:iam::111122223333:policy/DelegatedRoleAdminBoundary"
]
},
{
"Sid": "NeverRemoveABoundary",
"Effect": "Deny",
"Action": [
"iam:DeleteRolePermissionsBoundary",
"iam:DeleteUserPermissionsBoundary"
],
"Resource": "*"
},
{
"Sid": "NoPassRoleOutsideAppPath",
"Effect": "Deny",
"Action": "iam:PassRole",
"NotResource": "arn:aws:iam::111122223333:role/app/*"
}
]
}

CreateOnlyWithBoundary is the whole trick. iam:PermissionsBoundary is a condition key that IAM fills in with the boundary ARN the request itself is trying to attach. The Allow matches only when that value equals AppTeamBoundary exactly, so a CreateRole call carrying no boundary, or a boundary someone invented on the spot, matches nothing and dies. The Resource element pins new roles into the /app/ path, which keeps the blast radius mapped to a name pattern you can audit later.

Two of these statements exist because of specific escalation paths. iam:UpdateAssumeRolePolicy edits a role's trust policy, the document naming who may assume it, so an unscoped grant of that action lets a delegated admin write themselves into the trust policy of your incident-response role and step straight into it. Pinning it to /app/* closes that door. iam:PassRole is the other one: handing an existing role to a service such as Lambda or CodeBuild runs code as that role. Deny PassRole outside /app/* and the delegated admin can only pass roles that already carry the cap.

The two Deny statements near the end defend the mechanism itself. Without them, a delegated admin edits AppTeamBoundary to allow iam:* and the cap evaporates on the next role they create, or calls DeleteRolePermissionsBoundary and lifts it off an existing role outright. A boundary that does not defend itself is decoration.

NotPrincipal plus Deny silently locks out every bounded identity
AWS is blunt about this one in its own documentation. A resource-based policy statement that combines a NotPrincipal element with "Effect": "Deny" will deny any IAM principal that has a permissions boundary attached, whatever names you listed in NotPrincipal. Your bucket policy says 'deny everyone except the checkout-api role' and the checkout-api role loses access anyway, because it carries a boundary. The symptom is baffling, since the same policy behaves perfectly on unbounded roles. AWS's recommended rewrite is to drop NotPrincipal and use an ArnNotEquals condition on the aws:PrincipalArn context key instead.

Attach It and Prove the Cap Holds

terminal
# Security owns both boundary policies
aws iam create-policy --policy-name AppTeamBoundary \
--policy-document file://app-team-boundary.json \
--query 'Policy.Arn' --output text
aws iam create-policy --policy-name DelegatedRoleAdminBoundary \
--policy-document file://delegated-role-admin-boundary.json \
--query 'Policy.Arn' --output text
# Boundary goes on at creation time, in the same call
aws iam create-role --role-name checkout-api --path /app/ \
--assume-role-policy-document file://trust.json \
--permissions-boundary arn:aws:iam::111122223333:policy/AppTeamBoundary \
--query 'Role.Arn' --output text
# Read it back from the authoritative place
aws iam get-role --role-name checkout-api --query 'Role.PermissionsBoundary'
output
arn:aws:iam::111122223333:policy/AppTeamBoundary
arn:aws:iam::111122223333:policy/DelegatedRoleAdminBoundary
arn:aws:iam::111122223333:role/app/checkout-api
{
"PermissionsBoundaryType": "Policy",
"PermissionsBoundaryArn": "arn:aws:iam::111122223333:policy/AppTeamBoundary"
}

Now run the negative test, the one that matters. Assume the pipeline role that carries DelegatedRoleAdminBoundary and try to create a role the way a careless template would, with no boundary at all.

terminal
# Running as arn:aws:iam::111122223333:role/platform-role-provisioner
aws iam create-role --role-name search-indexer --path /app/ \
--assume-role-policy-document file://trust.json
# Same call, boundary supplied
aws iam create-role --role-name search-indexer --path /app/ \
--assume-role-policy-document file://trust.json \
--permissions-boundary arn:aws:iam::111122223333:policy/AppTeamBoundary \
--query 'Role.Arn' --output text
output
An error occurred (AccessDenied) when calling the CreateRole operation: User: arn:aws:sts::111122223333:assumed-role/platform-role-provisioner/tf-apply is not authorized to perform: iam:CreateRole on resource: arn:aws:iam::111122223333:role/app/search-indexer because no permissions boundary allows the iam:CreateRole action
arn:aws:iam::111122223333:role/app/search-indexer

Read the tail of that message closely, because AWS names the layer that said no. because no permissions boundary allows the ... action means the boundary held no matching Allow, an implicit deny. Here an Allow did exist, but its condition failed, and a condition that fails leaves you with no Allow at all. Swap that phrase for with an explicit deny in a permissions boundary and you are looking at a Deny statement somebody wrote on purpose. The other layers fill the same slot with service control policy, resource control policy, identity-based policy, session policy, resource-based policy or VPC endpoint policy. One caveat worth carrying: if several layers denied the same request, AWS names only one of them.

terminal
# Now as the app role itself, which has AdministratorAccess attached
aws sts get-caller-identity --query Arn --output text
aws iam create-user --user-name backdoor
aws s3 ls s3://checkout-prod-data/orders/
output
arn:aws:sts::111122223333:assumed-role/checkout-api/build-4471
An error occurred (AccessDenied) when calling the CreateUser operation: User: arn:aws:sts::111122223333:assumed-role/checkout-api/build-4471 is not authorized to perform: iam:CreateUser on resource: arn:aws:iam::111122223333:user/backdoor with an explicit deny in a permissions boundary: arn:aws:iam::111122223333:policy/AppTeamBoundary
2026-07-21 14:03:55 18422 orders/2026-07-21.json

Full administrator rights on the identity, and the IAM call still dies while the S3 read sails through. That gap between the two lines is the control working. AWS appended the offending policy ARN this time, which ends your search early. Do not count on it: plenty of services return the shorter form with the policy type and no ARN.

Simulate Before You Ship

You want this answer in a pull request, not at three in the morning. simulate-principal-policy evaluates a real principal's attached policies plus its attached boundary and reports a decision without making the call.

terminal
# One query reused so the two results line up
Q='EvaluationResults[].{action:EvalActionName,decision:EvalDecision,boundaryAllows:PermissionsBoundaryDecisionDetail.AllowedByPermissionsBoundary}'
aws iam simulate-principal-policy \
--policy-source-arn arn:aws:iam::111122223333:role/app/checkout-api \
--action-names iam:CreateUser --query "$Q"
aws iam simulate-principal-policy \
--policy-source-arn arn:aws:iam::111122223333:role/app/checkout-api \
--action-names s3:GetObject \
--resource-arns arn:aws:s3:::checkout-prod-data/orders/2026-07-21.json \
--query "$Q"
output
[
{
"action": "iam:CreateUser",
"decision": "explicitDeny",
"boundaryAllows": false
}
]
[
{
"action": "s3:GetObject",
"decision": "allowed",
"boundaryAllows": true
}
]

boundaryAllows is the field worth asserting on in continuous integration, because it separates 'the boundary blocked this' from 'the identity policy never granted it'. Learn the simulator's blind spots before you trust a green run, because AWS lists them plainly. It cannot test service control policies that carry any Condition, and most useful ones do. It does not simulate resource control policies at all. It does not simulate resource-based policies for IAM roles, only for users. It does not cover cross-account access for roles or users. A green allowed is a statement about IAM inside one account, not a promise about production.

Audit What Is Actually Attached

terminal
for r in $(aws iam list-roles --path-prefix /app/ \
--query 'Roles[].RoleName' --output text); do
printf '%-20s %s\n' "$r" "$(aws iam get-role --role-name "$r" \
--query 'Role.PermissionsBoundary.PermissionsBoundaryArn' --output text)"
done
output
checkout-api arn:aws:iam::111122223333:policy/AppTeamBoundary
search-indexer arn:aws:iam::111122223333:policy/AppTeamBoundary
legacy-batch None

That loop looks wasteful, calling get-role once per role, and there is a reason it has to. list-roles does not return the PermissionsBoundary field. AWS says so in the API reference: its resource-listing operations return a subset of attributes, and for roles the three it drops are PermissionsBoundary, RoleLastUsed and Tags. Filter a list-roles result on a boundary field and you are filtering on a key that is never present, so the audit passes on an empty set and reports nothing wrong. legacy-batch above is the finding: a role created before the boundary rule existed, sitting there uncapped.

Watch the Boundary Itself

iam-boundary-tamper.json
{
"source": ["aws.iam"],
"detail-type": ["AWS API Call via CloudTrail"],
"detail": {
"eventSource": ["iam.amazonaws.com"],
"eventName": [
"PutRolePermissionsBoundary",
"DeleteRolePermissionsBoundary",
"PutUserPermissionsBoundary",
"DeleteUserPermissionsBoundary",
"CreatePolicyVersion",
"SetDefaultPolicyVersion"
]
}
}
terminal
# IAM is a global service, so its CloudTrail events land in us-east-1 only
aws events put-rule --region us-east-1 \
--name iam-boundary-tamper \
--event-pattern file://iam-boundary-tamper.json \
--query RuleArn --output text
output
arn:aws:events:us-east-1:111122223333:rule/iam-boundary-tamper

Put that rule in us-east-1 or it will never fire, because IAM writes its CloudTrail events to that region regardless of where you work. A second condition catches people out: events with a detail type of AWS API Call via CloudTrail only reach EventBridge if a trail is actually logging management events in the account, so check that before you declare the alarm live. CreatePolicyVersion and SetDefaultPolicyVersion are in the list for a reason. Quietly publishing a wider version of the boundary policy and making it the default is a far softer way to break the cap than detaching it, and it leaves the attachment looking correct on every role.

The same check belongs in the pipeline that changes the policy. IAM Access Analyzer will compare a proposed boundary against the committed one and tell you whether the edit widens the ceiling, which is the exact question a reviewer skimming forty lines of JSON tends to get wrong.

terminal
aws accessanalyzer check-no-new-access \
--existing-policy-document file://app-team-boundary.json \
--new-policy-document file://app-team-boundary-proposed.json \
--policy-type IDENTITY_POLICY
output
{
"result": "FAIL",
"message": "The modified permissions grant new access compared to your existing policy.",
"reasons": [
{
"description": "New access in the statement with index: 0",
"statementIndex": 0,
"statementId": "DataPlaneCeiling"
}
]
}

Fail the build on FAIL and a widened boundary needs a human decision rather than a rubber stamp. Two practical notes. Custom policy checks like this one are billed per call, unlike validate-policy, which costs nothing and catches syntax and grammar problems, so run the free one on every commit and the paid one on boundary files. And this command compares two documents you hand it, which only helps if the committed file really is what is attached in the account. Reconcile the two with aws iam get-policy-version on a schedule.

What This Costs You

Boundaries buy safety with support load, and pretending otherwise leads to teams routing around them. An engineer now has three or four places to look when a call fails: the identity policy, the boundary, the service control policy above the account, and possibly a session policy from however they signed in. The error message names the layer, which helps, though it names only one layer even when several denied the request. Keep the boundary short, give every statement a Sid so it shows up by name in Access Analyzer output and in your own reviews, and write down the layer that blocked in the ticket.

The mechanism has hard edges too. One entity gets one boundary, never two, so you cannot stack a general cap and a team-specific cap. Groups cannot carry a boundary at all, only users and roles. There is no way to put one on the account root user, which is one of several reasons the root user should hold no access keys. And the document obeys the managed policy size limit of 6,144 characters, whitespace not counted, so a boundary covering a large estate is compressed by design, and the wildcards you compress it with are exactly where the leaks come from. A boundary allowing s3:* on "Resource": "*" still lets a compromised role walk every bucket in the account.

Plan the escape hatch before you need it. Someone will be stuck at midnight with a legitimate change the boundary forbids, and if the only path forward is a shadow admin role nobody documented, you have traded one risk for a worse one. Name a break-glass role that carries no boundary or a wider one, put it behind hardware multi-factor authentication (a second physical factor you hold, on top of a password), alarm every assumption of it, and review those alarms weekly. Keep the boundary's denials aligned with your service control policy denials as well, so the same forbidden action fails the same way for everybody instead of failing for one team and not another.

Try This

Run this in a lab account. Take a role that already has AdministratorAccess, staple an AWS managed read-only policy on as its boundary, and watch the cap bite.

terminal
aws iam put-role-permissions-boundary --role-name lab-admin \
--permissions-boundary arn:aws:iam::aws:policy/AmazonS3ReadOnlyAccess
# assume lab-admin, then:
aws iam create-user --user-name x
aws s3 ls
# put it back
aws iam delete-role-permissions-boundary --role-name lab-admin
output
An error occurred (AccessDenied) when calling the CreateUser operation: User: arn:aws:sts::111122223333:assumed-role/lab-admin/cli-session is not authorized to perform: iam:CreateUser on resource: arn:aws:iam::111122223333:user/x because no permissions boundary allows the iam:CreateUser action
2026-03-04 11:22:19 checkout-prod-data
2026-05-19 08:41:07 platform-tfstate

Note the flag name while you are there. The parameter is --permissions-boundary, on both create-role and put-role-permissions-boundary, and it takes the policy ARN. There is no --permissions-boundary-arn, and reaching for it is a common way to lose ten minutes to an unknown-argument error.

Next: AWS Organizations and service control policies, the ceiling that sits above the account and that nobody inside it can vote away.

Quick check
01A role has AdministratorAccess attached and a permissions boundary that allows only s3:* and logs:*. What can the role do?
Incorrect — the boundary is the ceiling, and no attached policy can raise it.
Correct — both layers have to allow an action, so the result is the intersection, and other layers such as SCPs can still narrow it further.
Incorrect — overlapping allows do not cancel each other; S3 and Logs are allowed by both layers and go through.
Incorrect — a boundary is a cap on what is permitted, not a list of exclusions.
02Your app role's boundary allows S3, DynamoDB and Logs, and says nothing at all about Secrets Manager. Another team attaches a resource policy to a secret that names the role's session ARN as principal and allows secretsmanager:GetSecretValue. Can the role read the secret?
Incorrect — an implicit deny in a boundary does not limit a resource policy that grants straight to a session.
Incorrect — that holds when the policy names the role ARN, but not when it names the role session ARN or an IAM user ARN.
Correct — silence in a boundary is not protection against resource policies; only an explicit Deny is.
Incorrect — cross-account access needs both sides to allow it, and this behaviour is the same-account case AWS documents.
03Your pipeline role runs aws iam create-role and gets: ... is not authorized to perform: iam:CreateRole on resource: arn:aws:iam::111122223333:role/app/search-indexer because no permissions boundary allows the iam:CreateRole action. What is the right fix?
Correct — the condition failed, so no Allow matched inside the boundary, and supplying the required boundary makes it match.
Incorrect — a missing identity grant reads because no identity-based policy allows ..., and this message names the boundary instead.
Incorrect — an SCP block puts service control policy in that slot, not permissions boundary.
Incorrect — removing the cap on the provisioner is exactly the escalation path this design exists to close.

Takeaway

The trap worth remembering here: notPrincipal plus Deny silently locks out every bounded identity. 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