L46 · AWS IAM in depth
Module 7: Cloud, Containers, and Kubernetes · ~20 min · Lesson 46 of 71
🎯 What you'll learn
- How principals, policies and ARNs fit together, and how to read any IAM policy
- How roles work: trust policy vs permissions policy, AssumeRole, and temporary credentials
- The condition keys worth knowing, and three ways they quietly fail
- A repeatable loop for getting a role to least privilege with IAM Access Analyzer
🔥 Why this matters
It's 6 p.m. and the nightly reconciliation DAG fails with AccessDenied. Someone attaches
s3:* and kms:* on * to the execution role "for now", and the DAG goes green. A year later
that role can read every bucket in the account, including customer statements, and any engineer
who can edit a DAG can use it. IAM is the row of the shared responsibility model (L45) that's
always yours, and it drifts toward "everything" one late fix at a time.
1. Principals, actions, resources and ARNs
Every AWS API call is an L27-style question: can this principal perform this action on this resource, in this context? A principal is an identity that can make a request:
- The root user: the account's owner identity. Lock it away and don't use it for daily work.
- IAM users: long-term identities with passwords or access keys. Avoid them for people.
- IAM roles: identities with no long-term credentials. You assume a role to get a temporary role session. People (via Identity Center), pods, Lambda functions and MWAA all work this way.
- Service principals, such as
airflow.amazonaws.com: AWS services acting for you.
Resources are named with ARNs (Amazon Resource Names):
arn:partition:service:region:account-id:resource. Some fields are empty: S3 bucket ARNs have no
region or account (arn:aws:s3:::example-recon-inbound), and IAM ARNs have no region.
A policy is a JSON document of statements:
{
"Version": "2012-10-17", ◀── policy language version
"Statement": [{
"Sid": "ReadDaily", ◀── label for humans
"Effect": "Allow", ◀── Allow or Deny
"Action": "s3:GetObject", ◀── service:Operation
"Resource": ◀── which ARNs
"arn:aws:s3:::example-recon-inbound/daily/*",
"Condition": { ◀── optional: only when...
"Bool": {"aws:SecureTransport": "true"}}
}]
}
Access key IDs tell you what kind of credential you're looking at. Their prefixes are documented:
| Prefix | Meaning |
|---|---|
AKIA |
Long-term access key for an IAM user (or root): valid until someone deletes it |
ASIA |
Temporary key from STS: comes with a session token and expires |
AROA / AIDA |
Unique ID of a role / a user (seen in UserId, not a key) |
An AKIA key in a repo is an emergency; an ASIA key is a problem with a deadline.
2. Roles: two policies and a token service
A role has two separate policies, and mixing them up is the most common IAM confusion:
┌────────── role: recon-reader ──────────┐
│ │
WHO may │ TRUST POLICY (resource policy on the │
become it? │ role): Principal = role/recon-ci in │
│ account 222233334444 │
│ │
WHAT can │ PERMISSIONS POLICY (identity policy): │
it do? │ s3:GetObject on example-recon- │
│ inbound/daily/* │
└────────────────────────────────────────┘
Assuming the role goes through STS (AWS Security Token Service):
recon-ci (acct 222233334444) STS S3 (acct 1111...)
│ │ │
│ 1. AssumeRole(RoleArn= │ │
│ ...role/recon-reader, │ │
│ RoleSessionName=run-0412) │ │
│───────────────────────────────▶│ 2. caller's policy │
│ │ allows it? and │
│ │ trust policy │
│ │ trusts caller? │
│ 3. ASIA... key, secret, │ │
│ session token, expiry │ │
│◀───────────────────────────────│ │
│ 4. GetObject, signed with temp keys (SigV4, L17) │
│────────────────────────────────────────────────────▶│
│ decided by recon-reader's permissions policy │
Facts worth memorizing (from the STS docs):
- A session lasts 1 hour by default, from 15 minutes up to the role's maximum (1 to 12 hours). Role chaining, using one role's session to assume another, is capped at 1 hour.
- Cross-account, both sides must allow: the caller's identity policy must allow
sts:AssumeRoleon the role, and the role's trust policy must trust the caller. - A trust policy with
"Principal": {"AWS": "arn:aws:iam::222233334444:root"}doesn't mean the root user. It means the whole account: any principal there whose own policy allows it. RoleSessionNameappears in CloudTrail. Make it meaningful (a run ID or a user).
If your organization uses IAM Identity Center, it's this same machine: each permission set becomes an AWSReservedSSO_... role in
each account, and the portal calls STS for you (L24's deep dive covers sessions and offboarding).
Pods get roles through IRSA or EKS Pod Identity (L50).
3. How a request is judged, and conditions
The evaluation order (explicit deny wins, SCPs and boundaries only limit, effective access is the intersection) isn't repeated here (see L27's deep dive). Focus instead on conditions: the ABAC part of IAM, where most real-world guardrails live.
| Condition key | Typical use |
|---|---|
aws:PrincipalOrgID |
Only principals from your AWS Organization |
aws:SourceVpce |
Only through your VPC endpoint (bucket policies) |
aws:SecureTransport |
Deny requests that aren't over TLS |
aws:RequestedRegion |
Keep work in approved Regions |
aws:PrincipalTag/... |
Match the caller's tags (ABAC, L27) |
aws:MultiFactorAuthPresent |
Require MFA for sensitive actions |
s3:prefix, kms:ViaService |
Service-specific keys: which folder, which calling service |
Three documented ways conditions fail quietly:
- Missing keys don't match. If a key isn't in the request, the condition is false. A Deny
using
"Bool": {"aws:MultiFactorAuthPresent": "false"}never fires for long-term access keys, because they don't send that key at all. UseBoolIfExistsin deny statements like this one. ForAllValueswith no values is true. An Allow withForAllValues:StringEqualson a multi-valued key matches a request that sends no values. Pair it with aNullcheck.aws:SourceIpand VPC endpoints. Requests that arrive through a VPC endpoint don't carry your public IP, so an IP-based rule won't match them. Useaws:SourceVpceoraws:VpcSourceIp.
4. Least privilege is a loop, not a guess
Nobody writes the perfect policy first time. Treat it as a cycle:
┌─▶ 1. start narrow ──▶ 2. run in dev ──▶ 3. read AccessDenied
│ (named ARNs) (real job) and CloudTrail
│ │
│ 6. remove unused ◀── 5. validate ◀── 4. generate a draft
│ (last accessed, (Access (Access Analyzer,
│ unused-access Analyzer from CloudTrail)
│ findings) policy checks)
└──────────────┘
IAM Access Analyzer helps at several steps:
- Policy generation drafts a policy from up to 90 days of CloudTrail activity for a role.
- Policy validation flags errors, security warnings and over-broad statements as you write.
- External access findings list resources shared outside your account or organization.
- Unused access findings list roles, keys and permissions nobody has used. This and custom policy checks (for example, "does this change grant new access?") are paid features.
IAM's last accessed data (per service, and per action for some services) is free and shows what a role actually used.
Some permissions are dangerous even when narrow: iam:PassRole, iam:CreatePolicyVersion,
iam:PutRolePolicy, iam:UpdateAssumeRolePolicy, lambda:UpdateFunctionCode. Each can turn a
small role into a big one; the second deep dive explains why.
🧪 Worked example: shrinking the reconciliation DAG's role
Before, after a year of late fixes:
{
"Version": "2012-10-17",
"Statement": [
{"Effect": "Allow", "Action": "s3:*", "Resource": "*"},
{"Effect": "Allow", "Action": "kms:*", "Resource": "*"}
]
}
Generating a policy from 90 days of CloudTrail shows the DAG only reads daily/ files, writes
reports, and uses one KMS key through S3. After (made-up names):
{
"Version": "2012-10-17",
"Statement": [
{"Sid": "ListDailyOnly",
"Effect": "Allow",
"Action": "s3:ListBucket",
"Resource": "arn:aws:s3:::example-recon-inbound",
"Condition": {"StringLike": {"s3:prefix": "daily/*"}}},
{"Sid": "ReadDaily",
"Effect": "Allow",
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::example-recon-inbound/daily/*"},
{"Sid": "WriteReports",
"Effect": "Allow",
"Action": "s3:PutObject",
"Resource": "arn:aws:s3:::example-recon-reports/out/*"},
{"Sid": "KeyOnlyViaS3",
"Effect": "Allow",
"Action": ["kms:Decrypt", "kms:GenerateDataKey"],
"Resource":
"arn:aws:kms:us-east-1:111122223333:key/recon-key-id",
"Condition": {"StringEquals": {
"kms:ViaService": "s3.us-east-1.amazonaws.com"}}}
]
}
What each change does:
| Change | What it stops |
|---|---|
ListBucket on the bucket ARN, Get/Put on object ARNs |
Listing and reading other folders; S3 checks these at different ARN levels, a classic gotcha |
| Reports bucket is write-only | A compromised DAG can't read past reports back out |
One key, kms:ViaService = S3 |
Using the key directly to decrypt data taken from elsewhere (L19) |
No kms:* |
No kms:ScheduleKeyDeletion or kms:PutKeyPolicy from a DAG |
Then run validation, deploy to dev, and watch for AccessDenied before prod. The key's own key
policy must allow this role too, since KMS key policies always need an explicit allow.
🛠 Try it (optional, ~5 min)
Two read-only commands, on your own laptop, if you have the AWS CLI set up (use whatever profile you normally use).
aws sts get-caller-identity
aws configure list
get-caller-identity needs no IAM permission at all (AWS documents that it works even under an
explicit deny), so it's a safe "who am I?". Sample output:
{
"UserId": "AROAEXAMPLEID123456:dev@example.com",
"Account": "111122223333",
"Arn": "arn:aws:sts::111122223333:assumed-role/
AWSReservedSSO_DataEngineer_0123abcd/dev@example.com"
}
(Arn wrapped for width.) Read it with this lesson's vocabulary: AROA... is the role's unique
ID, assumed-role means a temporary role session, and the part after the last / is the session
name. aws configure list makes no AWS call; it shows the last characters of the key and where
the credentials came from (the exact labels vary by CLI version). If the source is a long-lived
shared-credentials-file key rather than SSO, ask why.
🧠 Key takeaways
- A role has a trust policy (who can assume it) and a permissions policy (what it can do).
- STS turns an AssumeRole into temporary
ASIAkeys; prefer them to long-termAKIAkeys. - A trust policy naming
:roottrusts the whole account, not the root user. - Conditions fail quietly when keys are missing; know
IfExists,NullandForAllValues. - Least privilege is a loop: start narrow, generate from CloudTrail, validate, remove unused.
📖 Words to know
| Term | Meaning |
|---|---|
| Principal | An identity that can make an AWS request: root, user, role session, or service |
| ARN | Amazon Resource Name, arn:partition:service:region:account:resource |
| Role session | Temporary credentials obtained by assuming a role |
| Trust policy | The resource policy on a role that says who may assume it |
| STS | Security Token Service: issues temporary credentials |
| Role chaining | Using one role session to assume another; capped at 1 hour |
| Condition key | A request attribute a policy can test, like aws:PrincipalOrgID |
| IAM Access Analyzer | AWS tool for external and unused access findings, policy validation and generation |
⏭ Next time
L47 is a case study: in 2019, one over-privileged IAM role and an SSRF bug let an attacker copy data on about 100 million people from a bank's S3 buckets.
Cross-account roles and the confused deputy: ExternalId, SourceArn and PrincipalOrgID
The vendor problem
A cloud cost-reporting vendor, CostLens (made up), asks you to create a role in your account that
its AWS account 444455556666 can assume. Its system then reads your billing data. It serves
thousands of customers from the same account.
Here's the catch. Another CostLens customer could type your role ARN into their CostLens settings. CostLens would assume your role, with its own legitimate credentials, and show them your data. CostLens is a confused deputy (the same pattern as L40's deep dive, now between AWS accounts): a trusted party tricked into using its access for someone else.
attacker (CostLens customer B)
│ 1. "my role is arn:aws:iam::111122223333:role/costlens"
▼
CostLens (acct 444455556666)
│ 2. AssumeRole(your role) trust policy: "444455556666"
▼ ✔ matches: CostLens IS trusted
your account 111122223333 ──▶ 3. your billing data to customer B
Worked example: the ExternalId fix
The vendor generates a unique external ID for each customer and always sends it when assuming that customer's role. Your trust policy requires it:
{
"Effect": "Allow",
"Principal": {"AWS": "arn:aws:iam::444455556666:root"},
"Action": "sts:AssumeRole",
"Condition": {"StringEquals": {
"sts:ExternalId": "cl-7f3a9c2e-customer-a"}}
}
Customer B can enter your role ARN, but CostLens will call AssumeRole with B's external ID, and
your trust policy rejects it. Per AWS's docs, the external ID isn't a secret; what matters is that
the vendor generates it and customers can't choose it. If a vendor lets you type your own, it
doesn't protect anything. And here :root is acceptable because the condition, not the principal,
does the narrowing.
The same problem with AWS services
AWS services act as deputies too. When S3 sends event notifications to your SNS topic, the topic
policy trusts the S3 service, which serves every account. Without a condition, a bucket in
someone else's account could be set up to publish to your topic. AWS's fix is the pair
aws:SourceArn and aws:SourceAccount:
{
"Effect": "Allow",
"Principal": {"Service": "s3.amazonaws.com"},
"Action": "SNS:Publish",
"Resource": "arn:aws:sns:us-east-1:111122223333:recon-files",
"Condition": {
"ArnLike": {
"aws:SourceArn": "arn:aws:s3:::example-recon-inbound"},
"StringEquals": {"aws:SourceAccount": "111122223333"}
}
}
Sharing with your own organization
For "any account in our company may read this bucket", don't list account IDs that change. Use
aws:PrincipalOrgID:
"Condition": {"StringEquals": {
"aws:PrincipalOrgID": "o-exampleorg1"}}
New accounts in the organization get access automatically, and nobody outside does, even if a policy elsewhere is too generous. Resource control policies (L27) can enforce these conditions across every account at once.
🧠 Extra takeaways
- A shared vendor account that assumes customer roles needs a per-customer, vendor-generated ExternalId.
- An ExternalId the customer can choose provides no protection.
- When a service principal is trusted, add
aws:SourceArnandaws:SourceAccount. - Use
aws:PrincipalOrgIDinstead of lists of account IDs for organization-wide sharing.
Iam:PassRole and other privilege-escalation paths
What "passing a role" means
Many AWS resources run as a role: a Lambda function, an EC2 instance profile, an ECS task, a
Glue job, an MWAA environment, a CloudFormation stack. When you create one, you hand it a role
ARN. IAM checks iam:PassRole on that ARN: "may this caller give this role to a service?"
iam:PassRole isn't an API you call. It's a permission checked during another call, so CloudTrail
shows CreateFunction, not a PassRole event. That's one reason it's easy to over-grant and hard to
spot.
The escalation
A developer role was given iam:PassRole on * "so deployments work", plus Lambda permissions:
dev role: lambda:CreateFunction, lambda:InvokeFunction,
iam:PassRole on *
│
│ 1. create function, Role = ...role/platform-admin
│ (PassRole on * allows passing ANY role)
▼
Lambda function runs as platform-admin
│
│ 2. invoke it: code reads Secrets Manager, attaches
│ AdministratorAccess to the dev role, etc.
▼
dev role is now effectively admin
The developer never had admin. They had permission to make something else run as admin.
More paths in the same family
Rhino Security Labs published a widely cited list of 21 IAM privilege-escalation methods in 2018, and more have been found since. The ones most likely to be granted by accident:
| Permission | Why it escalates |
|---|---|
iam:PassRole + create a compute resource |
Run code as a stronger role (above) |
iam:CreatePolicyVersion |
Rewrite a policy attached to you and set it as default |
iam:AttachRolePolicy, iam:PutRolePolicy |
Add any permissions to a role you can use |
iam:UpdateAssumeRolePolicy |
Add yourself to a powerful role's trust policy |
iam:CreateAccessKey on other users |
Mint keys for someone with more access |
lambda:UpdateFunctionCode |
Replace the code of a function that already has a strong role |
Worked example: scoping PassRole
The safe version names the roles and the service:
{
"Effect": "Allow",
"Action": "iam:PassRole",
"Resource": "arn:aws:iam::111122223333:role/app/payments/*",
"Condition": {"StringEquals": {
"iam:PassedToService": "lambda.amazonaws.com"}}
}
Now the developer can only pass roles under the /app/payments/ path, and only to Lambda. Two
more guardrails complete it:
- If teams create their own roles, require a permissions boundary on every role they create
(the
iam:PermissionsBoundarycondition key oniam:CreateRole), so nothing they make can exceed the boundary (L27's deep dive). - Find existing paths with tools: Access Analyzer custom policy checks (paid) can flag statements
that grant
iam:PassRolebroadly; open-source PMapper (Principal Mapper) and Cloudsplaining graph or list escalation risks. Check each project's maintenance status before relying on it.
🧠 Extra takeaways
iam:PassRoleon*lets the holder run code as any role in the account.- Scope PassRole to role ARNs or paths, plus
iam:PassedToService. - Treat IAM-writing permissions (
CreatePolicyVersion,Put/AttachRolePolicy,UpdateAssumeRolePolicy) as admin. - Delegate role creation only with an enforced permissions boundary.
The quiz for this lesson and the Tests section need JavaScript.