IAM trust policies silently accept wildcards in principals — and silently deny everything
I needed a cross-account IAM role in a management account that workloads in a separate devops account could assume to send email via SES. Two types of callers: one shared service with a stable role name, and N dynamically-created per-tenant roles following a naming convention like myapp-apiserver-*.
The shared service was straightforward — exact ARN in the trust policy principal. For the per-tenant roles, I wrote what looked correct:
"Principal": { "AWS": "arn:aws:iam::111111111111:role/myapp-apiserver-*" }
terraform apply succeeded. The role was created. Every assume-role call was denied.
The API accepts it, the engine ignores it
IAM trust policies don’t evaluate wildcards in principal ARNs. There is no validation error, no warning in CloudTrail, no partial match. The wildcard character is treated as a literal that matches nothing. The authorization engine sees a principal ARN that doesn’t exist and denies every request.
I confirmed this by testing from a pod whose IRSA role was myapp-apiserver-test — an exact match for the pattern I intended. Denied. Changed the principal to the exact ARN, same pod, same request. Succeeded.
The documented pattern
The working approach uses :root as the principal with a StringLike condition on aws:PrincipalArn:
{
"Sid": "AllowTenantApiservers",
"Effect": "Allow",
"Principal": { "AWS": "arn:aws:iam::111111111111:root" },
"Action": "sts:AssumeRole",
"Condition": {
"StringLike": {
"aws:PrincipalArn": "arn:aws:iam::111111111111:role/myapp-apiserver-*"
}
}
}
The :root principal permits any identity in the account to attempt the call. The condition restricts which identities actually succeed. Two layers, one effective wildcard.
I tested both trust patterns from live pods on EKS. The shared service pod — matched by exact ARN — assumed the role. A per-tenant apiserver pod — matched by the StringLike condition — assumed the role. An SSO admin session — matched by neither — was denied. The ses:FromAddress condition on the role’s permission policy denied an unauthorized sender address. Four tests, four correct outcomes.
Why this isn’t a missing feature
My first reaction was that IAM should support wildcards in principals. It supports them in conditions. It supports them in resource ARNs. The omission felt arbitrary.
It isn’t. Trust policy principals are resolved at evaluation time — IAM validates that the ARN maps to a real entity. A wildcard can’t be validated that way. You don’t know what future roles will match myapp-apiserver-* at the time you write the policy. With exact principals, you can enumerate who can assume a role by reading the trust policy. With wildcards, the answer becomes “whatever happens to exist right now” — a security boundary that shifts under your feet.
There’s also the evaluation cost. Every sts:AssumeRole call hits the trust policy on a hot path. Set membership is fast. Pattern matching is not. And on a security boundary, the blast radius of a typo matters — myapp-* vs myapp* vs *-myapp-* are three different grants, and one of them matches every role in the account.
The :root + condition pattern forces two separate declarations of intent: “I’m opening this to the account” and “here’s the narrowing constraint.” That’s not a workaround. That’s a deliberate separation of concerns on a security boundary. The restriction has survived 20 years of pressure from every customer and every use case. The constraints that remain in IAM aren’t oversights — they’re load-bearing walls.
The Checkov problem
Checkov CKV_AWS_61 flags any trust policy with :root as a principal. It reads :root as “this account can assume the role without restriction.” It doesn’t evaluate conditions.
My first instinct was to add a skip annotation and move on. I put #checkov:skip=CKV_AWS_61 above the resource block. Pre-commit ran, skip was ignored — Checkov inline skips go inside the resource block, not above it. I moved it inside, still failed — missing the space after #. Third try passed.
Three attempts to silence a scanner I hadn’t yet proven wrong.
The scanner is right until you prove it wrong
My rule of thumb with static analysis — Checkov, shellcheck, hadolint, any of them — is that the rule exists for a reason. I started using Checkov last month. Every time it flags something, my default assumption is that I’m wrong and it’s right. The skip is the last resort, not the first instinct.
The right sequence was the opposite of what I did. Before reaching for a skip, I should have tightened the trust policy as far as IAM allows. The shared service has a stable name — it gets an exact principal ARN, no :root needed. Only the dynamic per-tenant pattern requires the :root + condition approach, because IAM doesn’t support what Checkov wants.
So I tightened. One statement with an exact ARN, one with :root + condition. Checkov still flagged it. Now I’ve earned the skip — I’ve exhausted every alternative and the remaining finding reflects a constraint in IAM, not a weakness in my policy.
The skip comment is an argument you’re making to your future self:
# checkov:skip=CKV_AWS_61:Uses :root with StringLike condition on
# aws:PrincipalArn — IAM trust policies don't support wildcards in principals
That’s not “I disagree with this check.” That’s “I tried what this check wants, IAM doesn’t allow it, here’s why the condition is equivalent.” If you can’t write a skip comment that specific, you haven’t done the work.
IAM is seductive and severe — easy to misread, hard to argue with. You can doubt its constraints. You can’t beat them. The restrictions that survive 20 years of every customer trying to shortcut them aren’t missing features. They’re the architecture working as intended.
References
- AWS JSON policy elements: Principal — the flat statement that wildcards can’t match part of a principal name or ARN
- How to use trust policies with IAM roles — AWS Security Blog walkthrough of trust policy mechanics
- Abusing Misconfigured Role Trust Policies with a Wildcard Principal — what happens when trust policies are too permissive