A developer does not have administrator permissions. They cannot directly read production secrets or modify critical IAM policies.
But they can launch an EC2 instance.
What happens if that developer can also assign an administrator IAM role to the instance?
The developer might not have administrative permissions personally, yet they could create a workload that operates with those permissions. This is one of the privilege-escalation risks cloud engineers need to understand when designing AWS IAM policies.
In this practical guide, we will reproduce the authorization scenario in a controlled AWS environment, restrict the iam:PassRole permission, and test whether the developer can attach an administrative role to EC2.
We will also examine two other IAM policy traps: NotAction and explicit deny precedence.
The objective: Allow a developer to launch EC2 instances with an approved application role while preventing them from passing a privileged role.
1. Understand the security problem
AWS compute services commonly use IAM roles to access other AWS services.
For example, an application running on EC2 might need to read files from S3, publish messages to SQS, or send logs to CloudWatch.
Instead of storing long-term access keys on the instance, you assign an IAM role through an instance profile. The workload can then obtain temporary credentials for that role and use its permissions.
The same architectural pattern applies to Lambda execution roles, ECS task roles, and roles used by other AWS services. AWS documents this workload-identity model here.
The security issue arises when an identity can both create or modify a workload and pass an overly privileged role to it.
Consider this example:
- A developer can launch EC2 instances.
- An application role provides limited access to Systems Manager and CloudWatch.
- An administrator role has broad permissions across the AWS account.
- The developer has unrestricted
iam:PassRolepermission.
The developer could attempt to attach the administrator role to an EC2 instance. If the relevant authorization checks and trust relationships allow it, software running on that instance could obtain temporary credentials for the administrator role.
The developer has not directly assumed the administrator role. Instead, they have configured an AWS service to use it.
That distinction is central to understanding iam:PassRole.
iam:PassRole allows an identity to pass an IAM role to an AWS service. It does not, by itself, give that identity the role’s permissions or directly grant permission to assume the role. AWS still evaluates the relevant permissions, role trust policy, and service configuration.
Reference: AWS IAM — Grant a user permissions to pass a role.
2. Build the lab architecture
We will use three roles.
| IAM role | Purpose |
|---|---|
AdminRole | Represents a highly privileged role that must not be passed by the developer |
AppServer-WebRole | Represents an approved EC2 application role with limited permissions |
Developer-Lab-Role | Allows selected EC2 operations and restricts which roles can be passed |
The intended authorization flow is:
Administrator / Lab Operator
|
| sts:AssumeRole
v
Developer-Lab-Role
|
+---- ec2:RunInstances
|
+---- iam:PassRole
|
+---------+---------+
| |
v v
AdminRole AppServer-WebRole
DENIED ALLOWED
The lab will use --dry-run for both instance-launch attempts. No EC2 instance should be launched during the tests.
Safety requirement: Use a dedicated sandbox account or isolated lab environment. The example AdminRole has AdministratorAccess, so never reproduce it in a production account merely to test this scenario. Do not remove --dry-run from the test commands.
3. Create the EC2 workload roles
First, create a trust policy that allows EC2 to assume a role.
Save the following as ec2-trust.json:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Service": "ec2.amazonaws.com"
},
"Action": "sts:AssumeRole"
}
]
}
This is the role’s trust policy. It specifies which principal may assume the role; it does not grant the role permission to access S3, DynamoDB, or other AWS resources.
Create AdminRole
Run the following commands using your lab administrator credentials:
aws iam create-role \
--role-name AdminRole \
--assume-role-policy-document file://ec2-trust.json
aws iam attach-role-policy \
--role-name AdminRole \
--policy-arn arn:aws:iam::aws:policy/AdministratorAccess
aws iam create-instance-profile \
--instance-profile-name AdminRole
aws iam add-role-to-instance-profile \
--instance-profile-name AdminRole \
--role-name AdminRole
This role represents the dangerous configuration. Its instance profile makes it possible to specify the role when requesting an EC2 launch.
We create it only so that the authorization test has a real privileged target.
Create AppServer-WebRole
Now create the approved application role:
aws iam create-role \
--role-name AppServer-WebRole \
--assume-role-policy-document file://ec2-trust.json
aws iam attach-role-policy \
--role-name AppServer-WebRole \
--policy-arn arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore
aws iam create-instance-profile \
--instance-profile-name AppServer-WebRole
aws iam add-role-to-instance-profile \
--instance-profile-name AppServer-WebRole \
--role-name AppServer-WebRole
AmazonSSMManagedInstanceCore provides core permissions needed for Systems Manager managed-instance functionality. It is an example application role for this demonstration, not a universal policy for every EC2 workload.
In this design, the developer should be able to pass AppServer-WebRole to EC2, but not AdminRole.
For CLI-managed instances, remember that the instance profile is a separate object from the IAM role. The profile contains the role and is what you specify when launching the instance.
4. Create the restricted developer policy
We now define the permissions of Developer-Lab-Role.
The policy needs to allow the developer to inspect EC2 resources and request instance launches. However, iam:PassRole must be restricted to approved application roles.
Start by getting your AWS account ID:
ACCOUNT_ID=$(aws sts get-caller-identity \
--query Account \
--output text)
REGION=ap-south-1
Create a file named DevPassRolePolicy.json:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "AllowEC2Describe",
"Effect": "Allow",
"Action": [
"ec2:DescribeInstances",
"ec2:DescribeImages",
"ec2:DescribeSecurityGroups",
"ec2:DescribeSubnets",
"ec2:DescribeVpcs"
],
"Resource": "*"
},
{
"Sid": "AllowRunInstances",
"Effect": "Allow",
"Action": "ec2:RunInstances",
"Resource": "*"
},
{
"Sid": "AllowPassOnlyApplicationRoles",
"Effect": "Allow",
"Action": "iam:PassRole",
"Resource": "arn:aws:iam::<ACCOUNT_ID>:role/AppServer-*",
"Condition": {
"StringEquals": {
"iam:PassedToService": "ec2.amazonaws.com"
}
}
}
]
}
Replace <ACCOUNT_ID> with your actual AWS account ID. Do not leave the placeholder in the deployed policy.
The policy has three distinct purposes.
AllowEC2Describe permits the developer to inspect selected EC2 resources.
AllowRunInstances permits the EC2 launch API operation. The resource wildcard is deliberately broad here so the lab focuses on the role-passing boundary. It is not a complete production-grade EC2 launch policy; production permissions should also restrict permissible images, instance types, network resources, and other launch parameters.
AllowPassOnlyApplicationRoles contains the important security restriction.
{
"Effect": "Allow",
"Action": "iam:PassRole",
"Resource": "arn:aws:iam::<ACCOUNT_ID>:role/AppServer-*",
"Condition": {
"StringEquals": {
"iam:PassedToService": "ec2.amazonaws.com"
}
}
}
The Resource ARN restricts which role names match the grant. The condition further limits role passing to EC2.
With these settings, AppServer-WebRole matches the permitted role pattern. AdminRole does not.
The naming pattern alone is not a complete security boundary. Administrators must also control who can create roles with the AppServer- prefix, change their policies, or otherwise make those roles more privileged than intended.
The iam:PassedToService condition key is documented in the AWS IAM condition-key reference.
Create the developer role
Create developer-trust.json, replacing the account ID:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"AWS": "arn:aws:iam::<ACCOUNT_ID>:root"
},
"Action": "sts:AssumeRole"
}
]
}
This trust policy delegates the ability to authorize role assumption to the same AWS account. The calling identity must still have permission to call sts:AssumeRole. For a production design, prefer trusting the specific administrative or deployment principal that should assume this role.
Create and attach the developer policy:
aws iam create-role \
--role-name Developer-Lab-Role \
--assume-role-policy-document file://developer-trust.json
aws iam create-policy \
--policy-name DevPassRolePolicy \
--policy-document file://DevPassRolePolicy.json
aws iam attach-role-policy \
--role-name Developer-Lab-Role \
--policy-arn "arn:aws:iam::${ACCOUNT_ID}:policy/DevPassRolePolicy"
At this point, the three roles and the required instance profiles should exist.
Allow a short period for IAM changes to propagate before continuing.
5. Prepare the EC2 launch parameters
We need an AMI, subnet, and security group for the two authorization tests.
Retrieve a current Amazon Linux 2023 AMI ID from AWS Systems Manager Parameter Store:
AMI_ID=$(aws ssm get-parameter \
--name /aws/service/ami-amazon-linux-latest/al2023-ami-kernel-default-x86_64 \
--region "$REGION" \
--query Parameter.Value \
--output text)
List available subnets and security groups:
aws ec2 describe-subnets \
--region "$REGION" \
--query 'Subnets[].{SubnetId:SubnetId,VPC:VpcId}' \
--output table
aws ec2 describe-security-groups \
--region "$REGION" \
--query 'SecurityGroups[].{GroupId:GroupId,VPC:VpcId,Name:GroupName}' \
--output table
Choose a subnet and a security group that belong to the same VPC. Use a suitable test security group, preferably one that permits no unnecessary inbound access.
Set the selected IDs:
SUBNET_ID=subnet-REPLACE_ME
SG_ID=sg-REPLACE_ME
Run these setup commands while using your administrator credentials. The developer policy does not include ssm:GetParameter, and we do not need to grant that permission merely to perform this lab.
6. Assume the developer role
Use your lab administrator identity to assume Developer-Lab-Role:
aws sts assume-role \
--role-arn "arn:aws:iam::${ACCOUNT_ID}:role/Developer-Lab-Role" \
--role-session-name PassRoleLab
AWS returns temporary credentials. Use those credentials for the following tests, or configure an AWS CLI profile that assumes the role.
If using environment variables, export the returned AccessKeyId, SecretAccessKey, and SessionToken into your current shell. Never commit temporary credentials to a repository or include them in published output.
Verify the active identity:
aws sts get-caller-identity
The ARN should identify an assumed session for Developer-Lab-Role.
This check matters. If the CLI is still using the administrator identity, the tests will not demonstrate the developer’s restricted permissions.
7. Test 1 — Attempt to pass AdminRole
Run the following command with the developer credentials:
aws ec2 run-instances \
--image-id "$AMI_ID" \
--instance-type t3.micro \
--subnet-id "$SUBNET_ID" \
--security-group-ids "$SG_ID" \
--iam-instance-profile Name=AdminRole \
--region "$REGION" \
--dry-run
The expected result is an UnauthorizedOperation response. The error should identify a missing authorization, ideally iam:PassRole on the AdminRole ARN.
Why is it denied?
The developer has ec2:RunInstances, but the requested IAM role does not match this policy resource:
arn:aws:iam::<ACCOUNT_ID>:role/AppServer-*
AWS evaluates the applicable permissions for the request. The iam:PassRole statement does not grant permission to pass AdminRole, so the attempted launch is rejected.
This is the result we want.
The developer has some EC2 permissions but cannot use those permissions to attach an arbitrary privileged role.
8. Test 2 — Pass AppServer-WebRole
Now run the same request with the approved instance profile:
aws ec2 run-instances \
--image-id "$AMI_ID" \
--instance-type t3.micro \
--subnet-id "$SUBNET_ID" \
--security-group-ids "$SG_ID" \
--iam-instance-profile Name=AppServer-WebRole \
--region "$REGION" \
--dry-run
The expected response is:
An error occurred (DryRunOperation):
Request would have succeeded, but DryRun flag is set.
Although the CLI reports an error response, DryRunOperation indicates that AWS authorized the request under the dry-run check.
The instance is not launched.
The difference between the two tests is the role being passed to EC2:
| Test | Instance profile | Expected authorization result |
|---|---|---|
| 1 | AdminRole | UnauthorizedOperation due to denied role passing |
| 2 | AppServer-WebRole | DryRunOperation, indicating authorization succeeded |
This conclusion assumes the selected AMI, subnet, security group, and other launch parameters are valid and the remaining applicable permissions do not introduce another denial.
AWS documents this distinction in the EC2 run-instances CLI reference.
The point of the test is not merely that one API call succeeds and another fails. It demonstrates that the policy controls which identity EC2 can be configured to use.
9. Two more IAM policy traps to recognize
The same least-privilege reasoning applies to policies beyond iam:PassRole.
Trap A: Allowing everything except one action with NotAction
Consider this statement:
{
"Effect": "Allow",
"NotAction": "s3:DeleteBucket",
"Resource": "*"
}
A developer might read it as, “Allow everything in S3 except deleting buckets.”
That interpretation is incorrect.
NotAction matches actions other than the action specified, subject to the resource scope and other policy evaluation rules. With Resource: "*", this statement can allow a much broader set of applicable AWS actions than intended.
If the intention is to grant read-only access to a particular S3 bucket, write a policy that explicitly allows the necessary S3 read actions and scopes them to the required bucket and object resources.
NotAction is a useful advanced policy element in some designs, but it is not a substitute for understanding the scope of the statement.
Reference: AWS IAM — NotAction policy element.
Trap B: Assuming an Allow overrides an explicit Deny
AWS IAM policy evaluation also has an important precedence rule: an applicable explicit Deny overrides an applicable Allow.
For example, suppose an identity has an Allow statement for terminating EC2 instances but is also subject to this policy:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Deny",
"Action": "ec2:TerminateInstances",
"Resource": "*"
}
]
}
The applicable explicit denial prevents the termination request from being authorized, even if another applicable policy allows the action.
This is why security reviews must examine the effective set of policies and not just find an Allow statement that appears to grant access.
Reference: AWS IAM — Effect policy element.
10. What I would change before using this pattern in production
The lab demonstrates one authorization boundary; it is not a finished production permissions model.
I would make the following changes before adapting it to an actual engineering team.
Restrict which roles can be passed. Use specific role ARNs or tightly governed role patterns. Where appropriate, scope the permitted service through iam:PassedToService. Avoid unrestricted iam:PassRole grants.
Protect the approved role namespace. If the policy permits AppServer-*, prevent ordinary developers from creating a role under that prefix and attaching unrestricted policies to it. A role naming convention is useful only when accompanied by governance.
Restrict EC2 launch permissions separately. The example gives ec2:RunInstances access on Resource: "*". In production, constrain the launchable AMIs, instance types, subnets, security groups, resource tags, and permitted launch mechanisms as appropriate for the environment.
Review role trust policies. Passing a role is one part of the authorization chain. The target role must also trust the appropriate AWS service, and its permissions must be limited to what the workload needs.
Test the effective permissions. Use IAM policy analysis and simulation tools as part of the review, and verify the actual behavior with controlled tests. An IAM statement should be evaluated in the context of all applicable identity-based policies, resource-based policies, permissions boundaries, and organization-level controls.
Keep workloads separate from human identities. Developers should use their own authenticated identities to perform administrative tasks, while applications should use workload roles with temporary credentials and limited permissions.
These controls reduce the chance that a routine deployment permission becomes an unintended route to broader access.
Conclusion
The most important lesson from this lab is that a permission should be evaluated in the context of what it enables—not just what its action name appears to describe.
A developer may not have administrator permissions directly. But the combination of resource creation and unrestricted role passing can create an indirect path to highly privileged workload credentials.
The practical fix is to control which roles can be passed, restrict the services that can receive them, protect the permissions attached to approved roles, and verify the boundary with negative tests.
In this lab, the policy allows AppServer-WebRole and rejects AdminRole. That is the intended result: the developer can perform the approved task without gaining an unrestricted route to administrative access.
The rule I use when reviewing AWS permissions is simple: if an identity can create or modify a workload and choose the IAM role it uses, review that combination as a potential privilege-escalation path.
Lab repository and further reading
- Hands-on lab: github.com/sagarpanwarwork/aws-cloud
- AWS IAM — Grant a user permissions to pass a role
- AWS IAM — Pass a role to a specific service
- AWS IAM — NotAction
- AWS CLI — EC2 run-instances
This lab is intended for controlled security testing in an isolated AWS environment. Keep the --dry-run flag in both launch tests, and remove the lab roles, instance profiles, and managed policy after completing the exercise.

