Skip to main content
Cloud Interviews beginner Lesson 5 of 10

IAM and Least Privilege

How a policy decision is actually evaluated, why an explicit deny always wins, roles versus long-lived keys, and the wildcard that looks scoped and is not.

Security rounds are not about reciting policy JSON. They are about the evaluation order, and about noticing that a policy which looks scoped is not.

The evaluation order

1. DEFAULT DENY            nothing is permitted until something allows it

2. EXPLICIT DENY?          any Deny, in any applicable policy → DENIED, stop
                           This is absolute. No Allow overrides it.

3. SERVICE CONTROL POLICY  organisation-level cap. Not permitted here → DENIED

4. RESOURCE POLICY         a bucket policy or queue policy may allow directly

5. IDENTITY POLICY         what the user or role is granted

6. PERMISSION BOUNDARY     a per-principal cap. Outside it → DENIED

7. SESSION POLICY          a further cap on assume-role sessions

The rule to state: an explicit deny always wins, and every guardrail must permit independently. An Allow in an identity policy means nothing if a service control policy does not also permit the action.

aws iam simulate-principal-policy \
  --policy-source-arn arn:aws:iam::123456789012:role/app-role \
  --action-names s3:GetObject s3:DeleteObject \
  --resource-arns arn:aws:s3:::prod-data/reports/q1.csv \
  --query 'EvaluationResults[].{action:EvalActionName,decision:EvalDecision}' --output table
--------------------------------------------------
|           SimulatePrincipalPolicy              |
+-------------------+----------------------------+
|      action       |         decision           |
+-------------------+----------------------------+
|  s3:GetObject     |  allowed                   |
|  s3:DeleteObject  |  explicitDeny              |
+-------------------+----------------------------+

simulate-principal-policy answers “would this work” without doing it. Naming it is a good response to “how would you verify a permission change before shipping it” — better than “I’d try it in staging”.

The wildcard that is not scoped

{
  "Version": "2012-10-17",
  "Statement": [{
    "Effect": "Allow",
    "Action": "s3:*",
    "Resource": "arn:aws:s3:::prod-data/*"
  }]
}

This looks scoped to one bucket. It is not:

aws iam simulate-custom-policy --policy-input-list file://policy.json \
  --action-names s3:DeleteBucket s3:PutBucketPolicy s3:GetObject \
  --resource-arns "arn:aws:s3:::prod-data" "arn:aws:s3:::prod-data/x" \
  --query 'EvaluationResults[].{a:EvalActionName,r:EvalResourceName,d:EvalDecision}' --output table
+---------------------+---------------------------+------------------+
|          a          |             r             |        d         |
+---------------------+---------------------------+------------------+
|  s3:DeleteBucket    |  arn:aws:s3:::prod-data   |  implicitDeny    |
|  s3:PutBucketPolicy |  arn:aws:s3:::prod-data   |  implicitDeny    |
|  s3:GetObject       |  arn:aws:s3:::prod-data/x |  allowed         |
+---------------------+---------------------------+------------------+

The bucket-level actions are denied here — because prod-data/* does not match the bucket ARN prod-data itself, only objects inside it. That subtlety cuts both ways and is worth being precise about:

arn:aws:s3:::prod-data       the BUCKET     — DeleteBucket, PutBucketPolicy, ListBucket
arn:aws:s3:::prod-data/*     the OBJECTS    — GetObject, PutObject, DeleteObject

A policy granting only prod-data/* cannot list the bucket, which breaks aws s3 ls while aws s3 cp works — a confusing failure that has a precise cause.

What s3:* on prod-data/* does still grant is every object action including DeleteObject, PutObjectAcl, and object-level replication configuration. “Read-only access to the bucket” written this way is not read-only.

{
  "Version": "2012-10-17",
  "Statement": [{
    "Effect": "Allow",
    "Action": ["s3:GetObject", "s3:ListBucket"],
    "Resource": ["arn:aws:s3:::prod-data", "arn:aws:s3:::prod-data/reports/*"],
    "Condition": {
      "IpAddress": {"aws:SourceIp": "203.0.113.0/24"},
      "Bool":      {"aws:SecureTransport": "true"}
    }
  }]
}

Named actions, both ARN forms, a prefix rather than the whole bucket, and two conditions. That is what “least privilege” looks like written out, and it is longer than the version people actually ship.

Roles, not keys

# what a role session actually returns
aws sts assume-role --role-arn arn:aws:iam::123456789012:role/app-role \
  --role-session-name demo --query 'Credentials.{expires:Expiration}' --output text
2026-09-10T13:42:07+00:00

One hour by default, up to twelve. A leaked session credential is worthless tomorrow.

# find long-lived keys and how stale they are
aws iam list-users --query 'Users[].UserName' --output text | \
  xargs -n1 -I{} aws iam list-access-keys --user-name {} \
  --query 'AccessKeyMetadata[].{user:UserName,age:CreateDate,status:Status}' --output text
ci-deploy    2021-04-11T09:22:41+00:00    Active
legacy-etl   2019-11-02T16:05:18+00:00    Active
backup-bot   2023-07-30T11:48:03+00:00    Active

A key from 2019, still active. This is the finding in most real audits, and it is what the question “how would you improve this account’s security posture” is fishing for.

INSTEAD OF                          USE
access keys on an EC2 instance      an instance profile (role)
access keys in a container          a task or pod identity role
access keys in CI                   OIDC federation from the CI provider
access keys for a developer         SSO with short-lived sessions
access keys for a partner           a cross-account role they assume
access keys                         almost anything else

OIDC federation for CI is the one worth naming specifically. GitHub Actions, GitLab, and CircleCI can all exchange a signed workflow token for a short-lived cloud role — no stored secret at all, and the trust policy can be scoped to a specific repository and branch:

{
  "Effect": "Allow",
  "Principal": {"Federated": "arn:aws:iam::123456789012:oidc-provider/token.actions.githubusercontent.com"},
  "Action": "sts:AssumeRoleWithWebIdentity",
  "Condition": {
    "StringEquals": {"token.actions.githubusercontent.com:aud": "sts.amazonaws.com"},
    "StringLike":   {"token.actions.githubusercontent.com:sub": "repo:acme/api:ref:refs/heads/main"}
  }
}

The sub condition is the important line. Without it, any repository on GitHub can assume the role — a real misconfiguration that has been exploited, and a good thing to be able to point at.

Building least privilege from evidence

Nobody successfully narrows a wildcard policy afterwards, because nothing breaks while it is too broad. The workable order is the reverse:

# what did this role actually use?
aws iam get-service-last-accessed-details --job-id $(
  aws iam generate-service-last-accessed-details \
    --arn arn:aws:iam::123456789012:role/app-role --query JobId --output text
) --query 'ServicesLastAccessed[?TotalAuthenticatedEntities>`0`].{svc:ServiceName,last:LastAuthenticated}' \
  --output table
-----------------------------------------------------------
|            GetServiceLastAccessedDetails                |
+----------------------+----------------------------------+
|         svc          |              last                |
+----------------------+----------------------------------+
|  Amazon S3           |  2026-09-10T08:14:22+00:00       |
|  Amazon SQS          |  2026-09-10T08:14:19+00:00       |
|  AWS KMS             |  2026-09-09T22:03:51+00:00       |
+----------------------+----------------------------------+

The role has permissions for eleven services and has used three in the last year. That report is the evidence, and IAM Access Analyzer will generate a policy from CloudTrail history directly:

aws accessanalyzer start-policy-generation \
  --policy-generation-details principalArn=arn:aws:iam::123456789012:role/app-role \
  --cloud-trail-details 'trails=[{cloudTrailArn=arn:aws:cloudtrail:us-east-1:123456789012:trail/main,allRegions=true}],accessRole=arn:aws:iam::123456789012:role/analyzer,startTime=2026-06-01T00:00:00Z'
{
    "jobId": "e1f2a3b4-5c6d-7e8f-9012-3456789abcde"
}

“I’d generate the policy from what the role actually called rather than writing it from imagination. Access Analyzer reads CloudTrail and produces a policy covering exactly the observed calls. Then I review it — the observation window has to be long enough to include the monthly job and the disaster-recovery path, which is the part that gets missed.”

That caveat is what makes the answer credible. A ninety-day window that misses the quarterly close breaks the quarterly close.

The escalation paths worth knowing

PERMISSION                          WHY IT IS EFFECTIVELY ADMIN
iam:CreatePolicyVersion             rewrite any policy you can name
iam:AttachRolePolicy                attach AdministratorAccess to yourself
iam:PassRole (unscoped)             launch a resource as any role
iam:CreateAccessKey                 issue keys for a more privileged user
lambda:UpdateFunctionCode           run code as the function's role
ec2:RunInstances + iam:PassRole     boot an instance with an admin profile
sts:AssumeRole (unscoped)           become anything with a permissive trust

Unscoped iam:PassRole is the one people miss. A policy granting ec2:RunInstances and iam:PassRole on Resource: "*" lets the holder launch an instance carrying the admin role and then use it — full escalation from what reads like a deployment permission.

The fix is to scope it:

{
  "Effect": "Allow",
  "Action": "iam:PassRole",
  "Resource": "arn:aws:iam::123456789012:role/app-runtime-role",
  "Condition": {"StringEquals": {"iam:PassedToService": "ec2.amazonaws.com"}}
}

Being able to name two or three of these escalation paths is a strong differentiator in a security-flavoured round, because it demonstrates that you read policies for what they enable rather than for what they are called.

Cross-account access

                    ACCESS KEYS SHARED       CROSS-ACCOUNT ROLE
credential          long-lived, theirs       short-lived, issued per session
revocation          rotate, tell everyone    delete the trust policy
audit trail         one identity, many users each session named and logged
scoping             account-wide             per-role, with conditions
external partner    never do this            plus an ExternalId condition

The sts:ExternalId condition prevents the confused deputy problem: a third-party vendor with a role in many customers’ accounts could otherwise be tricked into using their access to your account on someone else’s behalf. The external id is a shared secret proving the request came from the right customer relationship.

Explaining why the external id exists, not just that it does, is what the question is for.

Recognising it

QUESTION                                        ANSWER
"how do you do least privilege?"                from evidence — Access Analyzer, not imagination
"user has the permission but gets denied"       explicit deny, or an SCP / boundary caps it
"how do you give CI access?"                    OIDC federation, scoped to repo and branch
"how do you give a partner access?"             cross-account role + ExternalId
"secrets in the pipeline?"                      there should not be any — use role assumption
"this policy is scoped to one bucket"           check for both ARN forms and s3:* actions
"how do you stop a team escalating?"            permission boundary on the roles they create
"how do you audit this account?"                stale access keys, unscoped PassRole, root usage
"root account?"                                 MFA, no keys, alarm on any use of it

Practice

1. Grant s3:* on arn:aws:s3:::bucket/* and call it read-only.
Every object action is granted, including DeleteObject and PutObjectAcl.
Bucket-level actions are denied — so `aws s3 ls` fails while `aws s3 cp` works.

Both ARN forms are needed for a working read policy, and the actions must be named.

2. Simulate a permission before shipping it.
s3:GetObject allowed      s3:DeleteObject explicitDeny

simulate-principal-policy answers “would this work” without doing it — a better answer than “I’d try it in staging”.

3. Set up CI federation without the sub condition.
Any repository on GitHub can assume the role.

A real, exploited misconfiguration. Scope the trust policy to the repository and branch.

4. Grant ec2:RunInstances and unscoped iam:PassRole.
Effectively administrator — launch an instance carrying the admin role, then use it.

Scope PassRole to specific roles and add iam:PassedToService. This is the escalation path people miss.

Next: availability and failure — regions, zones, RTO and RPO, and the arithmetic of nines.

Frequently Asked Questions

How is an IAM decision evaluated?
Default deny, then any explicit Deny anywhere wins outright, then an Allow is needed from an identity or resource policy, and every applicable guardrail — service control policies, permission boundaries, session policies — must also permit it. One deny anywhere in that chain ends the evaluation.
Why use roles instead of access keys?
Roles issue short-lived credentials that rotate automatically, so a leaked credential expires in hours rather than living until someone notices. Long-lived keys end up in git history, CI configuration, and laptop dotfiles, and the majority of publicly reported cloud breaches start with one.
What is the difference between a permission boundary and a service control policy?
A service control policy sets the maximum permissions for every principal in an account or organisational unit and is set by the organisation. A permission boundary sets the maximum for one specific principal and is typically used so a team can create roles without being able to escalate beyond what you allowed. Neither grants anything — both only cap.
How do I actually build a least-privilege policy?
Start from deny, run the workload, read the access logs or the access analyser for what it actually called, and grant exactly that. Starting from a wildcard and narrowing is the approach everyone claims to take and nobody finishes, because nothing fails when the policy is too broad.