Permission boundaries in practice
Delegate admin without handing over the kingdom.
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.
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.
{"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.
{"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 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
# Security owns both boundary policiesaws iam create-policy --policy-name AppTeamBoundary \--policy-document file://app-team-boundary.json \--query 'Policy.Arn' --output textaws 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 callaws 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 placeaws iam get-role --role-name checkout-api --query 'Role.PermissionsBoundary'
arn:aws:iam::111122223333:policy/AppTeamBoundaryarn:aws:iam::111122223333:policy/DelegatedRoleAdminBoundaryarn: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.
# Running as arn:aws:iam::111122223333:role/platform-role-provisioneraws iam create-role --role-name search-indexer --path /app/ \--assume-role-policy-document file://trust.json# Same call, boundary suppliedaws 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
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 actionarn: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.
# Now as the app role itself, which has AdministratorAccess attachedaws sts get-caller-identity --query Arn --output textaws iam create-user --user-name backdooraws s3 ls s3://checkout-prod-data/orders/
arn:aws:sts::111122223333:assumed-role/checkout-api/build-4471An 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/AppTeamBoundary2026-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.
# One query reused so the two results line upQ='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"
[{"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
for r in $(aws iam list-roles --path-prefix /app/ \--query 'Roles[].RoleName' --output text); doprintf '%-20s %s\n' "$r" "$(aws iam get-role --role-name "$r" \--query 'Role.PermissionsBoundary.PermissionsBoundaryArn' --output text)"done
checkout-api arn:aws:iam::111122223333:policy/AppTeamBoundarysearch-indexer arn:aws:iam::111122223333:policy/AppTeamBoundarylegacy-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
{"source": ["aws.iam"],"detail-type": ["AWS API Call via CloudTrail"],"detail": {"eventSource": ["iam.amazonaws.com"],"eventName": ["PutRolePermissionsBoundary","DeleteRolePermissionsBoundary","PutUserPermissionsBoundary","DeleteUserPermissionsBoundary","CreatePolicyVersion","SetDefaultPolicyVersion"]}}
# IAM is a global service, so its CloudTrail events land in us-east-1 onlyaws events put-rule --region us-east-1 \--name iam-boundary-tamper \--event-pattern file://iam-boundary-tamper.json \--query RuleArn --output text
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.
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
{"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.
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 xaws s3 ls# put it backaws iam delete-role-permissions-boundary --role-name lab-admin
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 action2026-03-04 11:22:19 checkout-prod-data2026-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.
s3:* and logs:*. What can the role do?secretsmanager:GetSecretValue. Can the role read the secret?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?because no identity-based policy allows ..., and this message names the boundary instead.service control policy in that slot, not permissions boundary.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.