The evaluation chain, in the order AWS actually applies it

The vendor-neutral decision model is developed in cloud IAM architecture and is not restated here. What follows is only the AWS instantiation, which stacks five distinct ceiling types in a fixed order. Knowing that order is what turns an inexplicable AccessDenied into a two-minute diagnosis.

for every signed request:

  1. explicit Deny in ANY applicable document   -> DENY, final
  2. service control policy on the account's OU -> not allowed? DENY
  3. resource control policy on the resource    -> not allowed? DENY
  4. permission boundary on the principal       -> not allowed? DENY
  5. session policy passed to AssumeRole        -> not allowed? DENY
  6. Allow present in the principal's own policy
       OR in the policy attached to the target   -> ALLOW
  7. nothing matched                            -> DENY (implicit)

Steps 2 through 5 never grant anything. They only subtract, which is why a role whose own policy is provably correct can still be denied. Service control policies are allow lists evaluated deny-by-default: the FullAWSAccess policy attached by default is the thing that makes every account work, and detaching it from an organizational unit denies every action not named by some remaining SCP in that branch.

Three AWS quirks make an over-broad SCP hard to find. SCPs never apply to the management account, so the change that broke every workload account tests clean from the org root. SCPs do not apply to service-linked roles, so the managed autoscaling or load-balancer path keeps working while your own role for the same service fails. And the IAM policy simulator does not evaluate SCPs at all - it will happily report Allow for a call the live API refuses. The one piece of luck is the error text: an SCP refusal says the request was denied with an explicit deny in a service control policy, which is the string that tells you to stop reading the role.

Advertisement

Identity policies and resource policies are two separate grants

AWS is unusual in letting the grant live entirely on the resource. An identity policy attaches to a user, group or role. A resource policy attaches to the object itself: S3 bucket policies, SQS queue policies, SNS topic policies, KMS key policies, Lambda function policies added through lambda:AddPermission, Secrets Manager secret policies, ECR repository policies, EFS file system policies, API Gateway resource policies - and the trust policy on every role.

Within one account, either side is sufficient. A role with no attached policies at all can read a bucket whose bucket policy names it. This is the single most important consequence for auditing: enumerating a role's attached and inline policies does not tell you what that role can do. The grant may be written into a resource policy in a different service by a different team, and nothing in the role's console page hints at it.

Across accounts, both sides must allow, independently. The caller's identity policy must permit the action on the target ARN and the owner's resource policy must permit the caller's principal. Neither account can grant unilaterally, which is exactly the property that makes cross-account sharing reviewable - the resource owner always has a veto.

KMS inverts the default. The key policy is authoritative, and an identity policy granting kms:Decrypt does nothing unless the key policy either names the principal directly or carries the default statement giving the account root kms:* - that statement is what delegates the decision back to IAM in the first place. Removing it produces a key nobody, including an account administrator, can manage or delete. S3 adds two more layers on top: legacy object ACLs, and Block Public Access, which is an override rather than a policy input and wins over both bucket policy and ACL.

One more trap in the Principal element: arn:aws:iam::111122223333:root does not mean the root user. It delegates the decision to that account's IAM, so anyone there who can write an identity policy can grant themselves the access.

IAM primitivesUserslong-lived keysRolesassumed, temp credsPoliciesJSON permissionsPrefer roles + STS for services; users only for humans (via SSO) or last-resort automation
IAM entities and permission model.
Advertisement

Trust policies, sts:AssumeRole, and the external ID

Every role carries two kinds of document doing unrelated jobs. Permission policies say what the role may do. The trust policy - formally AssumeRolePolicyDocument - says who may become it. The trust policy is the role's resource policy, and it is the only resource policy in AWS that is mandatory: a role cannot be created without one.

The Principal element decides the federation model. An account ARN delegates to that account's IAM. A specific role ARN pins it to one caller. {"Service": "ec2.amazonaws.com"} lets a service assume it on your behalf. {"Federated": "...:oidc-provider/..."} or a SAML provider ARN routes it through an external identity provider.

The service-principal form creates the confused-deputy problem in its familiar shape, and the aws:SourceArn / aws:SourceAccount pair that fixes it is developed in AWS IAM conditions. The third-party variant is different and is solved by a different key. A SaaS vendor that monitors your account holds one role ARN per customer and assumes them with its own credentials. Nothing stops customer A from typing customer B's role ARN into their own configuration; the vendor, acting on A's instruction, would then assume B's role. sts:ExternalId closes this: the vendor generates a value unique and unguessable per customer, the customer pins it in the trust policy with StringEquals, and the vendor must send it on every AssumeRole call. An external ID that is shared across customers, or is just the customer name, provides none of the protection - the whole mechanism rests on A never learning B's value.

Two limits worth carrying in your head. A session that assumes another role - role chaining - is capped at one hour no matter what the target role's MaxSessionDuration says. And RoleSessionName is not decoration: it is what appears in CloudTrail, and if federation does not propagate a human-identifying value into it, every audit trail entry attributes the action to an anonymous session.

iam:PassRole - the escalation vector with no API behind it

There is no PassRole API. Nothing ever calls it. iam:PassRole is the authorization check a different service performs when you hand it a role ARN to run as, and because it never appears as an action anyone invokes, it is routinely granted with "Resource": "*" and never revisited.

That combination is administrator. ec2:RunInstances plus unrestricted iam:PassRole means launching one instance with the account's admin role attached and reading its credentials out of the metadata service. lambda:CreateFunction plus lambda:InvokeFunction plus PassRole means the function body executes as that role. The same shape holds for glue:CreateJob, ecs:RegisterTaskDefinition with ecs:RunTask, codebuild:CreateProject, cloudformation:CreateStack with a service role, and SageMaker notebook creation.

The wildcard is usually not laziness but sequencing - PassRole is written before anyone knows which role names will exist. The durable fix is to create application roles under a dedicated path and scope to it, so the grant stays correct as roles are added:

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

Sibling IAM-write permissions reach the same place without touching PassRole. iam:CreatePolicyVersion lets a principal rewrite a policy it is already attached to. iam:SetDefaultPolicyVersion rolls a policy back to an older, broader version that still exists in history. iam:UpdateAssumeRolePolicy rewrites a powerful role's trust document to name the attacker. And lambda:UpdateFunctionCode on a function that already runs as a privileged role needs no IAM permission whatsoever - the role is already attached, you are only changing what it executes.

Instance profiles and IMDS - where EC2 credentials come from

An EC2 instance does not hold a role. It holds an instance profile, a container for exactly one role. The console creates the profile implicitly and gives it the role's name, which is why the two are usually confused; CloudFormation and Terraform do not, and that is the entire explanation for Invalid IAM Instance Profile name raised against a role that plainly exists.

Credentials arrive over the link-local metadata endpoint at 169.254.169.254. A GET on /latest/meta-data/iam/security-credentials/ returns the role name; appending it returns AccessKeyId, SecretAccessKey, Token and Expiration. Nothing is stored on disk and the SDK refreshes well before expiry.

Under IMDSv1 that is an unauthenticated GET, so any server-side request forgery in the application is credential theft - the classic path from a URL-fetching feature to an account compromise. IMDSv2 requires a session token first:

TOKEN=$(curl -sX PUT "http://169.254.169.254/latest/api/token" \
  -H "X-aws-ec2-metadata-token-ttl-seconds: 21600")

curl -s -H "X-aws-ec2-metadata-token: $TOKEN" \
  http://169.254.169.254/latest/meta-data/iam/security-credentials/

Two properties do the work. The initial PUT defeats SSRF primitives that can only issue GETs, and the token response carries a hop limit so it cannot be relayed through a proxy or a second host. That hop limit is the operational catch: HttpPutResponseHopLimit defaults to 1, which stops a container on a bridge network from reaching IMDS at all. Raising it to 2 fixes the container and simultaneously re-exposes the host role to every other container on that instance. Prefer per-task or per-pod roles over the bump.

Set HttpTokens: required in the launch template so v1 is off from birth rather than remediated later. An instance credential used from anywhere other than the instance is precisely what GuardDuty's instance-credential-exfiltration finding detects, because STS can compare the calling address against the instance the session was minted for.

IRSA and EKS Pod Identity - roles for Kubernetes pods

By default every pod on an EKS node can reach IMDS and obtain the node's instance role. The node role therefore has to be the union of every workload's permissions on that node, and any pod can use all of them. Both AWS mechanisms for fixing this exist to break that union apart.

IRSA uses OIDC. The cluster publishes a discovery document; you register that issuer once as an IAM OIDC identity provider, then annotate the Kubernetes service account:

apiVersion: v1
kind: ServiceAccount
metadata:
  name: payments
  namespace: prod
  annotations:
    eks.amazonaws.com/role-arn: arn:aws:iam::123456789012:role/payments

A mutating admission webhook injects a projected service account token with audience sts.amazonaws.com, plus the AWS_ROLE_ARN and AWS_WEB_IDENTITY_TOKEN_FILE environment variables. The SDK finds them, calls AssumeRoleWithWebIdentity, and re-reads the file as the projected token is rotated by the kubelet.

The security of the whole arrangement lives in one condition block in the trust policy, and it must pin the subject, not just the audience:

"Condition": { "StringEquals": {
  "oidc.eks.eu-west-1.amazonaws.com/id/EXAMPLE:aud": "sts.amazonaws.com",
  "oidc.eks.eu-west-1.amazonaws.com/id/EXAMPLE:sub":
      "system:serviceaccount:prod:payments"
}}

Pinning only aud lets every service account in the cluster assume the role. A StringLike written as system:serviceaccount:*:payments is the same bug with a namespace wildcard - anyone who can create a namespace can take the role.

IRSA's cost shows up at scale: one IAM OIDC provider per cluster, and every trust policy must be rewritten when a cluster is rebuilt with a new issuer URL. EKS Pod Identity removes that. An agent runs as a DaemonSet, the mapping is an association object created through the EKS API rather than an annotation, and the trust policy names the pods.eks.amazonaws.com service principal with sts:AssumeRole and sts:TagSession. There is no issuer in the document, so one role works across clusters and survives a rebuild.

Neither is worth anything until pods are cut off from IMDS - hop limit of 1, or a network policy denying 169.254.169.254. If a pod can still ask the node for its role, per-pod roles are decorative.

Credential types and how long they actually live

CredentialLifetime
Root user password and access keyNo expiry. Delete the keys, keep hardware MFA.
IAM user access keyNo expiry until rotated or disabled.
AssumeRole session15 min to the role's MaxSessionDuration (1h default, 12h max).
Role chaining1 hour, hard cap, regardless of the target role's setting.
AssumeRoleWithWebIdentity / WithSAMLSame range, additionally bounded by the incoming token.
GetSessionToken (IAM user)15 min to 36 hours, 12 hours by default.
GetSessionToken (root)15 min to 1 hour.
GetFederationToken15 min to 36 hours, 12 hours by default.
Instance profile credentials via IMDSRotated automatically; the SDK refreshes ahead of expiry.

MaxSessionDuration is a property of the role, not of the caller, and a DurationSeconds above it fails the call rather than clamping to the maximum. That asymmetry is the usual reason a pipeline that requests a twelve-hour session works in one account and errors in another.

The property that matters most operationally is that a session credential cannot be revoked. It is self-contained and remains valid until it expires; deleting the role's policies does not invalidate the token already issued. The console's "Revoke active sessions" button does not revoke anything either - it attaches an inline policy named AWSRevokeOlderSessions to the role that denies every action for sessions issued before a timestamp. That is the real mechanism, and it is why a 12-hour MaxSessionDuration chosen for convenience is a measurable incident-response cost.

What a role actually used - last-accessed data and the credential report

Deriving least privilege from audit logs, and the Access Analyzer policy-generation loop, belong to cloud IAM architecture; the policy simulator and the condition-key catalogue belong to AWS IAM conditions. Two AWS data sources sit outside both and are worth naming because neither needs a log pipeline to exist first.

Service last-accessed data. GenerateServiceLastAccessedDetails takes any user, role, group or policy ARN and reports, per service, when an entity last used it over a trailing window. It answers "which of the forty services this policy grants has this role never once touched", which is the question that prunes a wildcard policy down to something reviewable. A limited set of services also report action-level detail; most are service-level only, so treat it as a tool for cutting breadth rather than tuning a narrow policy.

The credential report. GenerateCredentialReport produces one CSV per account covering every IAM user: password age and last use, both access key slots with age and last-used service, and MFA status. It is the cheapest way to find the six-year-old access key belonging to a contractor who left, and it is a single API call per account.

Both look only backwards. A permission exercised once a quarter by a failover drill or a year-end job is indistinguishable from a permission that is genuinely dead, which is why the shadow-mode step before enforcing a narrowed policy is not optional.

AWS diverges from the generic IAM model in four places worth internalising. A resource policy can grant access with no identity policy behind it, so reading a role's attachments is not an audit. The evaluation chain stacks five separate ceilings, an SCP refusal names itself in the error text, and the policy simulator ignores SCPs entirely. iam:PassRole with a wildcard resource is administrator, and it is invisible because no API by that name exists. And every workload credential ultimately comes from a metadata endpoint - lock it to IMDSv2 with a hop limit of 1 before per-task or per-pod roles mean anything at all.