An IAM policy with Allow and NotAction quietly granted IAM admin: reading what policies really allow
A developer role was meant to allow everything except IAM changes. Its policy used Allow with NotAction, yet a review found it could create access keys for other users and assume an admin role. How we found it with the policy simulator and Access Analyzer, and rewrote it safely.
During a quarterly access review, the security team asked a simple question about the developer role in our
sandbox and staging accounts: "Can this role change IAM?" The team that owned the role was certain the
answer was no; the policy had been written specifically to allow everything except IAM.
The policy:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "EverythingExceptIam",
"Effect": "Allow",
"NotAction": ["iam:*", "organizations:*", "account:*"],
"Resource": "*"
},
{
"Sid": "ManageOwnCredentials",
"Effect": "Allow",
"Action": ["iam:CreateAccessKey", "iam:DeleteAccessKey", "iam:ListAccessKeys", "iam:UpdateAccessKey"],
"Resource": "*"
}
]
}
Testing with the policy simulator
The IAM policy simulator evaluates whether a principal is allowed to perform actions on resources:
aws iam simulate-principal-policy \
--policy-source-arn arn:aws:iam::111122223333:role/developer \
--action-names iam:CreateAccessKey iam:CreateUser iam:AttachUserPolicy sts:AssumeRole \
--resource-arns arn:aws:iam::111122223333:user/ci-deployer
iam:CreateAccessKey allowed (matched statement ManageOwnCredentials)
iam:CreateUser implicitDeny
iam:AttachUserPolicy implicitDeny
sts:AssumeRole allowed (matched statement EverythingExceptIam)
Two results stood out. The role could create access keys for any IAM user, including ci-deployer, which
had broad deployment permissions. And sts:AssumeRole was allowed on every resource.
Following the second finding
sts:AssumeRole is not an IAM action, so the NotAction statement allowed it. Assuming a role also requires
that role's trust policy to allow the caller. We listed roles whose trust policies trusted the whole account:
aws iam list-roles --query "Roles[?contains(to_string(AssumeRolePolicyDocument), 'arn:aws:iam::111122223333:root')].RoleName"
Three roles trusted the account root, which delegates the decision to IAM policies in the account. One of them
was deploy-admin, with AdministratorAccess. The developer role could assume it.
What we ruled out
- Permission boundaries limiting the role. None were attached.
- Service control policies blocking IAM. The organisation's SCPs did not restrict IAM in these accounts.
- A recent change. The policy had been in place for two years.
Root cause
Two separate misunderstandings combined. First, Allow with NotAction grants every action not listed,
including actions in services that do not exist yet and actions such as sts:AssumeRole that lead to other
privileges. Second, the "manage own credentials" statement used Resource: "*" instead of the caller's own
user ARN, so it allowed key management on every user. Together they made a role meant to be "no IAM" able to
get administrator access in two steps.
How NotAction works
| Combination | Meaning |
|---|---|
Allow + Action |
Allows exactly the listed actions |
Allow + NotAction |
Allows everything except the listed actions |
Deny + NotAction |
Denies everything except the listed actions (common for guardrails) |
Allow + NotAction is not "deny these actions". A different statement, in the same or another policy, can
still allow the listed actions, as ManageOwnCredentials did for some IAM actions. Only an explicit Deny
overrides an Allow.
Privilege escalation paths
Many actions outside IAM lead to more permissions: assuming roles, passing roles to services
(iam:PassRole is IAM, but services like Lambda and EC2 can run with a passed role), modifying resources that
run code with powerful roles, or reading secrets. Broad allow statements make those paths easy to miss.
Who is affected
Accounts with "everything except X" policies written with Allow + NotAction, statements with
Resource: "*" for actions meant to apply to the caller only, and roles with AdministratorAccess trusted by
the whole account.
Reading policies like an attacker
The useful question when reviewing a policy is not "what is this meant to allow?" but "what is the most this principal can reach?". That includes actions on resources that run with other roles (Lambda functions, EC2 instances, CI pipelines), roles it can assume, secrets it can read, and policies it can attach. Tools that model escalation paths help, but the simulator plus a short list of known escalation actions covers most cases in a review.
Environment
| Component | Value |
|---|---|
| Accounts | Sandbox and staging, part of an AWS Organization |
| Role | developer, assumed through SSO by about 40 engineers |
| Risky targets | ci-deployer user with access keys, deploy-admin role trusting the account |
Rewrite with explicit denies as guardrails
We kept broad access for developers in sandbox accounts, but made the exclusions real with explicit Deny
statements, and scoped self-service credential actions to the caller:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "BroadAccess",
"Effect": "Allow",
"Action": "*",
"Resource": "*"
},
{
"Sid": "DenyIamOrgAccountChanges",
"Effect": "Deny",
"Action": ["iam:*", "organizations:*", "account:*"],
"Resource": "*"
},
{
"Sid": "DenyAssumingAdminRoles",
"Effect": "Deny",
"Action": "sts:AssumeRole",
"Resource": ["arn:aws:iam::*:role/deploy-*", "arn:aws:iam::*:role/*admin*"]
}
]
}
Developers use SSO and do not need IAM users or access keys at all, so the "own credentials" statement was
removed. If it had been needed, the correct resource would be arn:aws:iam::*:user/${aws:username}.
For staging, where broad access was not appropriate, we replaced the policy with service-specific permissions generated from CloudTrail activity using Access Analyzer's policy generation, then reviewed by hand.
Fix the trust policies
Roles with administrative permissions now trust only specific principals:
{
"Effect": "Allow",
"Principal": { "AWS": "arn:aws:iam::111122223333:role/ci-pipeline" },
"Action": "sts:AssumeRole",
"Condition": { "StringEquals": { "aws:PrincipalTag/team": "platform" } }
}
Remove long-lived keys
The ci-deployer user was replaced by an OIDC-federated role for CI, and its access keys were deleted.
Guardrails at the organisation level
A service control policy now denies iam:CreateAccessKey and iam:CreateUser in all accounts except a
break-glass account, so no policy mistake in a member account can recreate the problem.
Alternatives and trade-offs
- Permission boundaries on developer-created roles limit what any role they create can do. Useful when developers must manage IAM in sandboxes.
- Policy linting in CI with IAM Access Analyzer policy validation, which flags
Allow+NotActionandPassRolewith wildcards.
How to test the fix
aws iam simulate-principal-policy --policy-source-arn arn:aws:iam::111122223333:role/developer \
--action-names iam:CreateAccessKey sts:AssumeRole \
--resource-arns arn:aws:iam::111122223333:user/ci-deployer arn:aws:iam::111122223333:role/deploy-admin
# both: explicitDeny
aws accessanalyzer validate-policy --policy-type IDENTITY_POLICY --policy-document file://developer.json
Confirming in production
The simulator showed explicit denies for the escalation paths. Access Analyzer's unused access findings and
external access findings for the accounts were reviewed and closed. CloudTrail showed that the developer role
had assumed deploy-admin 14 times in the previous 90 days, all by the platform team for legitimate
deployments; those workflows moved to the CI role.
Communicating the change
Removing broad access from a role used by 40 engineers needs notice. We announced the change a week ahead, listed what would stop working (direct IAM changes, assuming deploy roles) and what to use instead, and kept a channel open for exceptions. Three legitimate needs surfaced and were handled with narrow, reviewed permissions.
Regression guard
- CI runs Access Analyzer policy validation on every IAM policy change in Terraform and fails on security warnings.
- A custom check rejects
AllowwithNotActionoutside an approved list. - Quarterly access reviews include simulator checks for known escalation actions.
Security implications
This was a real privilege escalation path from a widely used developer role to administrator in two
accounts. We treated it as a potential incident: reviewed CloudTrail for the full retention period for
CreateAccessKey on other users and for unexpected AssumeRole calls, and found no misuse.
Prevention checklist
- Do not use
AllowwithNotActionto mean "deny these"; use explicitDeny. - Scope self-service actions to
${aws:username}or remove them in favour of SSO. - Review role trust policies that trust the whole account.
- Validate policies with Access Analyzer and test escalation actions with the simulator.
- Add organisation-level guardrails for actions no member account should perform.
References
- AWS IAM, NotAction element: https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_policies_elements_notaction.html
- AWS IAM, Policy evaluation logic: https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_policies_evaluation-logic.html
- IAM policy simulator: https://docs.aws.amazon.com/IAM/latest/UserGuide/access_policies_testing-policies.html
- IAM Access Analyzer policy validation: https://docs.aws.amazon.com/IAM/latest/UserGuide/access-analyzer-policy-validation.html
Add a label
Embed this post
<iframe src="https://www.hibug.me/iam-notaction-allow-grants-too-much" width="700" height="420" frameborder="0" loading="lazy"></iframe>
I am an application and cloud security engineer. Most of my day is spent with development teams: reviewing designs, untangling IAM policies, hardening AWS accounts and making security checks fast enough that nobody wants to skip them. I believe security works best when it is concrete. Instead of long lists of best practices, my posts look at one decision at a time: a policy that granted more than intended, a design reviewed before the first line of code, a deletion that did not delete. Each one ends with checks you can run on your own systems.
Comments
0No comments yet. Be the first to comment!
Sign In to post a comment.