Exam Room · Security

Preventing Privilege Escalation With Permission Boundaries

January 12, 2028 · 33 min read

Cloud Security · part of The Exam Room

The situation

A platform team runs a multi-account AWS estate for a mid-size product organisation. Each product team has its own account, and every account already carries a small pipeline role that CI uses to deploy CDK stacks. Those stacks increasingly need IAM roles of their own: a role for a Lambda function, a role for an ECS task, an instance profile for a batch worker. Today, every one of those roles goes through a ticket to the platform team, and the queue is a week deep.

The platform team wants to delegate. Let each product team’s pipeline role create and manage IAM roles directly, in its own account, so a new Lambda role ships in the same deploy as the Lambda. That means granting iam:CreateRole, iam:PutRolePolicy, iam:AttachRolePolicy, and the delete counterparts to the pipeline role.

Here is the problem. A principal that can create a role and attach a policy to it can attach AdministratorAccess, then assume that role, and now it is an administrator. A principal that can create a role can create one that trusts an attacker’s account. iam:CreateRole plus iam:AttachRolePolicy, handed out plainly, is a privilege-escalation grant. The platform team needs product teams to manage their own roles without any of those roles, or the pipeline role itself, being able to climb to administrator.

What actually matters

Before reaching for a service, worth being precise about what a safe delegation actually requires, because the naive version fails in a way that is easy to miss in review.

The core issue is the ceiling on what a created role can do. When the pipeline role calls iam:CreateRole and iam:AttachRolePolicy AdministratorAccess, nothing about that sequence is individually suspicious. Each call is a legitimate IAM action the pipeline role was granted. The escalation lives in the composition, not in any single permission. So the control cannot be “deny attaching AdministratorAccess” as a one-off; there are dozens of managed policies that grant broad power, and a custom inline policy can grant the same thing without any recognisable name. The control has to cap the effective permissions of every role the pipeline creates, whatever policies get attached to it, down to a set the platform team is comfortable with. That is exactly what a permission boundary does: it is a managed policy attached to a principal that sets the maximum permissions the principal can ever have, and the principal’s effective permissions become the intersection of its identity policies and its boundary. Attach AdministratorAccess to a role whose boundary allows only S3 and DynamoDB, and the role can still only touch S3 and DynamoDB. The boundary wins.

That gives us the second requirement, and it is the one people miss. Capping the created roles is not enough on its own. The delegation policy has to force every created role to carry the boundary. If the pipeline role can create a role without a boundary, or with a boundary of its own choosing, the cap is optional and therefore worthless. So the grant of iam:CreateRole must be conditional: allowed only when the request attaches a specific, named boundary policy. IAM exposes this through the iam:PermissionsBoundary condition key, which matches the ARN of the boundary being set on the new role. No boundary, or the wrong boundary, and CreateRole is denied.

The third requirement is self-protection, and it is subtle. A boundary that the delegated principal can edit is a boundary the delegated principal can widen. If the pipeline role can call iam:CreatePolicyVersion on the boundary policy, or iam:DeleteRolePermissionsBoundary on a role it created, it can lift its own ceiling. The same goes for the delegation policy itself: if the pipeline role can edit the policy that constrains it, the constraint is advisory. So the delegation must deny the pipeline role any ability to modify the boundary policy, detach the boundary, or edit the delegation policy. The boundary and the rules that enforce it have to sit above the hand they are constraining.

Finally, blast radius and layering. A permission boundary is an account-level, per-principal control that the account’s own administrators manage. It does not defend against a compromised or careless account administrator, because whoever can attach the boundary can also change it. That is what Service Control Policies are for: an organisation-level ceiling, set in the management account, that no principal in the member account can alter, including the account’s root user. A design that survives a compromised administrator uses both. The SCP is the backstop the product account cannot touch; the permission boundary is the day-to-day delegation cap the platform team wires into the account. One without the other leaves a gap.

What we’ll filter on

  1. Caps effective permissions. The control limits what a principal can do to an intersection, regardless of which identity policies get attached to it later.
  2. Enforceable on creation. The mechanism can be required at role-creation time, so a delegated principal cannot create an uncapped role.
  3. Self-protecting. The delegated principal cannot edit, detach, or version the control that constrains it.
  4. Right scope. The control applies at the level the problem lives at: a single principal, an account, or the whole organisation.
  5. Auditable. Attaching, changing, or removing the control leaves a clear CloudTrail record.

The landscape

Four IAM instruments can cap what a principal is allowed to do. They look similar from a distance and behave very differently up close. Walking them in the order they bear on this problem.

1. Identity (permission) policies. The everyday grant: attach a policy to a role and it can do what the policy allows. On their own they are the problem here, not the solution. Grant iam:CreateRole and iam:AttachRolePolicy through an identity policy and you have granted escalation. Identity policies grant; they do not cap. Nothing about an identity policy stops the principal composing its grants into administrator.

2. Permission boundaries. A managed policy attached to a role or user in the special boundary slot, separate from its identity policies. It grants nothing. It only caps: the principal’s effective permissions are the intersection of what its identity policies allow and what the boundary allows. An action has to appear in both to be permitted. This is the instrument that lets you hand out iam:CreateRole safely, because you can require, via the iam:PermissionsBoundary condition key, that every role the delegated principal creates carries the same boundary. The delegated principal creates roles all day, and none of them, nor the principal itself, can exceed the boundary. Per-principal, account-managed.

3. Session policies. A policy passed inline at sts:AssumeRole or GetFederationToken time. Like a boundary, it caps rather than grants: the session’s effective permissions are the intersection of the role’s identity policies and the session policy. But it lives only for the life of the session and is chosen by whoever assumes the role. It is the right tool for scoping a single assumed session down, for instance a federation broker minting narrow sessions, and the wrong tool for a standing delegation, because it is not attached to the principal and cannot be required of roles the principal creates.

4. Service Control Policies. An Organizations control attached to an OU or account. It caps every principal in the account, including root, and no member-account principal can alter it. It grants nothing; it only sets the outer ceiling. An SCP that denies iam:* unless a boundary condition is met, or that denies attaching AdministratorAccess outright, is the organisation-level backstop under the per-account boundary. It is coarse, one policy for the whole account, but it is the only layer the product account cannot edit its way around.

For delegating role creation to a per-account pipeline role, the permission boundary is the control doing the real work: it is the only per-principal cap that can be required on created roles through a condition key. The SCP sits above it as the tamper-proof backstop. Session policies and plain identity policies do not fit this shape.

Evaluation

Side by side

Filtering the four instruments against the attributes:

Option Caps effective perms Enforceable on creation Self-protecting Scope
Identity policy n/a per-principal
Permission boundary ✓ (if self-edit denied) per-principal
Session policy per-session
Service Control Policy ✗ (denies, doesn’t require) account / OU

Identity policies grant and never cap, so they cannot contain an escalation on their own. Session policies cap but expire and are chosen by the assumer, so they cannot be required of roles a delegated principal creates. SCPs cap and are tamper-proof, but they deny rather than require, and they are account-wide rather than per-principal, so they make a superb backstop and a clumsy delegation tool. The permission boundary is the only instrument that caps a specific principal, can be forced onto every role that principal creates through the iam:PermissionsBoundary condition, and, once the principal is denied the right to edit it, cannot be widened from below. The pick is a permission boundary as the delegation cap, an SCP as the backstop, and a delegation policy carefully written so the pipeline role can create roles but not escape the boundary.

Effective permissions are the intersection

Identity policy allows everything the attached policies grant iam:CreateRole, iam:AttachRolePolicy AdministratorAccess (attached, but capped away) ec2:*, rds:* (granted, but not in boundary) Permission boundary allows the maximum, whatever the identity policy says Effective = intersection s3:* dynamodb:* logs:* in BOTH the identity policy and the boundary Service Control Policy: outer ceiling on the whole account no member-account principal can edit it, not even root denies iam:* unless a boundary is set; denies detaching the boundary the backstop the permission boundary rests on
The boundary does not grant. It caps. Effective permissions are only the actions the identity policy and the boundary both allow, with the SCP as the ceiling the account cannot lift.

The solution

Three pieces have to fit together: the boundary policy, the delegation policy that lets the pipeline role create roles safely, and the deny statements that stop the pipeline role from escaping.

The boundary policy. This is an ordinary customer-managed policy, distinguished only by where it gets attached. It describes the maximum any delegated role, and the pipeline role itself, may ever do. Make it as wide as the product teams legitimately need and no wider. If product roles touch S3, DynamoDB, SQS, and CloudWatch Logs, the boundary allows those and stops there. Because the boundary is an intersection, being generous costs you nothing you did not already grant through identity policies, but a hole in the boundary is a hole in the cap, so err tight. Give it a stable ARN and treat it as a governed artefact, because everything downstream references it by ARN.

The delegation policy, conditional on the boundary. The pipeline role’s identity policy grants the IAM management actions, but the grant of iam:CreateRole (and iam:PutRolePolicy, iam:AttachRolePolicy) is gated on the iam:PermissionsBoundary condition. That condition key matches the ARN of the boundary being attached in the request. Written as StringEquals against the boundary ARN, it means: you may create a role only if you set this boundary on it. Try to create a role with no boundary, and the condition is unmet, so the request is denied. Try to create one with a weaker boundary of your choosing, condition unmet, denied. The pipeline role ends up able to mint any number of roles, and every one of them carries the same ceiling it does. The iam:PolicyARN condition key does the parallel job for iam:AttachRolePolicy and iam:AttachUserPolicy, letting you constrain which managed policies may be attached, useful when you want to allow attaching from an approved list and nothing else.

The deny statements that make it self-protecting. This is the part that separates a real boundary from a decorative one. The pipeline role must be explicitly denied the ability to touch the machinery that constrains it. Deny iam:CreatePolicyVersion, iam:DeletePolicyVersion, and iam:SetDefaultPolicyVersion on the boundary policy ARN, so it cannot rewrite the boundary to say *. Deny iam:DeleteRolePermissionsBoundary and iam:PutRolePermissionsBoundary except where the put sets the correct boundary, so it cannot strip the boundary off a role it created or swap it. Deny iam:CreateRole without the boundary condition, which the conditional grant already handles, but an explicit deny makes it belt and braces against a future broad allow. And deny edits to the pipeline role’s own delegation policy, so it cannot loosen its own grant. An explicit Deny beats any Allow, so these hold even if a future identity policy grants something broad by accident.

Two mechanics that trip people up. First, a permission boundary caps permissions only; it has no effect on a role’s trust policy. A delegated principal that can create roles can still write a trust policy that lets an external account assume the new role. The boundary limits what that role can do once assumed, but if you also need to constrain who can assume created roles, that is a separate control, typically an SCP condition on iam:CreateRole or a review gate, not the boundary. Second, the boundary applies to the principal it is attached to, and resource-based policies are evaluated separately: a boundary on a role does not stop another account’s resource policy from granting that role access. The boundary caps what the principal’s own identity policies can achieve, which is exactly the escalation surface here, but it is not a universal firewall.

The escalation paths a boundary closes

It is worth naming the specific climbs this design shuts, because each one is a known IAM privilege-escalation vector.

Attach an admin policy to yourself or a role you control. Without a boundary, iam:AttachRolePolicy plus AdministratorAccess on any role you can assume is game over. With the boundary on both the pipeline role and every role it creates, attaching AdministratorAccess changes the paper permissions but not the effective ones; the intersection with the boundary still allows only S3, DynamoDB, and the rest. The attach succeeds and gains nothing.

Create a fresh role with admin and assume it. The pipeline role creates EscalationRole, attaches admin, sets a trust policy naming itself, assumes it. Every step is a granted action. The iam:PermissionsBoundary condition breaks the first step: CreateRole without the mandated boundary is denied, and with it the new role is capped exactly like its creator. There is no uncapped role to escape into.

Pass a privileged existing role to a new service. If a powerful role already exists, iam:PassRole lets a principal hand it to a Lambda or an EC2 instance and act through it. The boundary does not police PassRole directly, so this one is closed by scoping the pipeline role’s iam:PassRole grant with a Condition on iam:PassedToService and a resource constraint to roles that themselves carry the boundary, plus the SCP backstop. Worth calling out because a boundary alone does not close the PassRole path; the delegation policy has to.

Edit your own boundary. The whole scheme rests on the delegated principal being unable to widen its ceiling. The explicit denies on CreatePolicyVersion and PutRolePermissionsBoundary close it, and the SCP closes it again at the account level so that even an account administrator cannot quietly lift the boundary without tripping an organisation control.

Worked example

The platform team wants the pipeline-deployer role in each product account to create and manage IAM roles for that account’s workloads, capped so nothing it creates, and nothing it does, can exceed a fixed set of services.

First, the boundary policy, product-workload-boundary, attached to pipeline-deployer and required on every role it creates. It allows the services product workloads legitimately use:

{
  "Version": "2012-10-17",
  "Statement": [{
    "Sid": "WorkloadCeiling",
    "Effect": "Allow",
    "Action": [
      "s3:*", "dynamodb:*", "sqs:*", "sns:*",
      "logs:*", "cloudwatch:*", "lambda:InvokeFunction"
    ],
    "Resource": "*"
  }]
}

Then the delegation policy on pipeline-deployer. It grants IAM management, gated on the boundary, and denies the escape routes:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "CreateRolesOnlyWithBoundary",
      "Effect": "Allow",
      "Action": ["iam:CreateRole", "iam:PutRolePolicy", "iam:AttachRolePolicy"],
      "Resource": "arn:aws:iam::*:role/product/*",
      "Condition": {
        "StringEquals": {
          "iam:PermissionsBoundary":
            "arn:aws:iam::123456789012:policy/product-workload-boundary"
        }
      }
    },
    {
      "Sid": "ManageAndDeleteProductRoles",
      "Effect": "Allow",
      "Action": [
        "iam:DeleteRole", "iam:DeleteRolePolicy",
        "iam:DetachRolePolicy", "iam:TagRole", "iam:GetRole",
        "iam:UpdateAssumeRolePolicy"
      ],
      "Resource": "arn:aws:iam::*:role/product/*"
    },
    {
      "Sid": "DenyBoundaryTampering",
      "Effect": "Deny",
      "Action": [
        "iam:CreatePolicyVersion", "iam:DeletePolicyVersion",
        "iam:SetDefaultPolicyVersion", "iam:DeletePolicy"
      ],
      "Resource": "arn:aws:iam::123456789012:policy/product-workload-boundary"
    },
    {
      "Sid": "DenyRemovingBoundaryFromRoles",
      "Effect": "Deny",
      "Action": ["iam:DeleteRolePermissionsBoundary"],
      "Resource": "*"
    },
    {
      "Sid": "PassOnlyBoundariedProductRoles",
      "Effect": "Allow",
      "Action": "iam:PassRole",
      "Resource": "arn:aws:iam::*:role/product/*",
      "Condition": {
        "StringEquals": {
          "iam:PassedToService": [
            "lambda.amazonaws.com",
            "ecs-tasks.amazonaws.com",
            "ec2.amazonaws.com"
          ]
        }
      }
    },
    {
      "Sid": "DenyPassingRolesOutsideTheProductPath",
      "Effect": "Deny",
      "Action": "iam:PassRole",
      "NotResource": "arn:aws:iam::*:role/product/*"
    }
  ]
}

The product/ path prefix on the resource ARNs keeps the delegation to a namespace: pipeline-deployer manages roles under role/product/, and cannot touch the platform team’s own roles elsewhere in the account. The first statement is the one that matters; without its condition, everything else is theatre.

Finally, the SCP on the OU that holds every product account, so the account cannot edit its way out even with a compromised administrator:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "RequireBoundaryOnCreate",
      "Effect": "Deny",
      "Action": ["iam:CreateRole", "iam:CreateUser"],
      "Resource": "*",
      "Condition": {
        "StringNotEquals": {
          "iam:PermissionsBoundary":
            "arn:aws:iam::123456789012:policy/product-workload-boundary"
        }
      }
    },
    {
      "Sid": "ProtectTheBoundaryPolicy",
      "Effect": "Deny",
      "Action": [
        "iam:CreatePolicyVersion", "iam:DeletePolicy",
        "iam:SetDefaultPolicyVersion"
      ],
      "Resource": "arn:aws:iam::*:policy/product-workload-boundary"
    }
  ]
}

Now the boundary is required at two layers: the delegation policy requires it for pipeline-deployer, and the SCP requires it for anyone in the account, so a second principal that somehow gained iam:CreateRole still cannot mint an uncapped role. The SCP also protects the boundary policy from edits at the account level, closing the “just rewrite the boundary” path even for an account administrator. The delegation is safe: product teams ship their own roles in their own deploys, and no path leads from iam:CreateRole to administrator.

What’s worth remembering

  1. A permission boundary grants nothing; it caps. A principal’s effective permissions are the intersection of its identity policies and its boundary, so an action must appear in both to be allowed.
  2. To delegate role creation safely, gate iam:CreateRole on the iam:PermissionsBoundary condition key so the delegated principal can only create roles that carry the mandated boundary.
  3. A boundary is only safe if the delegated principal cannot edit it: deny iam:CreatePolicyVersion and friends on the boundary policy, and deny iam:DeleteRolePermissionsBoundary on roles, or the ceiling can be lifted from below.
  4. A permission boundary is per-principal and account-managed; a Service Control Policy is account- or OU-wide, tamper-proof from inside the account, and denies rather than requires. Use the boundary as the delegation cap and the SCP as the backstop.
  5. A boundary caps permissions, not trust policies, and does not police iam:PassRole on its own; scope PassRole with a condition and lean on the SCP to close the pass-a-privileged-role path.

These posts are LLM-aided. Backbone, original writing, and structure by Craig. Research and editing by Craig + LLM. Proof-reading by Craig.