Delegate IAM admin safely with AWS permission boundaries
This guide shows how to let developers create and manage IAM roles and users without letting them grant themselves admin. You will create a permission boundary policy, attach it to a delegated-admin role, and enforce that any IAM principals they create must carry that same boundary.
TL;DR — If you want someone to administer IAM without creating privilege-escalation paths, give them IAM permissions only through a role that is itself constrained, and require a permission boundary on every role/user they create. The key fix when this fails is usually missing one of the enforcement conditions:
iam:PermissionsBoundaryonCreateRole/CreateUser, oriam:PassedToService/restrictediam:PassRole. Reading time: ~5 min
Goal
When you finish, a delegated administrator can create and manage IAM roles and users for approved use cases, but any principal they create is capped by a permission boundary and cannot become full admin, even if they attach broader identity policies.
Prerequisites
- An AWS account and credentials with permission to create IAM policies and roles at the account level.
- AWS CLI v2 installed — check with:
aws --version
- A shell with
jqinstalled for readable output — check with:
jq --version
- Your AWS account ID — get it with:
aws sts get-caller-identity --query Account --output text
- A target delegated admin principal name to create in this guide:
DelegatedIamAdmin. - A region configured for CLI calls, even though IAM is global — check with:
aws configure list
Steps
Step 1: Create the permission boundary policy
Create a customer-managed policy that defines the maximum permissions any delegated admin-created role or user may ever have.
cat > boundary-policy.json <<'JSON'
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "AllowReadOnlyAndLimitedAppActions",
"Effect": "Allow",
"Action": [
"ec2:Describe*",
"s3:Get*",
"s3:List*",
"logs:Describe*",
"logs:Get*",
"logs:List*",
"cloudwatch:Get*",
"cloudwatch:List*",
"cloudwatch:Describe*"
],
"Resource": "*"
},
{
"Sid": "DenyPrivilegeEscalationServices",
"Effect": "Deny",
"Action": [
"iam:*",
"organizations:*",
"account:*",
"sts:AssumeRole"
],
"Resource": "*"
}
]
}
JSON
aws iam create-policy \
--policy-name DelegatedAdminBoundary \
--policy-document file://boundary-policy.json
You should see JSON containing an ARN like arn:aws:iam::123456789012:policy/DelegatedAdminBoundary.
Step 2: Create the delegated admin permissions policy
This policy lets the delegated admin manage IAM, but only if every created user/role has the boundary attached, and only for names under a controlled path/prefix.
ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text)
BOUNDARY_ARN="arn:aws:iam::${ACCOUNT_ID}:policy/DelegatedAdminBoundary"
cat > delegated-admin-policy.json <<JSON
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "AllowCreateDeleteRolesWithBoundary",
"Effect": "Allow",
"Action": [
"iam:CreateRole",
"iam:DeleteRole",
"iam:UpdateAssumeRolePolicy",
"iam:PutRolePolicy",
"iam:DeleteRolePolicy",
"iam:AttachRolePolicy",
"iam:DetachRolePolicy",
"iam:TagRole",
"iam:UntagRole",
"iam:PutRolePermissionsBoundary",
"iam:DeleteRolePermissionsBoundary",
"iam:GetRole",
"iam:ListRolePolicies",
"iam:ListAttachedRolePolicies"
],
"Resource": "arn:aws:iam::${ACCOUNT_ID}:role/delegated/*",
"Condition": {
"StringEquals": {
"iam:PermissionsBoundary": "${BOUNDARY_ARN}"
}
}
},
{
"Sid": "AllowCreateDeleteUsersWithBoundary",
"Effect": "Allow",
"Action": [
"iam:CreateUser",
"iam:DeleteUser",
"iam:PutUserPolicy",
"iam:DeleteUserPolicy",
"iam:AttachUserPolicy",
"iam:DetachUserPolicy",
"iam:TagUser",
"iam:UntagUser",
"iam:PutUserPermissionsBoundary",
"iam:DeleteUserPermissionsBoundary",
"iam:GetUser",
"iam:ListUserPolicies",
"iam:ListAttachedUserPolicies"
],
"Resource": "arn:aws:iam::${ACCOUNT_ID}:user/delegated/*",
"Condition": {
"StringEquals": {
"iam:PermissionsBoundary": "${BOUNDARY_ARN}"
}
}
},
{
"Sid": "AllowPassOnlyDelegatedRolesToApprovedServices",
"Effect": "Allow",
"Action": "iam:PassRole",
"Resource": "arn:aws:iam::${ACCOUNT_ID}:role/delegated/*",
"Condition": {
"StringEquals": {
"iam:PassedToService": [
"ec2.amazonaws.com",
"lambda.amazonaws.com"
]
}
}
},
{
"Sid": "AllowReadIam",
"Effect": "Allow",
"Action": [
"iam:List*",
"iam:Get*"
],
"Resource": "*"
},
{
"Sid": "DenyBoundaryRemovalOrPolicyTampering",
"Effect": "Deny",
"Action": [
"iam:DeletePolicy",
"iam:CreatePolicyVersion",
"iam:SetDefaultPolicyVersion",
"iam:DeletePolicyVersion"
],
"Resource": "${BOUNDARY_ARN}"
}
]
}
JSON
aws iam create-policy \
--policy-name DelegatedIamAdminPolicy \
--policy-document file://delegated-admin-policy.json
You should see a second policy ARN like arn:aws:iam::123456789012:policy/DelegatedIamAdminPolicy.
Step 3: Create the delegated admin role and attach the policy
Create a role that your developers can assume. Replace the trusted principal ARN with your real admin group/role/user ARN.
⚠️ A wrong trust policy can lock out the intended operators or let the wrong principal assume this role. Paste the exact trusted ARN you want before running the command.
TRUSTED_PRINCIPAL_ARN="arn:aws:iam::${ACCOUNT_ID}:role/PlatformEngineers"
cat > delegated-admin-trust.json <<JSON
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"AWS": "${TRUSTED_PRINCIPAL_ARN}"
},
"Action": "sts:AssumeRole"
}
]
}
JSON
aws iam create-role \
--role-name DelegatedIamAdmin \
--assume-role-policy-document file://delegated-admin-trust.json
aws iam attach-role-policy \
--role-name DelegatedIamAdmin \
--policy-arn arn:aws:iam::${ACCOUNT_ID}:policy/DelegatedIamAdminPolicy
You should see role metadata on create, and no output with exit code 0 on attach.
Step 4: Assume the delegated admin role
Use STS to get temporary credentials and export them into your shell.
aws sts assume-role \
--role-arn arn:aws:iam::${ACCOUNT_ID}:role/DelegatedIamAdmin \
--role-session-name delegated-iam-test > /tmp/delegated-session.json
export AWS_ACCESS_KEY_ID=$(jq -r '.Credentials.AccessKeyId' /tmp/delegated-session.json)
export AWS_SECRET_ACCESS_KEY=$(jq -r '.Credentials.SecretAccessKey' /tmp/delegated-session.json)
export AWS_SESSION_TOKEN=$(jq -r '.Credentials.SessionToken' /tmp/delegated-session.json)
aws sts get-caller-identity
You should see the assumed-role ARN ending with assumed-role/DelegatedIamAdmin/delegated-iam-test.
Step 5: Create a role under the enforced path with the boundary attached
Create a test role the delegated admin is allowed to manage.
cat > app-trust.json <<'JSON'
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Service": "ec2.amazonaws.com"
},
"Action": "sts:AssumeRole"
}
]
}
JSON
aws iam create-role \
--role-name app-readonly \
--path /delegated/ \
--assume-role-policy-document file://app-trust.json \
--permissions-boundary arn:aws:iam::${ACCOUNT_ID}:policy/DelegatedAdminBoundary
You should see JSON for role arn:aws:iam::123456789012:role/delegated/app-readonly.
Step 6: Attach a policy and confirm the boundary still caps it
Attach a broad managed policy to prove the boundary is the real ceiling.
aws iam attach-role-policy \
--role-name app-readonly \
--policy-arn arn:aws:iam::aws:policy/AdministratorAccess
aws iam get-role --role-name app-readonly \
--query 'Role.PermissionsBoundary.PermissionsBoundaryArn' \
--output text
You should see the boundary ARN printed. The attached admin policy does not override the boundary.
Step 7: Prove the guardrail blocks creation without the boundary
Try the same create call without --permissions-boundary.
aws iam create-role \
--role-name should-fail \
--path /delegated/ \
--assume-role-policy-document file://app-trust.json
You should see An error occurred (AccessDenied) when calling the CreateRole operation and the CLI exits non-zero.
Verify it works
Run these checks end to end.
aws iam list-roles \
--path-prefix /delegated/ \
--query 'Roles[].RoleName' \
--output json
aws iam get-role --role-name app-readonly \
--query '{RoleName:Role.RoleName,Boundary:Role.PermissionsBoundary.PermissionsBoundaryArn,Arn:Role.Arn}' \
--output json
aws iam simulate-principal-policy \
--policy-source-arn arn:aws:iam::${ACCOUNT_ID}:role/delegated/app-readonly \
--action-names iam:CreateUser s3:ListBucket ec2:DescribeInstances \
--output json
Expected results:
list-rolesincludesapp-readonly.get-roleshowsBoundaryset toarn:aws:iam::<account-id>:policy/DelegatedAdminBoundary.simulate-principal-policyreturnsEvalDecision: "explicitDeny"foriam:CreateUserandallowedfor read-only actions covered by the boundary.
A realistic simulate-principal-policy shape looks like:
{
"EvaluationResults": [
{
"EvalActionName": "iam:CreateUser",
"EvalDecision": "explicitDeny"
},
{
"EvalActionName": "s3:ListBucket",
"EvalDecision": "allowed"
},
{
"EvalActionName": "ec2:DescribeInstances",
"EvalDecision": "allowed"
}
]
}
Common pitfalls
Missing iam:PermissionsBoundary condition on CreateRole or CreateUser
Mistake: you allow iam:CreateRole/iam:CreateUser but do not require a boundary in the policy condition.
Symptom: the delegated admin can create an unconstrained role or user and then attach broad policies.
Fix: add "Condition": {"StringEquals": {"iam:PermissionsBoundary": "arn:aws:iam::<account-id>:policy/DelegatedAdminBoundary"}} to the allow statement for create actions.
Allowing unrestricted iam:PassRole
Mistake: iam:PassRole is granted on * or on all roles in the account.
Symptom: the delegated admin launches compute with a more privileged existing role and bypasses your boundary design.
Fix: scope iam:PassRole to arn:aws:iam::<account-id>:role/delegated/* and add iam:PassedToService conditions for only the services you need.
Forgetting the IAM path restriction
Mistake: resources are allowed as arn:aws:iam::<account-id>:role/* instead of role/delegated/* and user/delegated/*.
Symptom: the delegated admin can modify unrelated IAM principals created by your platform or security teams.
Fix: use --path /delegated/ on create commands and restrict policy Resource ARNs to that path.
Trust policy points at the wrong principal ARN
Mistake: the Principal in the delegated admin role trust policy references a user/role that does not actually assume it.
Symptom: aws sts assume-role fails with:
An error occurred (AccessDenied) when calling the AssumeRole operation: User is not authorized to perform: sts:AssumeRole on resource: arn:aws:iam::<account-id>:role/DelegatedIamAdmin
Fix: replace TRUSTED_PRINCIPAL_ARN with the exact caller ARN and rerun aws iam update-assume-role-policy --role-name DelegatedIamAdmin --policy-document file://delegated-admin-trust.json.
Editing the boundary policy in place without realizing it affects every bounded principal
Mistake: you change DelegatedAdminBoundary directly to test something.
Symptom: multiple existing app roles/users suddenly lose access or gain access account-wide.
Fix: create a new policy version only in a controlled change window, test with iam simulate-principal-policy, or create a separate boundary policy for a different delegation tier instead of reusing one boundary for everything.
This article was written by an AI system and published pending human review. Verify anything you intend to act on.
Have a project in mind?
Get an instant AI price estimate for it, or talk directly to our team.
One email a month on what we learn building with AI