Background

IAM and Identity

14 min read

Goal: be able to answer "why was this denied?" as a procedure rather than a guess. IAM looks like a sprawl of per-service permissions; it isn't. It's five policy types combined by one evaluation algorithm that never changes.

This is the page that repays rereading. Nearly every AWS security incident and a large share of day-to-day frustration traces back to something here.


1. The vocabulary

Every authorisation decision answers one question: may this principal perform this action on this resource under these conditions?

Term What it means
Principal Who is asking — an IAM user, a role session, an AWS service, or an anonymous caller
Action The API operation, namespaced by service: s3:GetObject, ec2:RunInstances
Resource What it's being performed on, as an ARN
Condition Extra requirements on the request context — source IP, MFA present, region, tags
Request context Everything AWS knows about the call: principal, action, resource, time, source, MFA state, and more

IAM is deny-by-default. With no policy anywhere, nothing is permitted. Every allowance is something you granted, directly or through a managed policy.

Users vs. roles

IAM user IAM role
Credentials Long-lived password and/or access keys Temporary, issued on assumption, auto-expiring
Attached to A specific person or application Assumed by anyone (or anything) the trust policy permits
Rotation Manual, and therefore usually neglected Automatic — the credentials expire on their own
Audit The user is the actor The session records who assumed the role

Use roles. Almost always. Human access should come from IAM Identity Center (which issues role sessions), and workloads should use instance profiles, task roles, IRSA/Pod Identity, or OIDC federation. The remaining legitimate uses for IAM users are narrow — a handful of legacy integrations that genuinely cannot federate — and each one is a long-lived credential someone must rotate and could leak.


2. The five policy types

This is where the confusion usually starts, because they look similar and do different jobs.

Type Attached to Purpose Has a Principal element?
Identity-based User, group, or role Grants permissions to that identity No — the identity is implied
Resource-based The resource itself (S3 bucket, KMS key, SQS queue, Lambda function…) Grants access to the resource, including to principals in other accounts Yes — it must say who
Service Control Policy (SCP) Organisation root, OU, or account A ceiling on what any principal in the account may do No
Permission boundary A user or role A ceiling on what that identity may do, regardless of its own policies No
Session policy Passed at AssumeRole or federation time A ceiling for that session only No

Three of these five only ever restrict. SCPs, permission boundaries, and session policies never grant anything — they cap. Only identity-based and resource-based policies grant.

Resource-based policies are the ones people forget. They're how cross-account access works without the target account handing out credentials, and how a bucket becomes public. If you audit only identity policies, you have audited half the system.

Resource Control Policies (RCPs) are a newer organisation-level ceiling on what resource policies may permit — closing the gap where a resource policy could grant access to an external principal despite your SCPs. ⚠️ verify availability and supported services against current AWS docs

The five IAM policy types and where each attaches: identity-based on users and roles, resource-based on resources, SCPs on organizational units, permission boundaries on identities, and session policies on assumed sessions


3. The evaluation algorithm

Commit this to memory. It answers nearly every "why is this denied".

                      Request arrives
                            │
              ┌─────────────▼─────────────┐
              │ Explicit DENY anywhere?   │──── yes ──▶ ✗ DENIED
              │ (any policy type at all)  │             final, unappealable
              └─────────────┬─────────────┘
                            │ no
              ┌─────────────▼─────────────┐
              │ SCP allows the action?    │──── no ───▶ ✗ DENIED
              │ (member accounts only)    │             account admin cannot fix
              └─────────────┬─────────────┘
                            │ yes
              ┌─────────────▼─────────────┐
              │ Permission boundary and   │──── no ───▶ ✗ DENIED
              │ session policy allow?     │
              └─────────────┬─────────────┘
                            │ yes
              ┌─────────────▼─────────────┐
              │ Identity-based OR         │──── no ───▶ ✗ DENIED
              │ resource-based policy     │             (implicit deny)
              │ explicitly allows?        │
              └─────────────┬─────────────┘
                            │ yes
                        ✓ ALLOWED

The three rules that fall out of this:

  1. An explicit Deny always wins. No allow anywhere overrides it. This is why a broad deny in an SCP or a boundary is such a blunt instrument — and such an effective one.
  2. Ceilings must all permit. SCP, permission boundary, and session policy each act as an upper bound. Any one of them omitting the action denies it, and no amount of identity policy helps.
  3. Something must explicitly allow. Absence of an allow is a deny. There is no "not mentioned means fine".

The cross-account rule is cleaner and worth memorising separately: for access across accounts, both sides must allow — an identity policy in the caller's account permitting the action, and a resource policy (or an assumable role) in the target account permitting the caller. Neither side can unilaterally grant access to the other. Within a single account, a resource-based policy that allows the principal can be sufficient on its own.

There are documented subtleties in how resource-based policies interact with permission boundaries and with different principal types. For everyday reasoning the flow above is correct; when the answer genuinely matters (an audit, a cross-account design review), check the current policy-evaluation documentation rather than trusting a remembered diagram — including this one. ⚠️ verify edge-case ordering against current AWS docs

The IAM policy evaluation flow: explicit deny, then service control policies, then permission boundaries and session policies, then identity and resource policies, defaulting to implicit deny


4. Reading a policy

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "ReadArtifactsFromOwnPrefix",
      "Effect": "Allow",
      "Action": ["s3:GetObject", "s3:ListBucket"],
      "Resource": [
        "arn:aws:s3:::acme-artifacts",
        "arn:aws:s3:::acme-artifacts/api/*"
      ],
      "Condition": {
        "StringEquals": { "aws:PrincipalTag/Team": "platform" },
        "Bool": { "aws:SecureTransport": "true" }
      }
    }
  ]
}
Element Notes
Version Always "2012-10-17". It's a policy-language version, not a date you change
Sid A label. Useful in review and in debugging; ignored by evaluation
Effect Allow or Deny
Action Service-namespaced operations. Wildcards allowed (s3:Get*)
Resource ARNs. * means every resource — rarely what you want for data actions
Principal Resource-based policies only — who is being granted access
Condition Key/operator/value tests against the request context

Two details that cause real bugs:

  • ListBucket acts on the bucket; GetObject acts on objects. They need different ARNs — one without a trailing path, one with /*. Getting this wrong produces "I can read files but can't list them", or vice versa, and it's the single most common S3 policy mistake.
  • NotAction and NotResource are inverted matches, not denies. "NotAction": ["iam:*"] with "Effect": "Allow" grants everything except IAM — an enormous grant that reads like a restriction. Use them sparingly and read them twice.

Managed vs. inline

Managed policy Inline policy
Reusable across identities ❌ — belongs to exactly one
Versioned with rollback ✅ (customer-managed)
Deleted with the identity
Good for Anything shared; the default choice A permission that must never be reused elsewhere

AWS-managed policies are convenient and usually too broad. AmazonS3FullAccess on a service role is not least privilege. They're fine for bootstrapping and for a few genuinely well-scoped ones (AmazonSSMManagedInstanceCore), but production roles should carry customer-managed policies you wrote.


5. Roles and trust

A role has two policies, and conflating them is a common source of confusion:

Policy Question it answers
Trust policy (a resource-based policy on the role) Who may assume this role?
Permission policy (identity-based) What may this role do once assumed?
// Trust policy — an EC2 instance may assume this role
{
  "Version": "2012-10-17",
  "Statement": [{
    "Effect": "Allow",
    "Principal": { "Service": "ec2.amazonaws.com" },
    "Action": "sts:AssumeRole"
  }]
}
// Trust policy — GitHub Actions via OIDC, scoped to one repo and branch
{
  "Version": "2012-10-17",
  "Statement": [{
    "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/infra:ref:refs/heads/main"
      }
    }
  }]
}

That sub condition is load-bearing. Without it, any GitHub repository in the world could assume your role — the OIDC provider vouches that the request came from GitHub Actions, not that it came from your Actions. Scope it to the repository, and ideally to the branch or environment. This is a frequently-published misconfiguration.

Role chaining — assuming a role from an already-assumed role — works, but the resulting session carries a shorter maximum duration. Long-running jobs that chain roles fail at the one-hour mark for reasons that look mysterious. ⚠️ verify current chaining duration limits against AWS docs

iam:PassRole is separate from the permission to create the resource. Launching an instance with a role attached needs ec2:RunInstances and iam:PassRole on that specific role ARN. It exists to stop someone who can create compute from attaching an administrator role to a machine they control — scope it to specific roles, never *.

Role assumption: the trust policy answering who may assume the role, and the permission policy answering what the assumed session may do


6. Conditions — where real least privilege lives

Actions and resources alone rarely express intent precisely. Conditions do.

Condition key Restricts to
aws:PrincipalOrgID Principals inside your organisation — excellent on resource policies
aws:SourceVpce Requests arriving via a specific VPC endpoint
aws:SourceIp An IP range (careful: not meaningful for calls via VPC endpoints)
aws:RequestedRegion Specific regions
aws:MultiFactorAuthPresent Sessions that used MFA
aws:SecureTransport TLS only
aws:PrincipalTag/*, aws:ResourceTag/* Tag-matching — the basis of ABAC
aws:SourceArn, aws:SourceAccount The specific caller a service is acting on behalf of

aws:SourceArn and aws:SourceAccount prevent the confused deputy. When an AWS service calls another service for you — S3 invoking Lambda, SNS publishing to a queue — the service is the principal. Without these conditions, anyone's bucket could trigger your function. Any resource policy granting access to a service principal should constrain the source.

{
  "Effect": "Allow",
  "Principal": { "Service": "s3.amazonaws.com" },
  "Action": "lambda:InvokeFunction",
  "Resource": "arn:aws:lambda:eu-west-1:123456789012:function:process-upload",
  "Condition": {
    "StringEquals": { "aws:SourceAccount": "123456789012" },
    "ArnLike": { "aws:SourceArn": "arn:aws:s3:::acme-uploads" }
  }
}

ABAC — permissions from tags

Rather than writing a policy per project, match tags on the principal against tags on the resource:

{
  "Effect": "Allow",
  "Action": ["ec2:StartInstances", "ec2:StopInstances"],
  "Resource": "*",
  "Condition": {
    "StringEquals": {
      "aws:ResourceTag/Team": "${aws:PrincipalTag/Team}"
    }
  }
}

One policy, scaling to any number of teams. The catch is that it's only as good as your tagging — an untagged resource matches nobody, and a mistagged one is exposed to the wrong team. ABAC requires tag enforcement (tag policies, SCPs requiring tags on create) to be trustworthy. See ARNs, Tagging & Quotas.


7. Where credentials come from in practice

Context Mechanism What you avoid
Humans IAM Identity Center permission sets IAM users, per-account logins, orphaned accounts
EC2 Instance profile Keys on disk
ECS / Fargate Task role Keys in the image or environment
EKS IRSA or EKS Pod Identity Node-wide credentials shared by every pod
Lambda Execution role Anything stored in the function config
CI/CD OIDC federation to a role Long-lived keys in a secret store
Third-party SaaS Cross-account role with ExternalId Handing over an access key

The pattern is uniform: no long-lived credentials anywhere. Each mechanism produces short-lived, automatically-rotated credentials scoped to one workload. If you find yourself creating an access key, stop and ask which of the above should have applied.

A note on EKS: node-level instance profiles are shared by every pod on the node, so any pod inherits the node role's permissions. IRSA and Pod Identity exist to give each workload its own identity — the difference between "this pod can read one bucket" and "everything on this node can do whatever the node can".


8. Debugging a denial

Work it in this order:

  1. Read the error message. AWS denial messages have become considerably more informative and often name the policy type responsible — an explicit deny, an SCP, or a missing permission. Read it before theorising.
  2. aws sts get-caller-identity. Confirm which principal is actually making the call. A surprising share of denials are the wrong profile or a stale environment variable.
  3. Can the account administrator grant it? If not, it's an SCP or a permission boundary — escalate to whoever manages the organisation rather than editing IAM in circles.
  4. Is there an explicit Deny? Search every applicable policy. One Deny beats every allow.
  5. Check the resource policy too, not just identity policies — especially for S3, KMS, SQS, SNS, Secrets Manager, and Lambda. Cross-account access needs both sides.
  6. Check conditions. MFA absent, wrong region, wrong VPC endpoint, a tag that doesn't match — the permission is present but the condition failed.
  7. Use the tools. The IAM policy simulator evaluates a specific call against current policies. IAM Access Analyzer finds resources exposed outside your account and validates policies as you write them. CloudTrail records the failed call with its error code.

The most useful single heuristic: if an account administrator with AdministratorAccess cannot fix it by editing IAM, it isn't IAM. It's an SCP, a permission boundary, or a resource policy in another account.


9. Anti-patterns

Anti-pattern Why it hurts Instead
IAM users with access keys Long-lived credentials that leak and nobody rotates Roles, federation, OIDC
"Action": "*", "Resource": "*" on workload roles Any application bug becomes an account compromise Scope actions and resource ARNs
AWS-managed *FullAccess policies in production Far broader than the workload needs Customer-managed policies you wrote
One shared role for several applications Each inherits the others' permissions One role per workload
An OIDC trust policy without a sub condition Any repository on the internet can assume your role Scope to repo, branch, environment
Resource policy for a service principal without aws:SourceArn Confused deputy — anyone's resource can trigger yours Constrain source ARN and account
iam:PassRole on * Lets a compute-creator attach an admin role to a box they control Scope to specific role ARNs
Auditing identity policies only Resource policies grant access too, including publicly Audit both; run Access Analyzer
NotAction used as if it were a deny Grants everything except the listed items Prefer explicit Action lists
Permissions accreting forever Roles only ever grow Access Analyzer unused-access findings, on a schedule

Check yourself

  • State the evaluation order. Where can an explicit Deny appear, and what beats it?
  • Three policy types can only restrict. Which, and why does that matter when debugging?
  • What must be true on both sides for cross-account access to work?
  • Why does an OIDC trust policy without a sub condition constitute a serious vulnerability?
  • Why is s3:ListBucket on arn:aws:s3:::my-bucket/* wrong?
  • An admin with AdministratorAccess gets AccessDenied and can't fix it in IAM. Name the two likely causes.
  • What does iam:PassRole prevent, and why is it a separate permission?

Next: Regions & Availability — the geography every design decision sits on.

← Back to the Foundations overview · ← Previous: The API & Control Plane