Identity & Access Management

IAM, Identity Center, permission boundaries, and least privilege principles

Last generated

Lesson 4 of 31 available15 practice questions

SPACED REPETITION Β· 15 practice questions

Make this lesson stick.

Try 3 questions now. No account needed. Sample answers aren't saved.

Watch the Lesson Video28 min
The whole lesson, narrated: how AWS decides allow or deny, resource-based policies and cross-account access, permission boundaries, least privilege at scale, five failure patterns, and choosing the right guardrail

How AWS Actually Decides Allow or Deny

You've almost certainly hit this moment: you attach a policy with s3:* to a role, the console shows the policy is attached, and yet the very next API call comes back AccessDenied. Nothing about the identity policy looks wrong, so the instinct is to add more permissions β€” widen the wildcard, attach AdministratorAccess, anything to make the red error go away. That instinct is exactly backwards, because AWS never evaluates a single policy in isolation. It evaluates a request context against every policy attached anywhere in the chain β€” identity-based policies, resource-based policies, permission boundaries, service control policies (SCPs), and session policies β€” and combines them using rules that are simple individually but easy to get wrong in combination.

This section builds the mental model for that combination logic: default-deny, explicit deny, and effective permission as an intersection. Once that model is solid, a traced example will show exactly why a role with s3:* attached can still be denied s3:GetObject.

The Default-Deny Baseline

🎯 Key Principle: Every request to AWS starts with an implicit deny. No action is permitted until some applicable policy contains an explicit "Effect": "Allow" statement that matches the action, the resource, and any conditions in the request.

This is easy to state but has a consequence that trips people up constantly: attaching zero policies to a role doesn't leave it with "default" access to anything β€” it leaves it able to do nothing at all. There's no concept of a role inheriting baseline permissions from its account. If you create a brand-new IAM role and attach no policies, every single API call that role attempts fails, not because something is denying it, but because nothing has ever allowed it.

The practical implication is that you build up permissions additively. A typical identity-based policy looks like this:

{
 "Version": "2012-10-17",
 "Statement": [
 {
 "Sid": "AllowS3ReadOnBucket",
 "Effect": "Allow",
 "Action": ["s3:GetObject", "s3:ListBucket"],
 "Resource": [
 "arn:aws:s3:::reports-bucket",
 "arn:aws:s3:::reports-bucket/*"
 ]
 }
 ]
}

With only this policy attached, a call to s3:PutObject on reports-bucket isn't blocked by any rule β€” it simply never had a chance to be allowed. Default-deny and "deny by rule" produce the same AccessDenied error message, but they are different failure modes, and distinguishing them matters once you're troubleshooting: a default-deny failure means you're missing an Allow somewhere; a rule-based deny (covered next) means something is actively vetoing an Allow you already have.

Explicit Deny Always Wins

🎯 Key Principle: If any applicable policy β€” identity-based, resource-based, permission boundary, SCP, or session policy β€” contains an explicit "Effect": "Deny" that matches the request, the request is denied, full stop, regardless of how many other policies say Allow.

This is not a tiebreaker or a "last policy wins" rule β€” deny has absolute priority over allow, from any source, evaluated first conceptually. An explicit deny in a permission boundary that nobody else on the team knows about will silently override an AdministratorAccess policy attached to the same role. An explicit deny in an SCP at the organization level will override every single Allow inside every account under that OU.

{
 "Version": "2012-10-17",
 "Statement": [
 {
 "Sid": "DenyOutsideCorpNetwork",
 "Effect": "Deny",
 "Action": "s3:GetObject",
 "Resource": "*",
 "Condition": {
 "NotIpAddress": {
 "aws:SourceIp": ["203.0.113.0/24"]
 }
 }
 }
 ]
}

Attach this statement anywhere in the chain and any s3:GetObject call from outside that IP range is denied, even for a principal that separately holds AdministratorAccess. This is precisely why explicit deny is the standard tool for hard guardrails β€” it can't be out-voted by a more permissive policy added later, which is also why it's a favorite mechanism for permission boundaries and SCPs, both covered in their own sections of this lesson.

⚠️ Common Mistake: teams add an explicit deny to "be extra safe," forget it exists, and then spend hours debugging why a seemingly correct new policy has no effect. Because explicit deny beats every allow with no exceptions, it should be reserved for guardrails you want to be genuinely un-overridable, not sprinkled in casually.

Effective Permission Is an Intersection, Not a Union

Here is the piece that explains the s3:* puzzle from the introduction. When more than one layer that has to allow the request applies to the same principal β€” an identity-based policy plus a permission boundary, or plus a session policy β€” they don't add together. The effective permission is the overlap between them.

🧠 Mnemonic: think of each policy layer as a stencil laid on top of the others. Light (an allowed call) only passes through where every stencil has a hole in the same place. Identity policy, boundary, and session policy each cut their own holes; the request only goes through where all of them line up. An explicit deny anywhere is a solid patch over the whole stack β€” no light gets through at all, no matter how the stencils align.

Formally, for a given request:

effective_permission =
 (identity_policy_allows)
 ∩ (boundary_allows, if a boundary is attached)
 ∩ (session_policy_allows, if a session policy is present)
 βˆ’ (any explicit deny anywhere in the chain)

A permission boundary or session policy that is absent doesn't restrict anything β€” the intersection only includes layers that are actually attached to that principal or that session. This is why an identity policy granting s3:* is not itself the final word: it is the widest possible ceiling the intersection can reach, but a narrower boundary or session policy pulls that ceiling down.

Worked Trace: One Role, Three Layers, Four Requests

Now trace this against a concrete role. DataAnalystRole is an assumable role with three things attached to it at different points: an identity-based policy that's intentionally broad, a permission boundary that caps it, and a session policy passed in at the moment the role is assumed (for example, by a federation broker or an application calling sts:AssumeRole with a Policy parameter).

Identity-based policy attached to DataAnalystRole:

{
 "Version": "2012-10-17",
 "Statement": [
 {
 "Sid": "BroadS3Access",
 "Effect": "Allow",
 "Action": "s3:*",
 "Resource": "*"
 }
 ]
}

Permission boundary attached to DataAnalystRole:

{
 "Version": "2012-10-17",
 "Statement": [
 {
 "Sid": "CapToReadOnly",
 "Effect": "Allow",
 "Action": "s3:GetObject",
 "Resource": "*"
 }
 ]
}

Session policy passed when the role is assumed for this particular session:

{
 "Version": "2012-10-17",
 "Statement": [
 {
 "Sid": "ScopedToOneBucket",
 "Effect": "Allow",
 "Action": ["s3:GetObject", "s3:ListBucket"],
 "Resource": [
 "arn:aws:s3:::reports-bucket",
 "arn:aws:s3:::reports-bucket/*"
 ]
 }
 ]
}

Here is the evaluation flow every request goes through, drawn as a straight-line process rather than a diagram with branches that could misalign:

Request arrives
 ↓
Step 1: Scan every applicable policy for an explicit Deny
 ↓ (none found β†’ continue; found β†’ DENY immediately, stop)
Step 2: Does the identity-based policy allow this action+resource?
 ↓
Step 3: Is a permission boundary attached? If so, does it also allow it?
 ↓
Step 4: Is a session policy present? If so, does it also allow it?
 ↓
Step 5: All attached layers allowed it β†’ ALLOW
 (any attached layer failed to allow it β†’ DENY, default-deny applies)

Request A β€” s3:GetObject on arn:aws:s3:::reports-bucket/2026-summary.pdf

  • Step 1: no explicit deny anywhere in the three policies. Continue.
  • Step 2: identity policy allows s3:* on Resource: * β†’ matches, allow.
  • Step 3: boundary allows s3:GetObject on Resource: * β†’ matches, allow.
  • Step 4: session policy allows s3:GetObject on reports-bucket/* β†’ the requested key matches, allow.
  • Step 5: all three layers allowed β†’ ALLOW.

Request B β€” s3:PutObject on the same object

  • Step 1: no explicit deny. Continue.
  • Step 2: identity policy allows s3:* β†’ includes PutObject, allow.
  • Step 3: the boundary's only statement allows s3:GetObject β€” PutObject is not in it, so the boundary contributes no allow for this action.
  • Step 5: the boundary layer failed to allow β†’ DENY. This is a default-deny outcome, not an explicit-deny outcome: the boundary never said "deny," it simply never granted this action, and the intersection can't include an action that one required layer never allowed.

Request C β€” s3:GetObject on arn:aws:s3:::confidential-bucket/secret.pdf

  • Step 1: no explicit deny. Continue.
  • Step 2: identity policy allows any resource, allow.
  • Step 3: boundary allows GetObject on any resource, allow.
  • Step 4: session policy only covers reports-bucket and its objects β€” confidential-bucket doesn't match, so the session layer contributes no allow for this resource.
  • Step 5: session layer failed to allow β†’ DENY. Same mechanism as Request B, different layer doing the narrowing.

Request D β€” s3:GetObject on reports-bucket/2026-summary.pdf, but the identity policy is amended to add an explicit deny outside the corporate IP range (as shown earlier), and the caller is on a coffee-shop connection

  • Step 1: the explicit deny statement matches β€” action is s3:GetObject, the condition NotIpAddress is true because the caller's IP isn't in the allowed range.
  • Evaluation stops immediately at Step 1 β†’ DENY, regardless of the fact that all three allow layers would otherwise have lined up exactly as in Request A.

πŸ’‘ Mental Model: the identity policy sets the outer boundary of what's imaginable, the permission boundary and session policy each cut that boundary down further, and an explicit deny anywhere is a trapdoor that bypasses the whole intersection calculation and ends the request immediately.

Where Resource-Based Policies and SCPs Sit in the Chain

The trace above covers identity-based policies, boundaries, and session policies β€” all of which live on the principal's side of the request. Two more policy types can enter the same evaluation, and it matters where they sit relative to everything above.

Service control policies (SCPs) apply at the AWS Organizations level, above every account they cover. An SCP doesn't grant any permission by itself β€” think of it as a second, wider intersection applied before the account-level evaluation even starts: if the SCP doesn't allow an action for a given account, no identity policy, boundary, or session policy inside that account can grant it, and an explicit deny in an SCP overrides everything below it the same way an explicit deny anywhere else does. The full mechanics of designing and layering SCPs across an organization are covered in the multi-account section of this roadmap; for this evaluation model, the takeaway is just that SCPs form an outer intersection layer that sits above the identity-based evaluation traced here.

Resource-based policies β€” an S3 bucket policy, a KMS key policy, a Lambda resource policy β€” are attached to the resource being accessed rather than to the calling principal. For a request made by a principal in the same account as the resource, AWS effectively OR's the resource-based policy's allow together with the identity-based policy's allow: either one granting access is enough, unless an explicit deny in either vetoes it. For a request from a principal in a different account, the resource-based policy becomes load-bearing in a different way β€” it's the standard way to grant that access without the calling principal assuming a role in the resource's account. That mechanic, along with the Principal/NotPrincipal pitfalls it introduces, is the entire subject of "Resource-Based Policies and the Two-Owners Problem," the next section β€” the point to carry forward here is simply that resource-based policies join the same evaluation as a peer to the identity-based side, not as a layer that further narrows it the way a boundary or session policy does.

πŸ“‹ Quick Reference Card:

πŸ”§ Policy type🎯 Role in evaluationβž• Can it grant alone?
πŸ§‘ Identity-basedBase allow set for the principalβœ… Yes
πŸ“¦ Resource-basedPeer allow set for same-account; grants cross-account without role assumptionβœ… Yes
🚧 Permission boundaryCaps the identity policy's allow❌ No, only narrows
πŸ”‘ Session policyCaps the identity policy's allow, per-session❌ No, only narrows
🏒 SCPCaps everything inside the account/OU❌ No, only narrows

The row for boundaries, session policies, and SCPs sharing "only narrows" is exactly what made Request B and Request C in the trace fail even though the identity policy alone would have allowed both: none of those three layers can hand out permission that the identity policy didn't already offer, they can only take permission away from what it offers.

⚠️ Common Mistake: reading s3:* on an identity policy and assuming that's the final answer for what a role can do. The identity policy tells you the maximum possible access if every other attached layer happens to agree β€” it's rarely the whole story once a boundary or session policy is involved, which is routine for federated and cross-account access patterns.

πŸ€” Did you know? The sts:AssumeRole API call itself accepts an optional Policy parameter that attaches exactly this kind of session policy for the lifetime of the temporary credentials it returns β€” it's how a federation broker can hand the same underlying role to different users with different effective scopes without maintaining a separate IAM role per user.

Resource-Based Policies and the Two-Owners Problem

Every permission check we traced in the previous section started from one place: the principal's own attached policies. But that's only half the picture. Many AWS resources β€” S3 buckets, KMS keys, Lambda functions, SQS queues, SNS topics β€” can carry a policy of their own, written by whoever owns the resource, independent of anything attached to the principal making the request. This creates a situation with two owners of the access decision: the identity's administrator controls what that identity can attempt, and the resource's administrator controls what can reach the resource. Neither owner can see or edit the other's policy, yet across accounts both must agree before access is granted (within one account, either policy is enough, as the previous section showed).

Identity-Based Policies vs. Resource-Based Policies

An identity-based policy is a JSON document attached to an IAM user, group, or role. It answers the question "what can this principal do?" You've been looking at these since the first section β€” they live in the principal's own account and are managed by whoever administers that identity.

A resource-based policy is a JSON document attached directly to a resource rather than to a principal. It answers a different question: "who is allowed to reach this specific resource, and with what actions?" The resource itself becomes the enforcement point. Not every AWS service supports this β€” it's a per-service capability β€” but the ones that do span a wide range of use cases:

  • S3 bucket policies β€” control who can read, write, or list objects in a bucket
  • KMS key policies β€” control who can use or manage a specific encryption key (and are actually mandatory: a KMS key with no key policy statements granting access is unusable even by the account root)
  • Lambda resource policies β€” control who (which principals or which AWS services) can invoke a specific function
  • SQS queue policies β€” control who can send messages to or receive messages from a specific queue

Structurally, a resource-based policy looks almost identical to an identity-based policy β€” same Effect, Action, Resource, Condition elements β€” with one addition that changes everything: a Principal element. Identity-based policies never specify a principal because the principal is implicit (it's whoever the policy is attached to). Resource-based policies must specify a principal explicitly, because the policy isn't attached to any particular caller β€” it's attached to the thing being accessed, and it needs to state who it's talking about.

Identity-based policy (attached to a role)
 ↓
Answers: "What can THIS ROLE do?"
 ↓
No Principal element β€” the principal is implicit

Resource-based policy (attached to an S3 bucket)
 ↓
Answers: "Who can reach THIS BUCKET, and how?"
 ↓
Principal element is required β€” must name who it applies to

The Cross-Account Unlock

🎯 Key Principle: A resource-based policy can grant a principal in a different AWS account access to your resource without that principal ever assuming a role in your account β€” and apart from legacy access control lists (ACLs), it is the only policy type that can.

This is worth sitting with because it's counter to how most engineers first learn cross-account access. The more commonly taught pattern is: Account B creates a role in Account A's environment (or rather, Account A creates a role that trusts Account B), and a user in Account B calls sts:AssumeRole to get temporary credentials scoped to that role. That pattern works, but it requires the calling principal to change identity mid-flight β€” they stop being "themselves" and become the assumed role for the duration of the call.

Resource-based policies offer a fundamentally different path: the principal in Account B keeps its own identity the entire time. It never calls AssumeRole. It simply calls s3:GetObject (or whatever the action is) directly, using its own long-term or session credentials from its own account, and the bucket's resource policy in Account A recognizes that principal by ARN and allows the request. No cross-account trust relationship needs to exist on the identity side at all β€” the entire cross-account grant lives in one place: the resource policy.

This matters practically because it changes who owns the risk and who owns the maintenance. With role assumption, Account A's administrators must create and maintain a role, a trust policy, and (usually) coordinate an external ID or condition with Account B. With a resource-based policy, Account A's administrators write one policy statement on the resource itself, and Account B's principals need no special setup at all beyond having the right ARN and permission on their own side to call the API in the first place β€” which brings us to a detail worth flagging early: granting cross-account access via a resource policy alone is not sufficient if the calling principal's own identity-based policy in Account B doesn't also allow the action. Both owners must agree. A partner account's user with no S3 permissions at all in their own identity policy will still be denied, even if your bucket policy explicitly names them, because AWS's cross-account evaluation requires an allow on both sides (the same-account case is the exception: there, an allow in the resource policy alone is enough).

Account A (resource owner)              Account B (calling principal)
 bucket policy:                          identity policy:
 Allow arn:aws:iam::B:user/partner        Allow s3:GetObject on that bucket ARN
 to s3:GetObject                          ↓
 ↓                                        Both must say yes
 Cross-account request β†’ ALLOWED only when both policies agree

Principal vs. NotPrincipal β€” and the Trap

The Principal element in a resource-based policy names exactly who the statement applies to β€” an account, a specific IAM user or role ARN, an AWS service principal (like lambda.amazonaws.com), or, in rare and generally discouraged cases, "*" for anyone.

AWS also has a NotPrincipal element, supported in the resource-based policies of some services, which inverts the logic: the statement applies to everyone except the principals listed. On the surface this looks like a convenient shortcut β€” "allow everyone except this one untrusted account" β€” but AWS supports it only with "Effect": "Deny", and it's one of the more dangerous constructs in resource-policy authoring.

⚠️ Common Mistake: Combining NotPrincipal with "Effect": "Allow"

AWS's policy reference is blunt about this: NotPrincipal must be used with "Effect": "Deny", and using it with "Effect": "Allow" is not supported. Even read on its own terms, the combination is an over-grant. NotPrincipal with Allow would mean "grant access to every principal in the universe except the ones listed" β€” and "every principal in the universe" includes every AWS account that exists, every IAM role anyone has ever created anywhere, and any principal that gets created in the future. That is the logic of "Principal": "*" with one name crossed out, except it looks restrictive because it names an exclusion list, which makes it easy to review and approve without noticing how wide it actually is.

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "UnsupportedNotPrincipalWithAllow",
      "Effect": "Allow",
      "NotPrincipal": {
        "AWS": "arn:aws:iam::111122223333:role/known-bad-actor"
      },
      "Action": "s3:GetObject",
      "Resource": "arn:aws:s3:::internal-reports/*"
    }
  ]
}

Read literally, this statement asks to grant s3:GetObject on every object in internal-reports to any principal in any AWS account on earth, with a single named exception. "Not supported" does not mean harmless: IAM Access Analyzer's policy validation reports this combination as a security warning rather than an error, because it can be overly permissive and can grant permissions to anonymous principals. A reviewer skimming this policy might see "NotPrincipal: known-bad-actor" and mentally file it as "this blocks the bad actor," missing that as written it asks to open the bucket to everyone else. Blocking one principal is a job for an explicit Deny that names it, not for an Allow that leaves it out.

The one supported use of NotPrincipal is paired with "Effect": "Deny", inside a resource-based policy meant to enforce that only a specific set of trusted principals can act, by denying everyone else (it can't be used in identity-based policies or role trust policies, and SCPs don't support Principal or NotPrincipal at all). That pattern ("deny unless you're on this list") is an allowlist-by-denial, but AWS no longer recommends NotPrincipal even for that in new policies: it advises a Deny with "Principal": "*" and an ArnNotEquals condition on aws:PrincipalArn instead, which is easier to troubleshoot β€” and, unlike NotPrincipal with Deny, doesn't end up denying every principal that has a permissions boundary attached, listed or not. The moment NotPrincipal appears next to Allow, treat it as a defect to remove, not a grant to reason about β€” an explicit Principal allowlist expresses the intended access more safely and more legibly than a NotPrincipal exclusion list ever will.

Worked Example: Partner Read-Only Access via Bucket Policy

Suppose your company (account 222233334444) runs a data-sharing pipeline and needs to give a partner organization (account 999988887777) read-only access to a specific prefix in an S3 bucket named shared-analytics-exports. The partner should be able to list and read objects under partner-a/ but nothing else in the bucket, and β€” critically β€” you do not want to touch any identity policy inside your own account to make this happen, because those policies govern your own engineers and shouldn't be entangled with an external relationship that a different team manages.

Here is the bucket policy that accomplishes this, attached directly to shared-analytics-exports:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "PartnerAReadOnlyAccess",
      "Effect": "Allow",
      "Principal": {
        "AWS": "arn:aws:iam::999988887777:root"
      },
      "Action": "s3:GetObject",
      "Resource": "arn:aws:s3:::shared-analytics-exports/partner-a/*"
    },
    {
      "Sid": "PartnerAListOwnPrefix",
      "Effect": "Allow",
      "Principal": {
        "AWS": "arn:aws:iam::999988887777:root"
      },
      "Action": "s3:ListBucket",
      "Resource": "arn:aws:s3:::shared-analytics-exports",
      "Condition": {
        "StringLike": {
          "s3:prefix": ["partner-a/*"]
        }
      }
    }
  ]
}

Walk through what each part is doing. The Principal names the partner's account root (arn:aws:iam::999988887777:root), which in a resource policy means "any principal within account 999988887777 that has been separately granted this permission on its own side" β€” it does not mean literally the root user; it delegates the decision of which specific IAM identity within that account gets to use this grant back to the partner account's own administrators. The actions are deliberately narrow β€” GetObject and ListBucket only, no PutObject, no DeleteObject β€” and they sit in two statements because they act on different resources: GetObject on the object ARN pattern scoped to the partner-a/ prefix, ListBucket on the bucket ARN itself (it operates on the bucket, not an object). The Condition on the second statement further constrains listing to that same prefix, so even ListBucket can't be used to enumerate other partners' folders in the same bucket; it sits on the ListBucket statement alone because the s3:prefix key exists only on list requests and would stop a GetObject allow from ever matching.

Nothing in your own engineers' identity policies changed. A developer with a broad s3:* identity policy on their own role keeps exactly the access that policy gives them β€” the bucket policy neither adds to it nor takes from it, because for a principal inside your own account, your own identity-based policies remain the primary grant; the bucket policy here is purely additive for the external partner. This is the practical payoff of the two-owners model: the resource owner can extend a narrow, auditable grant to an external party as one small, self-contained policy, fully separable from the mass of internal identity policies that govern everyone else.

πŸ’‘ Pro Tip: Give every resource-policy statement a descriptive Sid (statement ID), like PartnerAReadOnlyAccess above. When you're auditing a bucket policy with a dozen statements accumulated over years, a clear Sid is often the only thing that tells you why a grant exists without archaeology through change history.

Preventing the Confused Deputy: aws:SourceArn and aws:SourceAccount

A distinct risk shows up when the principal in a resource policy isn't a human or a role in another account, but an AWS service β€” for example, allowing S3 to invoke your Lambda function when an object is uploaded, or allowing SNS to publish into your SQS queue. Here the principal is something like s3.amazonaws.com, and that service principal is shared across every AWS customer using that service. If your policy only checks "is the caller S3," it can't distinguish your own bucket's notifications from another AWS customer's bucket that happens to also be configured to invoke your function β€” a pattern known as the confused deputy problem, where a legitimately-privileged service is tricked into acting on behalf of the wrong caller.

Two condition keys close this gap:

  • aws:SourceArn β€” restricts the grant to requests originating from one specific resource ARN (a specific bucket, a specific topic)
  • aws:SourceAccount β€” restricts the grant to requests originating from resources owned by one specific AWS account

Here's a Lambda resource-based policy statement β€” the kind added via aws lambda add-permission β€” that allows S3 to invoke a function, but only when the invocation originates from one named bucket in one named account:

{
  "Sid": "AllowS3InvokeFromExportsBucketOnly",
  "Effect": "Allow",
  "Principal": {
    "Service": "s3.amazonaws.com"
  },
  "Action": "lambda:InvokeFunction",
  "Resource": "arn:aws:lambda:us-east-1:222233334444:function:process-export",
  "Condition": {
    "ArnLike": {
      "aws:SourceArn": "arn:aws:s3:::shared-analytics-exports"
    },
    "StringEquals": {
      "aws:SourceAccount": "222233334444"
    }
  }
}

The equivalent CLI call to attach this permission looks like:

aws lambda add-permission \
  --function-name process-export \
  --statement-id AllowS3InvokeFromExportsBucketOnly \
  --action lambda:InvokeFunction \
  --principal s3.amazonaws.com \
  --source-arn arn:aws:s3:::shared-analytics-exports \
  --source-account 222233334444

Without the aws:SourceArn condition, any S3 bucket in any AWS account β€” including a bucket an attacker controls β€” that gets configured with a matching event notification pointing at your function's ARN could trigger an invocation against it (subject to the attacker knowing or guessing your function ARN, and to any other constraints your function's own code enforces). Adding aws:SourceArn closes that down to exactly one bucket; adding aws:SourceAccount as a second layer closes a real gap for S3: a bucket ARN carries no account ID, so if the bucket is ever deleted, another account can create a bucket with the same name and the same ARN β€” pinning the account stops that. Using both together is the standard, defensive pattern β€” for sources whose ARN includes the account ID, SourceArn alone is usually sufficient, but including SourceAccount costs nothing and removes any ambiguity about which account's resource is doing the calling.

⚠️ Common Mistake: Copying a service-integration resource policy from documentation or an existing stack without adding aws:SourceArn or aws:SourceAccount, because the example works fine functionally without them β€” the invocation succeeds either way during testing, so the missing condition doesn't announce itself as a bug. It only becomes a live exposure the day someone else's identically-shaped integration exists and can be pointed at your resource.

This same pattern generalizes across every service-to-service integration that relies on a resource-based policy: SNS-to-SQS subscriptions, EventBridge rules invoking Lambda, API Gateway invoking Lambda. In each case, the service principal is shared across all AWS customers, and aws:SourceArn (with aws:SourceAccount as a belt-and-suspenders addition) is the mechanism that ties the grant back to your specific upstream resource rather than anyone's.

The recurring shape across all of this is that a resource-based policy shifts part of the access decision out of the identity's own account and onto the resource itself, which is exactly what makes it indispensable for cross-account sharing and service integrations β€” and exactly why its Principal element, and the conditions scoping that principal, deserve the same scrutiny you'd give any policy that can grant access to a stranger.

Permission Boundaries in Practice

Imagine you want to let developers create and manage their own IAM roles for the workloads they own β€” without every role change routing through a central platform team ticket queue. Grant them iam:CreateRole and iam:AttachRolePolicy directly and you've handed them a path to attach AdministratorAccess to any role they create, including one they can then assume themselves. Permission boundaries exist to solve exactly this: they let you delegate IAM authoring power while capping, structurally, what that power can ever produce.

What a Boundary Actually Is

A permission boundary is a managed policy β€” written in the same JSON policy language as any identity-based policy β€” that you attach to a user or role in the special "boundary" slot, distinct from the identity's regular attached policies. The critical thing to internalize is what a boundary does not do: it never grants anything. A boundary attached to a role with zero identity policies attached grants that role nothing at all, even if the boundary itself allows s3:* on every resource. A boundary is purely a ceiling.

🎯 Key Principle: A permission boundary answers the question "what is the maximum this identity's own policies are allowed to grant?" β€” it never answers "what can this identity do?" That second question is always answered by the identity-based policy, filtered through the boundary.

This distinction trips people up because a boundary looks like a normal allow policy β€” same Effect: Allow, same Action/Resource shape β€” but it plays a completely different structural role. Building on the general evaluation formula from "How AWS Actually Decides Allow or Deny," when a boundary is attached, effective permission for a request narrows to:

effective permission = (identity policy allows) ∩ (boundary allows) βˆ’ (any explicit deny)

Both the identity policy and the boundary have to allow the action for it to succeed. If the identity policy grants s3:PutObject but the boundary's allow list doesn't include s3:PutObject at all, the request is denied β€” not because anything explicitly denied it, but because the intersection of the two allow sets is empty for that action. This is the part people misread most often: for whatever the identity's own policies grant, a boundary that simply omits an action behaves exactly like a deny for that action, even though nothing in the boundary policy says "deny." (One documented exception: within the same account, a resource-based policy that names an IAM user or a role session directly isn't limited by that omission β€” only an explicit deny in the boundary would stop it.)

Identity policy allows: s3:GetObject, s3:PutObject, s3:DeleteObject
Boundary allows: s3:GetObject, s3:PutObject
 ↓
Intersection: s3:GetObject, s3:PutObject
 ↓
s3:DeleteObject β†’ DENIED (outside the boundary's ceiling)

Worked Example: A Developer Self-Service Boundary

The canonical use of permission boundaries is delegated IAM authoring: you want developers to create roles for their own services (say, a role that a Lambda function assumes) without a platform engineer reviewing every single policy. You give developers iam:CreateRole, iam:PutRolePolicy, iam:AttachRolePolicy, and so on β€” but you require every role they create to carry a specific boundary, enforced through a condition on the iam:CreateRole and iam:PutRolePolicy calls.

Here is a boundary policy designed for that scenario:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "AllowMostServiceActions",
      "Effect": "Allow",
      "Action": [
        "s3:*",
        "dynamodb:*",
        "sqs:*",
        "logs:*",
        "cloudwatch:*"
      ],
      "Resource": "*"
    },
    {
      "Sid": "DenyIamAndOrgManagement",
      "Effect": "Deny",
      "Action": [
        "iam:*",
        "organizations:*"
      ],
      "Resource": "*"
    }
  ]
}

Walk through what this achieves. The first statement grants a broad but bounded allow list for everyday application services β€” storage, queues, logging β€” which is the "ceiling" developers get to work within for whatever identity policy they attach to a role. The second statement is an explicit deny on iam:* and organizations:*. Notice it's a deny, not just an omission β€” an explicit deny inside the boundary is redundant with omission for blocking access (both stop the action), but writing it explicitly makes the intent unmistakable during a security review and protects against someone later adding an overly broad allow elsewhere in the same policy that would otherwise reopen the gap. Since iam:* is denied, a role carrying this boundary cannot grant itself fresh IAM permissions, cannot create new users, and cannot escalate by editing other roles' trust policies β€” no matter what a developer writes into its policies.

What the boundary cannot do is restrict the developers themselves β€” it applies to the roles that carry it, not to the people who create them. So the delegation needs one more piece, and it is the one people skip; skipping it is precisely the gap this section flags. The developers' own permissions need a guard that denies removing a permissions boundary outright, and denies setting one unless the boundary being set is the same approved boundary ARN:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "DenyBoundaryRemoval",
      "Effect": "Deny",
      "Action": "iam:DeleteRolePermissionsBoundary",
      "Resource": "*"
    },
    {
      "Sid": "DenyBoundarySwap",
      "Effect": "Deny",
      "Action": "iam:PutRolePermissionsBoundary",
      "Resource": "*",
      "Condition": {
        "StringNotEquals": {
          "iam:PermissionsBoundary": "arn:aws:iam::123456789012:policy/dev-self-service-boundary"
        }
      }
    }
  ]
}

Without this guard, a developer who has iam:PutRolePermissionsBoundary could simply swap the restrictive boundary on a role they created for a permissive one (or, with iam:DeleteRolePermissionsBoundary, detach it), walking straight back out through the door the boundary was built to close.

⚠️ Common Mistake: Teams write a boundary that correctly blocks iam:CreateUser, iam:AttachUserPolicy, and privilege-escalating actions on roles β€” but never restrict iam:PutRolePermissionsBoundary or iam:DeleteRolePermissionsBoundary for the developers who create the bounded roles. The boundary looks airtight in isolation, but if the developer's identity policy also grants those two actions (which broad "role administration" policies often do by default), the developer can just remove or replace the boundary on a role they created and the entire delegation model collapses.

Tracing a Concrete Escalation Attempt

Suppose a developer's identity policy grants iam:CreateRole, iam:PutRolePolicy, iam:PassRole, and iam:PutRolePermissionsBoundary, and every role they create must carry the boundary above β€” but the guard on boundary changes is missing from this hypothetical (weaker) setup. Here's the sequence:

  1. Developer creates a new role, data-processor-role, attaching the required boundary (satisfying whatever condition forces boundary attachment at creation time).
  2. Developer calls iam:PutRolePolicy on data-processor-role, attaching an inline policy with AdministratorAccess-equivalent permissions.
  3. Because the DenyIamAndOrgManagement statement is still present in this trace, iam:* stays blocked on data-processor-role regardless of what the inline policy says.
  4. Developer instead calls iam:PutRolePermissionsBoundary on data-processor-role, replacing the restrictive boundary with a permissive one β€” because the guard isn't there to stop them.
  5. With the restrictive boundary gone, the inline AdministratorAccess policy from step 2 now applies at full strength: data-processor-role is an effectively unbounded admin role, created entirely through delegated self-service permissions.

That's the exact mechanism the developer-side guard shown earlier exists to close: it denies iam:DeleteRolePermissionsBoundary outright and makes iam:PutRolePermissionsBoundary subject to a condition that only permits setting the one approved boundary ARN, never swapping in a different one.

πŸ’‘ Mental Model: Think of a permission boundary as a glass ceiling with a lock on the ladder. The ceiling caps how high the identity's own policies can reach. But if the identity can also reach the ladder mechanism itself β€” the API calls that attach or detach boundaries β€” they can raise or remove the ceiling, and the cap was never real. Locking the ladder (restricting who can call the boundary-management APIs, and under what conditions) is not optional hardening; it's part of the boundary's core design, not an afterthought.

Boundary vs. Service Control Policy: Same Shape, Different Scope

A permission boundary and a Service Control Policy (SCP) are easy to conflate because both act as ceilings that intersect with identity-based allows rather than granting anything themselves, and both can only narrow, never widen, what a principal can do. But they operate at completely different scopes, and mixing them up leads to designing the wrong control for the problem.

AspectPermission BoundaryService Control Policy
πŸ”’ Attaches toA single IAM user or roleAn OU or account in AWS Organizations
🌐 ScopeOne principal, one accountEvery principal in every account under that OU
πŸ‘€ Who manages itAccount-level IAM adminOrganization management account admin
🎯 Typical useDelegated IAM self-service for a teamOrg-wide guardrail (e.g., block all regions except approved ones)
🧩 Can root user bypass it?Not applicable β€” a boundary can't be set for the root userNo β€” applies even to a member account's root user (the management account is exempt)

The practical rule of thumb: if the question is "what's the ceiling for this specific role's own policies," reach for a boundary. If the question is "what should be off-limits for every account in this part of the org, no matter who manages IAM there," that's an SCP question, and the deeper mechanics of how SCPs interact with Organizations belong to the multi-account strategy topic rather than here β€” the important takeaway for this section is just that the two controls are not substitutes for each other, and using a boundary where you actually need an SCP (or vice versa) leaves a scope gap: a boundary attached to one role does nothing to stop a different role in the same account, or any role in a sibling account, from doing the exact thing you thought you'd blocked.

When to Reach for a Boundary

Boundaries earn their complexity in a specific, recurring situation: you need to delegate IAM authoring β€” not just IAM usage β€” to someone outside the central security or platform team, and you want a structural guarantee that no combination of policies they write can exceed a known ceiling. If nobody on a team ever creates or edits IAM policies themselves, a boundary is solving a problem you don't have, and a well-scoped identity policy is sufficient on its own. The moment you grant iam:CreateRole, iam:PutRolePolicy, iam:AttachRolePolicy, or iam:CreateUser to anyone outside a small trusted set, the boundary conversation should start immediately β€” because those actions are precisely the ones that let a principal write permissions for another identity, and without a boundary in place, there is no ceiling on what that other identity can be handed.

⚠️ Common Mistake: Attaching a permission boundary to a role but never enforcing that new roles created by delegated users also get the boundary attached at creation time. If a developer has iam:CreateRole without a condition requiring iam:PermissionsBoundary to be set on that call, they can simply create a brand-new role with no boundary at all, sidestepping the entire control. The enforcement point is the iam:CreateRole and iam:PutRolePolicy calls themselves, typically via a Condition block requiring iam:PermissionsBoundary to equal the approved boundary ARN β€” not just the boundary policy's own content.

A Second Worked Boundary: Tightening an Overly Generous First Draft

A first attempt at a self-service boundary often starts too loose. Suppose the initial boundary allowed developers to create roles with any trust policy at all β€” including one that trusts an external, unknown AWS account. That's a real gap: even with iam:* blocked, a developer could create a role whose trust policy lets an outside account assume it, handing external access to whatever the identity policy on that role permits. Permission boundaries govern the permissions policy side of a role, not the trust policy side directly β€” a subtlety worth being explicit about, because it's easy to assume a boundary locks down everything about a role it's attached to.

Tightening this requires a separate control, layered alongside the boundary rather than inside it: reviewing each delegated role's trust policy β€” no IAM condition key on iam:CreateRole inspects it β€” which IAM Access Analyzer's external access findings make practical by flagging any role that trusts a principal outside the account or organization. This is a good illustration of a broader pattern worth carrying forward: a boundary caps what the role can do, but who can assume the role is governed by the role's trust policy, evaluated as a resource-based policy on the role itself β€” the kind of policy covered in "Resource-Based Policies and the Two-Owners Problem," with its own failure modes in "IAM Failure Patterns That Lead to Breaches." Treating the boundary as a complete solution to "is this role safe" without separately checking its trust policy is a recurring gap in otherwise well-designed delegation models.

Putting the Pattern Together

A complete developer self-service delegation, then, needs three pieces working together rather than the boundary policy alone: the boundary itself (capping the permissions ceiling), a condition on role-creation and policy-attachment calls that forces every new role to carry that exact boundary ARN, and a restriction on the developers themselves β€” not just inside the boundary on the roles they create β€” on changing or removing the boundary from an existing role. Miss any one of the three and the delegation model has a hole: skip the boundary and there's no ceiling at all; skip the enforced-attachment condition and developers can create unbounded roles that never had a boundary to begin with; skip the boundary-removal restriction and the ceiling that does get attached can be quietly lifted later. None of the three pieces alone constitutes least privilege for delegated IAM authoring β€” they're a package, and the value of the pattern comes specifically from the fact that no single API call, however permissioned, can bypass all three at once.

Engineering Least Privilege at Scale

Writing a permissions policy by guessing which API calls an application will make is a losing game β€” you either overshoot into a wildcard grant "to unblock the team" or you undershoot and spend the next two weeks chasing AccessDenied errors in production. The fix isn't a better guess; it's a different workflow that treats the policy as something you measure and refine rather than something you author once and forget.

The Iterate-Then-Narrow Workflow

🎯 Key Principle: Least privilege is not a policy you write correctly on the first attempt β€” it's a policy you arrive at by observing real behavior and cutting away everything unused.

The workflow has three phases, and skipping the middle one is the most common way teams end up stuck with permanent overprovisioning:

Phase 1: LAUNCH
 Attach a broad, scoped-to-service policy
 (e.g., AmazonS3FullAccess on a dev role)
 ↓
Phase 2: OBSERVE
 Let the workload run through its real code paths
 (deploys, batch jobs, error paths, month-end jobs)
 ↓
Phase 3: NARROW
 Generate a right-sized policy from actual
 CloudTrail activity, review it, replace the broad one

The reason phase 1 has to be deliberately broad rather than a best-guess narrow policy is that a workload's real permission footprint is usually larger than what a developer can enumerate from reading the code β€” it includes the retry paths, the cold-start initialization calls, the once-a-month reconciliation job, and the calls made by an SDK's internals (for example, an S3 client silently calling s3:GetBucketLocation before your first PutObject). Guessing narrow up front means you will hit those gaps in production, under time pressure, and the natural fix under pressure is to widen the policy rather than add the one missing action β€” which quietly recreates the overprovisioning you were trying to avoid.

⚠️ Common Mistake: Treating the "broad policy" phase as permanent because narrowing feels like a low-priority chore once the workload works. The observation window needs a tracked end date β€” the same policy that's a reasonable starting point in week one is a real exposure in month six.

Generating a Right-Sized Policy from Real Usage

IAM Access Analyzer includes a policy generation feature that reads a role or user's CloudTrail event history over a period you specify and produces an IAM policy containing only the actions that principal actually invoked. This turns the narrowing step from manual log archaeology into a repeatable, auditable process.

The mechanics: you point Access Analyzer at a principal (a role or user) and a time range β€” commonly 30 to 90 days to catch monthly and quarterly jobs β€” and it scans the CloudTrail trail covering that range for events attributed to that principal. It then emits a draft policy template listing the specific actions used, for the services where it can identify them, with resource ARN placeholders β€” which you replace with your real ARNs β€” for actions that support resource-level permissions.

# Start a policy generation job for a role, scanning CloudTrail
# events from the last 31 days
aws accessanalyzer start-policy-generation \
  --policy-generation-details '{
    "principalArn": "arn:aws:iam::111122223333:role/order-processor"
  }' \
  --cloud-trail-details '{
    "accessRole": "arn:aws:iam::111122223333:role/service-role/AccessAnalyzerMonitorServiceRole",
    "trails": [{
      "cloudTrailArn": "arn:aws:cloudtrail:us-east-1:111122223333:trail/org-trail",
      "allRegions": true
    }],
    "startTime": "2026-01-01T00:00:00Z",
    "endTime": "2026-01-31T23:59:59Z"
  }'

# Poll for the job, then retrieve the generated policy
aws accessanalyzer get-generated-policy \
  --job-id <job-id-from-previous-call> \
  --include-resource-placeholders \
  --include-service-level-template

The output is a starting draft, not a finished policy β€” it reflects exactly what happened during the observation window, so a job that only runs monthly needs at least one full billing cycle in the window or its once-a-month API calls won't show up at all. Treat the generated document as the input to a review, not the output of one: check it against the code paths you know exist (including rarely-exercised error handling and admin scripts) before replacing the broad policy.

πŸ’‘ Pro Tip: Run policy generation against a staging or pre-production copy of the workload first, exercising every code path deliberately (including failure injection), so the generated policy is complete before you ever apply the tighter version to the production role.

Catching Unintended External Exposure

Narrowing what a principal can do only addresses one direction of the least-privilege problem. The other direction β€” a resource that has granted access more broadly than intended through its own resource-based policy β€” is what Access Analyzer's external access findings are built to catch.

Access Analyzer continuously evaluates the resource-based policies in an account (or across an organization, if run at that scope) β€” S3 bucket policies, KMS key policies, IAM role trust policies, Lambda resource policies, SQS queue policies, and others β€” and flags any statement that grants access to a principal outside the account or, if you've defined an organization as the zone of trust, outside that organization. A finding doesn't mean the access is malicious; it means the policy grants more reach than the account boundary you defined, and a human needs to confirm that's intentional.

A concrete scenario: a bucket policy written to allow a CI/CD pipeline role to write build artifacts is misconfigured with a Principal of "*" left in from testing (a Principal can't carry a partial wildcard, so it is all or nothing), or a colleague copies a policy from documentation and forgets to replace the placeholder account ID with their partner's real one. Access Analyzer surfaces this as an external access finding before it becomes an incident report, because the tool evaluates the policy's actual grant rather than relying on someone remembering to audit it. Reviewing these findings on a schedule β€” not just once at setup β€” matters because resource policies get edited more casually than identity policies (a bucket policy is often changed to "just unblock this one integration" without going through the same review as a role's permissions).

Scoping Broad Grants with Condition Keys

Sometimes an action genuinely needs to stay broad β€” a role might legitimately need s3:GetObject across many buckets, or an admin action might need to remain available for break-glass situations. In those cases, the least-privilege lever isn't narrowing the action or resource; it's attaching a condition that restricts when and from where the grant applies, using AWS's global condition keys inside the policy's Condition block.

Four condition keys cover most of the practical scoping situations you'll reach for:

πŸ”‘ Condition keyπŸ”’ Restricts to🎯 Typical use
aws:MultiFactorAuthPresentMFA-authenticated sessions onlyGate destructive or high-value actions
aws:SourceIpRequests from a specific IP/CIDR rangeRestrict console/API access to office or VPN ranges
aws:RequestedRegionSpecific AWS Region(s)Enforce a data-residency or region allowlist
aws:PrincipalOrgIDPrincipals inside your AWS OrganizationBlock access from principals outside the org, even if account ID matches a pattern

A policy statement combining an action, a resource, and one of these conditions reads as "allow this, but only under this circumstance," which is a fundamentally different shape than either an unconditional allow or a narrower action list β€” the action stays broad, and the condition does the narrowing:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "AllowIAMChangesOnlyWithMFA",
      "Effect": "Allow",
      "Action": [
        "iam:CreateUser",
        "iam:DeleteUser",
        "iam:AttachUserPolicy"
      ],
      "Resource": "*",
      "Condition": {
        "Bool": {
          "aws:MultiFactorAuthPresent": "true"
        }
      }
    }
  ]
}

⚠️ Common Mistake: Using BoolIfExists instead of plain Bool in an Allow like this one. The aws:MultiFactorAuthPresent key is absent from requests signed with long-term access keys, and BoolIfExists treats an absent key as a match β€” so the Allow would also cover CLI and SDK calls made with no MFA at all. Plain Bool matches only when the key is present and true, which is the strict form for an Allow. BoolIfExists belongs in the mirror-image guardrail: a Deny with "aws:MultiFactorAuthPresent": "false", which blocks both sessions without MFA and long-term keys.

Worked Example: Narrowing a Wildcard S3 Policy

Start with the kind of policy that's extremely common on a role stood up during initial development β€” broad by default because nobody had yet observed what the workload actually touches:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "TooBroad",
      "Effect": "Allow",
      "Action": "s3:*",
      "Resource": "*"
    }
  ]
}

This grants every S3 action (including bucket deletion, ACL changes, and policy modification) against every bucket in the account, regardless of who owns it. Running this role through the iterate-then-narrow workflow after a tracked observation period, suppose the generated policy from Access Analyzer shows the role using S3, and the code paths confirm two buckets: app-uploads-prod and app-uploads-staging β€” note that object-level data events (s3:GetObject, s3:PutObject, and the listing calls that s3:ListBucket authorizes) aren't identified by policy generation and must be added manually based on known code paths. The first narrowing pass drops the wildcard action and the wildcard resource:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "ScopedToActualUsage",
      "Effect": "Allow",
      "Action": [
        "s3:GetObject",
        "s3:PutObject"
      ],
      "Resource": [
        "arn:aws:s3:::app-uploads-prod/*",
        "arn:aws:s3:::app-uploads-staging/*"
      ]
    },
    {
      "Sid": "ListOnBucketsThemselves",
      "Effect": "Allow",
      "Action": "s3:ListBucket",
      "Resource": [
        "arn:aws:s3:::app-uploads-prod",
        "arn:aws:s3:::app-uploads-staging"
      ]
    }
  ]
}

Note the split between the two statements: s3:GetObject and s3:PutObject act on objects, so their resource ARNs need the /* suffix, while s3:ListBucket acts on the bucket itself, so its resource ARN has no suffix. This is a frequent source of a "policy looks right but still gets AccessDenied" bug β€” attaching s3:ListBucket to an object-suffixed ARN silently doesn't work, because the action and the ARN shape have to match.

The policy is now scoped to the right actions and the right buckets, but it will still work from anywhere any valid credential for that role is used β€” including, if the role's trust policy or credential handling is ever loosened, from outside the private network the workload runs in. The final tightening pass adds a condition restricting the calls to originate from the application's own VPC, using the aws:SourceVpc condition key evaluated at an S3 VPC endpoint:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "ScopedToActualUsage",
      "Effect": "Allow",
      "Action": [
        "s3:GetObject",
        "s3:PutObject"
      ],
      "Resource": [
        "arn:aws:s3:::app-uploads-prod/*",
        "arn:aws:s3:::app-uploads-staging/*"
      ],
      "Condition": {
        "StringEquals": {
          "aws:SourceVpc": "vpc-0123456789abcdef0"
        }
      }
    },
    {
      "Sid": "ListOnBucketsThemselves",
      "Effect": "Allow",
      "Action": "s3:ListBucket",
      "Resource": [
        "arn:aws:s3:::app-uploads-prod",
        "arn:aws:s3:::app-uploads-staging"
      ],
      "Condition": {
        "StringEquals": {
          "aws:SourceVpc": "vpc-0123456789abcdef0"
        }
      }
    }
  ]
}

Three narrowing moves happened here, each independent of the others: action wildcard β†’ specific verbs, resource wildcard β†’ specific bucket ARNs, and an added condition scoping the source network. In practice you'd apply aws:SourceVpc only when the request genuinely travels through a VPC endpoint (the key is absent on calls made directly over the public S3 endpoint, so this Allow doesn't match them and they are denied), so confirm the workload's network path before relying on this condition as your only network control.

πŸ’‘ Real-World Example: A batch job that reads input files and writes output reports to S3 is a good candidate for this exact pattern β€” the policy you end up with after a full run cycle is typically a small, stable set of actions (GetObject, PutObject, ListBucket β€” the object-level ones added by hand) against a small, stable set of buckets, making it one of the cleaner cases to fully narrow. A role shared across many unrelated Lambda functions is a harder case, because its generated policy is really the union of several workloads' needs β€” that's usually a sign the role should be split rather than narrowed as one unit.

Making Narrowing a Recurring Practice, Not a One-Time Cleanup

A policy narrowed correctly today reflects today's code paths. A deploy that adds a new S3 prefix, switches to a different bucket, or adds a new downstream integration will either break under the tightened policy (forcing a fast, visible fix) or β€” more insidiously β€” continue working because the new call falls under an action already granted, while a separate, no-longer-used action sits in the policy unnoticed. The workflow doesn't end after the first narrowing pass; treat it as a loop that runs on a cadence (tied to a release cycle or a fixed calendar interval) rather than a project with a defined end date.

 β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
 β”‚ Broad launch β”‚
 β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”˜
        ↓
 β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
 β”‚   Observe    β”‚ ← re-enter here on each
 β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”˜   review cycle
        ↓
 β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
 β”‚    Narrow    β”‚
 β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”˜
        ↓
   (time passes, code changes)
        ↓
   back to Observe

πŸ’‘ Mental Model: Treat a narrowed policy the same way you'd treat a dependency version pin β€” correct when set, but silently wrong the moment the thing it describes (the workload's real behavior) moves on without it. The fix isn't avoiding the pin; it's re-checking it on a schedule.

IAM Failure Patterns That Lead to Breaches

Across incident post-mortems and access reviews, the vast majority of IAM breaches trace back to a handful of repeatable misconfigurations rather than novel exploits β€” the mechanisms below show up again and again because they hide inside policies that look reasonable at a glance.

Pattern 1: Privilege Escalation via iam:PassRole

iam:PassRole is the permission that lets a principal hand off an IAM role to an AWS service so that service can act on the principal's behalf β€” you need it whenever you launch an EC2 instance with an instance profile, create a Lambda function with an execution role, or configure any service that assumes a role to do work. The permission itself sounds narrow, but it becomes dangerous the moment it's combined with permission to launch something that runs code, because the launched resource inherits the passed role's credentials β€” credentials the launching principal may not otherwise have.

Consider a developer whose policy grants iam:PassRole on Resource: "*" plus ec2:RunInstances. Somewhere in the account there's a role called DataPipelineAdminRole with a trust policy that lets ec2.amazonaws.com assume it, and that role happens to carry broad data-platform permissions. The developer never had those permissions directly β€” but they don't need to.

Step 1: Developer calls ec2:RunInstances,
        specifying --iam-instance-profile DataPipelineAdminRole
   ↓
Step 2: iam:PassRole check succeeds because Resource: "*"
         covers DataPipelineAdminRole's ARN
   ↓
Step 3: EC2 launches the instance and attaches the role;
         the instance's metadata service now serves temporary
         credentials for DataPipelineAdminRole
   ↓
Step 4: Developer connects to the instance (SSH or SSM) and
         retrieves those credentials from the metadata endpoint
   ↓
Step 5: Developer now acts as DataPipelineAdminRole β€”
         a privilege escalation with no policy on their own
         identity ever changing

The same chain works with lambda:CreateFunction plus iam:PassRole: create a function, attach the powerful execution role, then trigger the function to run arbitrary code with that role's credentials β€” no server access required at all.

The fix is to scope iam:PassRole down to the exact role ARNs a principal legitimately needs to pass, and β€” where the API supports it β€” add the iam:PassedToService condition key so the role can only be passed to the specific service it's meant for.

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

With this policy, the developer can pass exactly one role, and only to the Lambda service β€” passing it to EC2, or passing any other role, both fail. ⚠️ Common Mistake: teams often scope iam:PassRole to Resource: "*" during initial setup "to get things working" and never revisit it once the pipeline is live β€” the wildcard quietly becomes a standing escalation path.

Pattern 2: Privilege Escalation via Self-Managed Policy Edits

A second escalation path doesn't touch compute at all β€” it targets the policy document itself. Customer-managed policies support versioning: iam:CreatePolicyVersion creates a new version of an existing policy (optionally setting it as the active default in the same call), and iam:SetDefaultPolicyVersion switches which of the up-to-five retained versions is currently active. If a principal holds either of these permissions over a policy that is attached to their own user or role, they can rewrite their own effective permissions.

🎯 Key Principle: any permission that lets a principal modify a policy attached to itself is functionally equivalent to granting that principal whatever the rewritten policy says β€” the original, narrower policy was never a real ceiling.

Worked example: a developer's identity has a customer-managed policy called DevSelfServicePolicy attached, scoped to a handful of S3 and DynamoDB actions. That same identity also has iam:CreatePolicyVersion and iam:SetDefaultPolicyVersion on arn:aws:iam::123456789012:policy/DevSelfServicePolicy β€” perhaps granted so the team could iterate on their own policy without waiting on a platform team.

{
  "Effect": "Allow",
  "Action": [
    "iam:CreatePolicyVersion",
    "iam:SetDefaultPolicyVersion"
  ],
  "Resource": "arn:aws:iam::123456789012:policy/DevSelfServicePolicy"
}

With this in place, the developer runs one CLI call:

aws iam create-policy-version \
  --policy-arn arn:aws:iam::123456789012:policy/DevSelfServicePolicy \
  --policy-document '{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"*","Resource":"*"}]}' \
  --set-as-default

The policy attached to their own identity now grants * on *. No trust policy changed, no role was assumed, and nothing about the identity's attachments looks different in a casual review β€” the same policy ARN is still attached, just carrying a new version underneath. Even without CreatePolicyVersion, holding only iam:SetDefaultPolicyVersion is enough if an older, more permissive version of the policy still exists among the retained versions β€” rolling back to it achieves the same result.

The structural fix is to never let a principal hold policy-editing permissions over a policy that governs its own access β€” that authoring capability belongs to a separate, more trusted identity, or it needs to sit behind a permission boundary that structurally blocks iam:* actions regardless of what the identity's own policy says (the boundary mechanism for this is covered in "Permission Boundaries in Practice").

Pattern 3: Wildcard Resources Paired with Broad Actions

Of all the patterns found in access reviews, "Resource": "*" combined with a broad action like s3:*, dynamodb:*, or ec2:* is the single most common over-permissioning pattern β€” not because teams are careless, but because it's the fastest way to get something working. A Lambda function needs to read one object from one bucket during development; a developer grants s3:* on Resource: "*" because the exact bucket ARN isn't finalized yet, the code ships, and the policy is never revisited.

{
  "Effect": "Allow",
  "Action": "s3:*",
  "Resource": "*"
}

The risk isn't abstract: this single statement grants the ability to read, write, and delete every object in every bucket in the account, plus bucket-level actions like changing bucket policies or disabling versioning β€” far beyond "read one file." If the credentials attached to this policy are ever exposed (a leaked access key, a compromised CI runner, an over-shared Lambda environment variable), the blast radius is the entire S3 estate rather than one bucket.

The systematic technique for collapsing a wildcard policy like this down to specific actions, specific bucket ARNs, and supporting conditions is covered in "Engineering Least Privilege at Scale" β€” the point to internalize here is recognition: any statement combining a service-wide action wildcard with Resource: "*" should be treated as a finding in an access review, not a passing configuration, regardless of how the workload behaves today.

Pattern 4: Permission Drift

Even a policy that was tightly scoped when written doesn't stay that way in effect β€” the workload changes, the team rotates, features get deprecated, but the policy statements granting access to them stay attached indefinitely. Permission drift is the gap that opens up between what a policy allows and what the identity actually still uses.

A concrete shape of this: a role created for a data-migration project is granted s3:* and dynamodb:* scoped to the migration's resources so the team can move fast during a time-boxed effort. The migration finishes, the role keeps running (because deleting IAM roles feels risky and nobody owns the cleanup task), and six months later that role β€” still fully permissioned β€” is a live target with permissions nobody remembers granting or needs anymore. Nothing about the original grant was wrong; the failure is that permissions were treated as a one-time setup decision instead of something that needs to shrink as usage shrinks.

⚠️ Common Mistake: treating an access review as a project milestone ("we did our IAM audit before the compliance deadline") rather than a recurring maintenance task. A policy that was correct at launch is not guaranteed to be correct a year later, and nothing about IAM's own mechanics forces a review β€” it has to be scheduled deliberately, using IAM's last-accessed information (which shows when each service a role is allowed to use was last accessed) together with the Access Analyzer policy generation and findings covered in "Engineering Least Privilege at Scale" as the evidence base.

Pattern 5: Trust Policy Misconfiguration

The previous patterns all involve a principal's own identity-based policy granting too much. This one involves the door on the other side: the trust policy (the role's assume-role policy document) decides who is even allowed to call sts:AssumeRole in the first place, via its Principal element. Get that element wrong and it doesn't matter how tightly the role's permissions are scoped β€” the wrong people can assume it.

The clearest version of this mistake is a Principal left as "*" β€” often a leftover from testing a cross-account setup where the exact partner account ID hadn't been filled in yet, and the placeholder was never replaced:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": "*",
      "Action": "sts:AssumeRole"
    }
  ]
}

With this trust policy, any authenticated AWS principal anywhere β€” not just in your organization, not just the intended partner β€” can call sts:AssumeRole on this role's ARN and receive temporary credentials, provided they can discover or guess the ARN. Role ARNs are not secret; they routinely appear in CloudTrail logs, error messages, and infrastructure-as-code repositories, so "nobody will find it" is not a real control.

A subtler version scopes the Principal correctly to a specific account but omits a distinguishing condition where one is needed β€” for example, a role meant to be assumed only by a specific third-party SaaS vendor's monitoring service, trusting the vendor's AWS account ID:

{
  "Effect": "Allow",
  "Principal": { "AWS": "arn:aws:iam::999999999999:root" },
  "Action": "sts:AssumeRole"
}

If that vendor account is shared across many of the vendor's customers (a common SaaS integration pattern), any of the vendor's other customers β€” or anyone who compromises the vendor's account β€” can potentially assume the same role, because there's nothing here uniquely tying the trust to your specific integration. The fix is to require an sts:ExternalId condition, a unique identifier the vendor assigns to your integration (AWS does not treat it as a secret) that the vendor must present at assume-role time:

{
  "Effect": "Allow",
  "Principal": { "AWS": "arn:aws:iam::999999999999:root" },
  "Action": "sts:AssumeRole",
  "Condition": {
    "StringEquals": {
      "sts:ExternalId": "a1b2c3-your-unique-integration-id"
    }
  }
}

Now the vendor can assume the role only by presenting the external ID tied to your integration β€” which it sends only when acting for you β€” closing the confused-deputy gap where the vendor's shared account alone was the only check. This condition mechanic mirrors the aws:SourceArn/aws:SourceAccount guard used on resource-based policies to stop confused-deputy access from other AWS services, covered in "Resource-Based Policies and the Two-Owners Problem" β€” same underlying problem (an intermediary being trusted more broadly than intended), applied to a different policy type.

Comparing the Five Patterns

Each of these failures has a different attack surface, but they share a root cause: a control that looks fine in isolation stops being fine once combined with another permission or left unattended over time.

πŸ”“ Patternβš™οΈ MechanismπŸ›‘οΈ Primary Fix
PassRole escalationBroad PassRole + launch permissionScope Resource + iam:PassedToService
Self-policy-edit escalationCreatePolicyVersion/SetDefaultPolicyVersion on own policySeparate authoring identity + boundary
Wildcard resourceBroad action + Resource: "*"Narrow to specific ARNs
Permission driftUnused grants left in placeRecurring last-accessed review
Trust policy misconfigOverly broad or under-conditioned PrincipalExact principal + ExternalId

πŸ’‘ Mental Model: the first two patterns are escalation paths a principal exploits against its own account β€” turning weak permissions into strong ones. The last one is an entry path an outsider exploits into the account. Wildcard resources and permission drift are the slow-burn versions of both: they don't create an exploit by themselves, but they widen whatever exploit eventually gets tried.

What ties all five together is that none of them show up as an error at the moment they're created β€” aws iam create-role and aws iam put-role-policy succeed either way, because IAM's API has no concept of "too permissive." Catching these patterns requires actively looking for them: reviewing iam:PassRole grants for missing resource scoping, checking who holds edit rights over policies attached to themselves, grepping for "Resource": "*" alongside wildcard actions, running last-accessed reports on standing roles, and diffing trust policy Principal blocks against the actual list of accounts and services that should be able to assume each role.

Choosing the Right Guardrail: A Decision Framework

Every misconfiguration in the previous section came from someone reaching for the wrong tool β€” a boundary doing an SCP's job, an identity policy trying to solve a cross-account problem that only a resource policy can solve. This section turns the four mechanisms you've now seen individually into a single checklist you can run before writing a single line of policy JSON.

The Governing Question Determines the Control

Each IAM guardrail exists to answer exactly one governing question. If you find yourself trying to make one mechanism answer a question it wasn't built for, that's the signal you've picked the wrong control β€” not that you need a cleverer policy.

🎯 Key Principle: Before writing any policy, state the governing question in plain English first. The question's shape tells you which control to reach for; the JSON is just the implementation detail.

Walk the four questions in order:

Start: I need to control access. What is the governing question?
↓
Q1: Is this about what ONE principal (user or role) is allowed to do,
    across whatever resources it touches?
    β†’ YES: use an IDENTITY-BASED POLICY attached to that principal.
↓ (if the question isn't about a single principal's own actions)
Q2: Is this about who ELSE β€” possibly in another AWS account, possibly
    an AWS service β€” is allowed to reach ONE specific resource?
    β†’ YES: use a RESOURCE-BASED POLICY attached to that resource.
↓ (if the question isn't about external reach into a resource)
Q3: Is this about capping what a principal's OWN policies can ever
    grant, including policies someone writes for it in the future?
    β†’ YES: use a PERMISSION BOUNDARY attached to that principal.
↓ (if the question isn't about a per-principal ceiling)
Q4: Is this about what's off-limits for EVERY principal in an account
    or organizational unit, no matter what any identity or resource
    policy inside it says?
    β†’ YES: use a SERVICE CONTROL POLICY (SCP) at the OU or account level.

Each step maps to a concrete, recognizable scenario:

  • Q1 β€” identity-based policy: "Can the billing-exporter Lambda execution role call s3:PutObject on the exports bucket and dynamodb:UpdateItem on the invoices table?" This is purely about what that one role can do. You write the answer directly into the role's identity policy.
  • Q2 β€” resource-based policy: "Can partner account 999900001111 read objects from our shared-reports bucket without ever assuming a role in our account?" This is a reach-into-my-resource question, and β€” as covered in "Resource-Based Policies and the Two-Owners Problem" β€” a resource-based policy is the policy type that grants that without role assumption.
  • Q3 β€” permission boundary: "Developers can author IAM policies for roles they create themselves β€” but can any of those self-written policies ever grant iam:* or spin up an unbounded admin role?" This is a ceiling question about a principal's own authoring power, which is exactly what "Permission Boundaries in Practice" covers.
  • Q4 β€” SCP: "No matter what any identity policy or resource policy inside the sandbox OU says, can anyone disable CloudTrail logging or move workloads outside two approved regions?" This is an OU-wide ceiling, independent of any single account's configuration.

⚠️ Common Mistake: Trying to use an identity-based policy to stop a resource from being reached by an external account. An identity policy only constrains the principal it's attached to β€” it says nothing about principals in other accounts, because those principals never look at your identity policies at all. If the governing question contains the words "someone else" or "another account," you're in Q2 territory, not Q1.

Scope and Ownership at a Glance

The four questions map cleanly onto four different scopes β€” how wide a net the control casts β€” and in most organizations, onto four different owners. A session policy belongs in the same table because the formula at the end of this section depends on it, even though it isn't something you author as a standing artifact the way the other four are.

GuardrailπŸ”§ Attached To🌐 ScopeπŸ§‘ Typical OwnerπŸ”’ Grants or Restricts
Identity-based policyIAM user or roleSingle principalApplication/service teamβœ… Grants
Resource-based policyThe resource (bucket, key, function, queue)Single resource, any principalResource/platform ownerβœ… Grants (even cross-account)
Permission boundaryIAM user or roleSingle principal, ceiling onlyCentral security/IAM team🚫 Restricts only
Session policyThe AssumeRole/federation callOne temporary sessionWhatever system calls sts:AssumeRole🚫 Restricts only
Service control policy (SCP)OU, account, or org rootEvery principal in that account/OUCloud platform/Organizations admin🚫 Restricts only

Two patterns fall out of this table immediately. First, only two of the five rows can actually grant anything β€” identity-based and resource-based policies. Every other guardrail (boundary, session policy, SCP) exists purely to narrow what those two grants are allowed to produce; none of them can hand out a permission that isn't already present in an identity or resource policy. Second, ownership tends to widen as scope widens: an application team owns its own role's identity policy, but nobody wants an individual application team able to author an SCP that governs every account in the organization. That ownership split is precisely why boundaries and SCPs exist as separate mechanisms from identity policies β€” they let a central team set a ceiling without having to co-author every team's day-to-day policy.

🧠 Mnemonic: Read the table top to bottom as G-G-R-R-R β€” Grant, Grant, Restrict, Restrict, Restrict. The first two rows add permissions; everything below them can only take permissions away.

Where Identity Center and Cross-Account Patterns Plug In

When an administrator assigns a permission set in AWS IAM Identity Center, AWS provisions an IAM role in the target account and attaches an identity-based policy generated from that permission set's definition. Nothing about the evaluation model changes for that role: a session established through Identity Center still runs through the same identity-policy-∩-boundary-∩-session-policy chain as any other assumed role, and you can still set a permissions boundary on the permission set itself (Identity Center applies it to the role it provisions) if you want a hard ceiling under a permission set that multiple teams can request. The dedicated Identity Center material covers how permission sets are structured and assigned across accounts; what matters here is that the checklist above still applies unchanged to whatever role Identity Center hands your user for the session.

The same is true for attribute-based access control (ABAC) and multi-account patterns: ABAC is really just a way of writing the content of an identity-based or resource-based policy using tags and aws:PrincipalTag conditions instead of hardcoded ARNs, and cross-account access still resolves to the Q2 answer β€” a resource-based policy, or a trust policy paired with sts:AssumeRole β€” regardless of how many accounts are involved. Those techniques change what you write inside a policy; they don't add a fifth governing question to the checklist above.

The One Formula to Carry Forward

Everything in this lesson compresses into a single expression. Keep this one, because every other rule β€” which policy wins, why a boundary can only take away and never add, why an SCP overrides a permissive identity policy β€” is a consequence of it.

🎯 Key Principle:

effective access =
    (identity-based policy allows)
    ∩ (permission boundary allows, if one is attached)
    ∩ (session policy allows, if one is present)
    ∩ (SCPs allow, if the account belongs to an organization)
    βˆ’ (any explicit deny found anywhere in the applicable
       policies β€” identity, boundary, session, resource-based,
       or SCP)

A short worked example makes the formula concrete without re-deriving the full evaluation trace already covered in "How AWS Actually Decides Allow or Deny." Suppose a role's identity policy allows dynamodb:* on Resource: *, it has no permission boundary, and it's assumed with a session policy that scopes actions to a single resource: arn:aws:dynamodb:us-east-1:111122223333:table/Orders. Applying the formula: the intersection of dynamodb:* on everything with dynamodb:* on just the Orders table leaves dynamodb:* on Orders only β€” the session can never touch any other table, no matter what the identity policy alone would have allowed.

Now add one more fact: the organization has an SCP attached to the account's OU that explicitly denies dynamodb:DeleteTable everywhere, for every principal, as a data-loss guardrail. Re-applying the formula, the explicit-deny term subtracts DeleteTable from the result even though both the identity policy and the session policy would have allowed it on Orders. The session ends up with every dynamodb action on the Orders table except DeleteTable β€” a clean illustration that the subtraction term wins regardless of which layer produced the deny.

When a request comes back denied and it isn't obvious which layer is responsible, don't guess β€” simulate the identity-side evaluation first:

import boto3

iam = boto3.client("iam")

# Checks identity-based policy, any attached permission boundary and
# any SCPs in scope for this role against the given action and resource.
response = iam.simulate_principal_policy(
    PolicySourceArn="arn:aws:iam::111122223333:role/OrdersServiceRole",
    ActionNames=["dynamodb:DeleteTable"],
    ResourceArns=[
        "arn:aws:dynamodb:us-east-1:111122223333:table/Orders"
    ],
)

for result in response["EvaluationResults"]:
    print(result["EvalActionName"], "->", result["EvalDecision"])
    # MatchedStatements shows which identity-policy statement produced
    # this decision; statements from SCPs are never listed

πŸ’‘ Pro Tip: simulate_principal_policy evaluates the identity-based policy, the permission boundary and any SCPs in scope for the principal you name β€” but it never fetches a resource-based policy for you, and for an IAM role like this one it cannot simulate one at all (the ResourcePolicy parameter is supported for IAM users only). In the example above, the simulator would report dynamodb:DeleteTable as denied because of the SCP, although it won't show you which SCP statement did it β€” MatchedStatements never lists SCP statements. Treat a simulator result as strong evidence, not as "what production will do": AWS notes that results can differ from the live environment, so check resource policies separately when they're in play and confirm against the real account.

For the off-limits side of the checklist, an SCP itself is just a policy document with Deny statements and conditions, no different in shape from an identity policy:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "DenyOutsideApprovedRegions",
      "Effect": "Deny",
      "Action": "*",
      "Resource": "*",
      "Condition": {
        "StringNotEquals": {
          "aws:RequestedRegion": ["us-east-1", "eu-west-1"]
        }
      }
    },
    {
      "Sid": "DenyDynamoDBTableDeletion",
      "Effect": "Deny",
      "Action": "dynamodb:DeleteTable",
      "Resource": "*"
    }
  ]
}

The structural resemblance to an ordinary identity policy is deliberate and also the most common source of confusion: teams sometimes try to use an SCP to grant extra access, forget that SCPs never add permissions on their own, and then wonder why nothing changed. An SCP only narrows what the account's identity and resource policies are allowed to produce β€” exactly the restrict-only behavior in the scope table above.

Putting the Checklist to Work

Three concrete habits turn this framework from a diagram into something you actually use. When reviewing an existing role during an access review, run each attached policy through the four questions and confirm it's answering only the question it was designed for β€” a permission boundary that's quietly trying to grant access, or an identity policy that's trying to gate cross-account reach, is a sign the control was picked for the wrong reason. When a request fails unexpectedly, apply the formula in order: check the identity policy, boundary and SCPs first with the simulator, then manually check for a resource-based policy on the target resource, then β€” if the simulator points at an SCP β€” find which one further up the OU hierarchy, since the simulator won't show you the SCP statement that matched. And when provisioning a new Identity Center permission set or delegating a new self-service pattern to a team, write down the governing question in plain English before touching JSON; if the answer doesn't cleanly land on one of the four questions above, that's usually a sign the access pattern needs to be split into two separate, narrower questions rather than solved with one clever policy.

⚠️ Keep the formula itself as the one thing to memorize above everything else in this lesson: effective access is what the identity-based policy allows, intersected with every restricting layer that is attached, minus any explicit deny found anywhere in the chain β€” and remember that a boundary, a session policy, and an SCP can each only shrink that intersection, never expand it.