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.