AWS IAM Policy Evaluation Logic: SCPs, Permission Boundaries and Least-Privilege with Access Analyzer (2026)
Most AWS access incidents are not caused by a missing policy. They are caused by an engineer who believed a guardrail was in force when it was not, or who believed a permission was blocked when a different policy type quietly granted it. IAM policy evaluation is the deterministic procedure AWS runs on every single API request, and almost every “why was this allowed” or “why is this denied” question reduces to knowing which policy types grant, which only limit, and in what order they are checked.
This matters more in 2026 than it did three years ago. Organizations now stack service control policies (SCPs), resource control policies (RCPs), permission boundaries, session policies, resource-based policies and tag-based conditions on the same request, and each layer has a different job. Teams that treat them as one pile of JSON end up with either over-broad access or mysterious denials.
You will leave with a precise mental model of the evaluation flow, a working picture of how ceilings and grants combine, worked examples across accounts, policy and Terraform snippets, and a repeatable least-privilege loop built on IAM Access Analyzer.
What this covers: the evaluation order, the grant-versus-ceiling distinction, SCPs and RCPs, permission boundaries and session policies, cross-account and trust-policy rules, confused-deputy defenses, ABAC, privilege-escalation paths, and the Access Analyzer workflow.
Context and Background
AWS Identity and Access Management (IAM) is the authorization layer in front of nearly every AWS API. A request carries a principal (a user, a role session, or an anonymous caller), an action such as s3:GetObject, a resource ARN, and a bag of context keys such as aws:SourceIp, aws:PrincipalTag/team, or aws:RequestedRegion. IAM compares that request context to every policy that could apply and returns Allow or Deny.
The foundational rules have been stable for years, and the AWS documentation states them plainly: all requests are implicitly denied by default (the account root user is the exception), an explicit allow is needed to permit a request, and an explicit deny overrides any allow. What has grown is the number of policy types in play. Identity-based policies and resource-based policies were joined by permission boundaries and session policies, then by AWS Organizations SCPs, and more recently by resource control policies, which apply to the resource side of a request rather than the principal side.
The incumbent mental model, “attach a policy to a user and it works,” breaks down in a multi-account landing zone. A platform team writes SCPs at the organization root, an application team writes role policies, a security team enforces a boundary on any role the application team creates, and a vendor integration arrives through a cross-account trust policy. Four owners, four policy types, one request. When something fails, the only reliable debugging tool is the evaluation algorithm itself.
If you work in cloud identity more broadly, this post pairs naturally with our coverage of SPIFFE and SPIRE workload identity, which solves the “who is this workload” question that IAM then answers with “what may it do.” The authoritative reference for everything below is the AWS IAM User Guide section on policy evaluation logic, which this article follows and extends with worked examples. Where AWS documentation was silent, we say so rather than guess.
One framing idea runs through the rest of the post. Policy types fall into two families. Grants (identity-based policies, resource-based policies) are the only things that can produce an Allow. Ceilings (SCPs, RCPs, permission boundaries, session policies) can only remove permissions that a grant would otherwise give. Confusing a ceiling for a grant is the single most common design error in IAM, and the evaluation algorithm makes it obvious why.
The Evaluation Flow: Deny First, Then Ceilings, Then Grants
The short answer: IAM first sweeps every applicable policy for an explicit Deny, which wins immediately. If none exists, the request must clear the RCP and SCP ceilings, then find an Allow in a resource-based or identity-based policy, and finally pass the permission boundary and session policy ceilings. Anything not explicitly allowed is implicitly denied.

Figure 1: Same-account IAM policy evaluation order. Any explicit Deny ends evaluation immediately; ceilings must allow; a grant must exist.
The diagram follows the ordered steps in the AWS documentation for single-account requests. Reading it as code makes the behavior predictable, so here it is in prose, step by step.
Step 1: explicit deny sweep. The enforcement code evaluates all applicable policies, namely SCPs and RCPs, resource-based policies, identity-based policies, permissions boundaries and session policies, looking for a Deny statement that matches the request. One matching Deny anywhere returns a final Deny. No other policy can override it. This is why a deny written in an SCP at the organization root is such a powerful control: no account-level admin can write an Allow that beats it.
Step 2: RCPs. Resource control policies attach to resources in the account (via the organization, OU or account). If no applicable Allow is found in the RCPs, the result is Deny. AWS handles this for you with a managed RCPFullAWSAccess policy that is attached automatically when RCPs are enabled and cannot be detached, so a baseline Allow always exists.
Step 3: SCPs. Service control policies apply to principals in the member account. As with RCPs, the absence of an applicable Allow produces a Deny. The default FullAWSAccess SCP supplies that Allow, which is why removing it without a replacement blocks everything beneath that point in the hierarchy.
Step 4: resource-based policies. If a resource-based policy allows the principal, the request can be allowed even when an identity-based policy, permissions boundary or session policy says nothing. For most resources, an explicit Allow for the principal in either an identity-based or a resource-based policy is enough, within a single account. Two notable exceptions are IAM role trust policies and KMS key policies, which must explicitly allow the principal. We return to the principal-type subtleties of this step below.
Step 5: identity-based policies. For an IAM user this includes the user’s own policies and those of its groups. With no identity policy statement allowing the action, the request is implicitly denied.
Step 6: permissions boundary. If the principal has a boundary and the boundary does not allow the action, the result is Deny.
Step 7: session policies. For role sessions and federated user sessions, a session policy passed at session creation must also allow the action. If none was passed, a default is created and does not narrow anything.
Grants versus ceilings
Two lines in the AWS documentation capture the whole design. Identity-based plus resource-based policies in the same account give the union of their permissions. Identity-based policies combined with a permissions boundary, or with SCPs and RCPs, give the intersection.
That is the grant-versus-ceiling split. A permission boundary never gives a user anything. An SCP never gives a user anything. The AWS Organizations documentation says this directly: SCPs do not grant permissions, and administrators must still attach identity-based or resource-based policies. A boundary of s3:* on a role with no identity policy yields a role that can do nothing.
This explains a classic surprise. An engineer writes an SCP allowing ec2:* and wonders why nobody in the member accounts can launch instances. The SCP only raised the ceiling; no grant sits underneath it. Conversely, an administrator attaches AdministratorAccess to a role and wonders why S3 calls fail. A deny SCP or a boundary is capping it.
Principal-type subtleties in resource-based policies
Step 4 has a rule table that trips up experienced engineers, and it is worth stating exactly. The AWS documentation describes behavior for single-account evaluation that depends on how the principal is named in the resource policy:
- Role ARN (
arn:aws:iam::111122223333:role/examplerole): the grant is still limited by an implicit deny in a permissions boundary or session policy. A wildcard exception exists: boundaries and session policies do not limit permissions granted using theaws:PrincipalArncondition key with a wildcard in thePrincipalelement, unless identity-based policies contain an explicit deny. - Assumed-role session ARN (
arn:aws:sts::111122223333:assumed-role/examplerole/session): permissions granted directly to a session are not limited by an implicit deny in an identity-based policy, permissions boundary or session policy. - IAM user ARN: grants to a user ARN are not limited by an implicit deny in an identity-based policy or boundary.
- STS federated user session (from
GetFederationToken): grants to the federated user ARN are not limited by identity policies, boundary or session policy. If a resource policy instead grants to the ARN of the IAM user who federated, the session is limited by the boundary and session policy.
The practical consequence is that a resource policy naming a specific session ARN can bypass a role’s boundary. That is rarely what the author intended, and it is why security reviews should grep resource policies for assumed-role ARNs. Note again that these rules apply to same-account evaluation; cross-account requests are governed by the separate rules covered later.
Why the order matters even though the result is order-independent
Strictly, IAM returns the same decision regardless of the order you imagine, because all ceilings must allow, a grant must exist, and a deny beats everything. The ordering is still the best debugging sequence. When a request is denied, work in this order: look for explicit denies (SCP, RCP, resource, identity, boundary, session), then check each ceiling for a missing Allow, then confirm that a grant exists. The IAM policy simulator models the same logic, and many services now include the type of policy responsible in their access-denied messages (coverage varies by service, so verify for the API you are debugging).
SCPs, RCPs and the Organization-Level Ceiling
Service control policies are the first ceiling most teams meet, and they are the most commonly misunderstood. This section covers what they restrict, what they do not, how inheritance works, and the limits that shape how you structure them.

Figure 2: Ceilings stack from the organization root down to the session. The effective permission set is the intersection of every ceiling and a grant.
The figure shows the layering. Effective permissions for a role session in a member account are bounded by every SCP from the root through each OU to the account, then by the role’s permission boundary, then by the session policy. Inside all of those ceilings, an identity-based or resource-based grant must exist. Each layer can only shrink what remains.
How SCP inheritance actually works
Per the AWS Organizations documentation, a Deny at any level (root, OU or account) applies to every OU and account beneath it. An Allow works differently: a permission is allowed only if an explicit Allow exists at every level from the root through each OU down to the target account. SCP evaluation is deny-by-default, so a missing Allow at any level blocks access.
That asymmetry drives two common strategies:
- Deny list (recommended default). Keep the AWS-managed
FullAWSAccessSCP attached everywhere and add explicit Deny statements for what must never happen, such as leaving the organization, disabling CloudTrail, or using unapproved regions. - Allow list. Detach
FullAWSAccessand replace it with an allow list of approved services at each level. The documentation warns that relying only on Allow statements can cause unintended access, because overlapping broader Allows can override more restrictive ones at the same level, and that removingFullAWSAccesswithout a replacement blocks every action beneath that level.
AWS recommends Deny statements for restrictions that should apply broadly. In practice, a deny list is easier to reason about and far less likely to produce a platform-wide outage when someone edits a policy at 4 p.m. on a Friday.
What SCPs do not touch
The documentation is explicit about the blind spots. SCPs do not affect users or roles in the management account; they affect only member accounts. They do not affect service-linked roles. They do not directly affect resource-based policies, and they do not affect principals from accounts outside the organization. A short list of tasks cannot be restricted either, including registering for the Enterprise support plan as the root user and providing trusted signer functionality for CloudFront private content. Apart from those exceptions, SCPs apply to all users and roles in attached member accounts, including the root user.
Two design lessons follow. First, keep workloads out of the management account entirely, since no SCP can govern it. Second, understand that SCPs cannot stop an external principal from using a resource-based grant on your bucket, which is the gap RCPs were introduced to fill.
Resource control policies
RCPs are the resource-side analogue of SCPs. They apply to resources in accounts where they are attached and set the maximum permissions available on those resources, regardless of who the caller is. That makes them a data-perimeter tool: you can write an RCP that denies access to supported resources unless the caller belongs to your organization, which holds even if a developer later attaches an overly permissive bucket policy. As with SCPs, an RCP does not grant anything; RCPFullAWSAccess is attached automatically, cannot be detached, and counts toward the attachment limit.
RCP support is limited to a documented list of AWS services, which has grown over time. Check the current supported-services list in the Organizations documentation before you rely on an RCP for a specific resource type, since an RCP does not protect a service it does not support.
Limits that shape your structure
The Organizations quotas page lists the numbers that constrain design:
| Item | Limit |
|---|---|
| SCP maximum document size | 10,240 characters |
| SCPs attached to root, OU or account | 10 each |
| RCP maximum document size | 5,120 characters |
| RCPs attached to root, OU or account | 5 each (includes RCPFullAWSAccess) |
| OU nesting depth | 5 levels under the root |
| SCPs per organization | 10,000 |
The console strips extra whitespace outside quotation marks before counting characters, while the SDK and CLI save the policy exactly as provided. The practical effect: if a deny-list SCP grows past 10,240 characters, you split it by theme (region restriction, security-service protection, data-perimeter) and attach several, remembering that each root, OU and account has its own 10-policy attachment cap.
A deny-list SCP worked example
The following SCP denies the use of regions outside an approved set for most services, while exempting global services and a break-glass role. It is a pattern, not a drop-in policy: tune the NotAction list to your own service footprint.
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "DenyOutsideApprovedRegions",
"Effect": "Deny",
"NotAction": [
"iam:*",
"organizations:*",
"route53:*",
"cloudfront:*",
"support:*",
"sts:*"
],
"Resource": "*",
"Condition": {
"StringNotEquals": {
"aws:RequestedRegion": ["eu-west-1", "us-east-1"]
},
"ArnNotLike": {
"aws:PrincipalArn": "arn:aws:iam::*:role/BreakGlassAdmin"
}
}
},
{
"Sid": "ProtectAuditTrail",
"Effect": "Deny",
"Action": [
"cloudtrail:StopLogging",
"cloudtrail:DeleteTrail",
"cloudtrail:UpdateTrail"
],
"Resource": "*",
"Condition": {
"ArnNotLike": {
"aws:PrincipalArn": "arn:aws:iam::*:role/OrgSecurityAdmin"
}
}
}
]
}
Two details deserve attention. The NotAction plus Deny combination means “deny everything except these,” which is the correct way to exempt global services. And the ArnNotLike exemption uses aws:PrincipalArn, which for assumed roles evaluates to the role ARN rather than the session ARN, so one entry covers every session of that role.
Permission Boundaries and Session Policies: Delegation Without Escalation
Permission boundaries and session policies are ceilings that attach lower in the stack than SCPs. Their jobs are different: a boundary lets one team safely create roles for another, and a session policy lets a broker hand out a narrower credential than the role it assumed.
Permission boundaries for delegated administration
A permissions boundary is a managed policy attached to a user or role that sets the maximum permissions that principal’s identity-based policies can grant. Effective permissions are the intersection. Adding a boundary can reduce what a user can do; removing one can increase it.
The reason to use them is delegation. Suppose the platform team wants application teams to create their own roles without filing a ticket, but never wants those roles to reach IAM, billing, or the audit account. Without boundaries, the ability to run iam:CreateRole and iam:AttachRolePolicy is effectively administrator access, since the creator can attach AdministratorAccess to a new role and assume it. With boundaries, you grant iam:CreateRole only under the condition that the new role carries the approved boundary.
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "CreateRolesOnlyWithBoundary",
"Effect": "Allow",
"Action": ["iam:CreateRole", "iam:PutRolePolicy", "iam:AttachRolePolicy"],
"Resource": "arn:aws:iam::111122223333:role/app-*",
"Condition": {
"StringEquals": {
"iam:PermissionsBoundary": "arn:aws:iam::111122223333:policy/AppTeamBoundary"
}
}
},
{
"Sid": "NeverTouchTheBoundary",
"Effect": "Deny",
"Action": [
"iam:DeleteRolePermissionsBoundary",
"iam:PutRolePermissionsBoundary",
"iam:CreatePolicyVersion",
"iam:DeletePolicyVersion",
"iam:SetDefaultPolicyVersion"
],
"Resource": [
"arn:aws:iam::111122223333:role/app-*",
"arn:aws:iam::111122223333:policy/AppTeamBoundary"
]
}
]
}
The first statement uses the iam:PermissionsBoundary condition key so that role creation and policy attachment succeed only when the target role carries the approved boundary. The second statement closes the three escape hatches: removing the boundary, swapping it, or editing the boundary policy itself. Omitting any of these makes the whole construct decorative.
Boundaries do not limit resource policies the way you expect
The principal-type table from the evaluation section matters here. If a resource-based policy names a role by ARN, the grant is still limited by the role’s boundary. If it names the assumed-role session ARN, the boundary does not apply to that grant. Boundaries also constrain only IAM users and roles, so they say nothing about grants made to other principal types. Teams that rely on boundaries as a universal safety net should audit resource policies separately, which is exactly what Access Analyzer’s external access findings (covered below) do.
Session policies
When a caller invokes AssumeRole, AssumeRoleWithSAML, AssumeRoleWithWebIdentity or GetFederationToken, it can pass an inline session policy and up to 10 managed policy ARNs to narrow the resulting session. The permissions of the session are the intersection of the role’s identity-based policies, its boundary, and the session policy. The documented size constraint is that the combined size of the passed JSON policy and the managed policy ARN characters is at most 2,048 characters, and only one JSON policy document can be passed. Session policies and session tags also count toward a separate session-token size limit, reported through SessionTokenUtilization.
A typical use is a multi-tenant broker. A backend holds one role that can read a tenant-partitioned S3 bucket, and for each customer request it assumes that role with a session policy restricting the key prefix to the caller’s tenant ID. The role is broad, but each credential handed to a downstream component is narrow. The limit of 2,048 characters is tight, so it encourages short, parameterized policies rather than per-request generation of long ones.
Cross-Account Access, Trust Policies and Confused Deputies
Single-account evaluation lets either a resource-based or an identity-based policy grant access. Cross-account evaluation is stricter, and trust policies add a second authorization surface that is easy to misconfigure.

Figure 3: Cross-account access through a role. STS checks the caller’s identity policy and the target’s trust policy, then the session is evaluated against the target account’s ceilings.
Both sides must say yes
When a principal in Account A reaches a resource in Account B, the standard model is that Account A must allow it through an identity-based policy on the principal, and Account B must allow it through a resource-based policy or, for role-based access, a trust policy. This is general AWS behavior documented across the IAM and STS guides; the AWS page dedicated to cross-account evaluation was not retrievable when this article was prepared, so confirm the exact rules for your service on that page before relying on edge cases.
The role-based pattern has three artifacts. In Account A, the caller’s identity policy allows sts:AssumeRole on the target role ARN. In Account B, the role’s trust policy names the Account A principal. Also in Account B, the role’s permission policies define what the session can do. After assumption, the session carries the permissions of the role in Account B, not the caller’s original permissions, which is why Account B’s SCPs, boundary and RCPs apply to it and Account A’s do not.
{
"Version": "2012-10-17",
"Statement": [{
"Sid": "AllowVendorAssumeWithExternalId",
"Effect": "Allow",
"Principal": { "AWS": "arn:aws:iam::999988887777:root" },
"Action": "sts:AssumeRole",
"Condition": {
"StringEquals": { "sts:ExternalId": "example-unique-id-1234" }
}
}]
}
Naming an account’s :root as the principal in a trust policy delegates the decision to that account’s IAM administrators: any principal in that account that also has an identity policy permitting sts:AssumeRole on this role can assume it. It is a convenient pattern for vendors and a dangerous one for first-party roles, where naming the specific role ARN is tighter. The trust-policy length default is 2,048 characters, adjustable to 8,192, so large allow-lists of principals should be replaced with organization-based conditions.
Confused deputy: external IDs and source conditions
The confused deputy problem occurs when an entity without permission coerces a more privileged entity into acting for it. AWS documents two variants. In the cross-account variant, a third party you have delegated access to, such as a monitoring vendor, is tricked into using your role on behalf of another customer. In the cross-service variant, an AWS service principal is manipulated into reading or writing your resources on behalf of someone else’s configuration.
The defenses are different for each:
| Scenario | Mitigation | Condition key |
|---|---|---|
| Third party assumes your role | Require a unique, vendor-generated ID in the trust policy | sts:ExternalId |
| Service principal acts for an unauthorized account | Restrict by source account | aws:SourceAccount |
| Service principal acts for an unauthorized resource | Restrict by source resource ARN | aws:SourceArn |
| Service principal acts for another organization | Restrict by organization | aws:SourceOrgID, aws:SourceOrgPaths |
AWS recommends adding these keys whenever you grant access to a service principal, and notes that cross-service protections can also be applied centrally with RCPs. The external ID works because the vendor generates a unique value per customer and supplies it on AssumeRole; another customer who submits your role ARN will cause the vendor to send their external ID, and the call fails.
Role chaining and session length
Role chaining means using a role to assume a second role. AWS documents that chaining limits the console, CLI or API role session to a maximum of one hour regardless of the maximum session duration configured on the roles, and that supplying a DurationSeconds over one hour in a chained call fails. Outside chaining, the role session maximum can range from 1 to 12 hours, with a 900-second minimum for DurationSeconds and a one-hour default.
This has an operational consequence for CI systems that chain from an OIDC-federated role into an account-specific deployment role: a long Terraform apply that outlasts an hour will fail mid-run on credential expiry. The fix is either a single-hop federation into the deployment role or splitting the pipeline into shorter stages.
ABAC, Conditions and the Anatomy of a Policy Decision
Role-based policies enumerate resources; attribute-based access control (ABAC) enumerates relationships between principal tags and resource tags. For organizations with many teams and accounts, ABAC reduces policy count dramatically, but it moves the security boundary into tagging governance.
Tag-based conditions
The core pattern compares a tag on the calling principal to a tag on the target resource:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "ManageOwnTeamInstances",
"Effect": "Allow",
"Action": ["ec2:StartInstances", "ec2:StopInstances"],
"Resource": "arn:aws:ec2:*:*:instance/*",
"Condition": {
"StringEquals": {
"aws:ResourceTag/team": "${aws:PrincipalTag/team}"
}
}
},
{
"Sid": "DenyTagTampering",
"Effect": "Deny",
"Action": ["ec2:CreateTags", "ec2:DeleteTags"],
"Resource": "arn:aws:ec2:*:*:instance/*",
"Condition": {
"ForAnyValue:StringEquals": { "aws:TagKeys": ["team"] }
}
}
]
}
One policy now serves every team: a principal tagged team=payments can start only instances tagged team=payments. The second statement is the part teams forget. If principals can edit the team tag on a resource, they can re-tag an instance into their own team and gain access. In real deployments the deny should exempt a tag-administration role, and an SCP should enforce it organization-wide so a local admin cannot remove it.
Worked evaluation: four requests, four outcomes
Consider a role AppRole in account 1111, with an identity policy allowing s3:GetObject and s3:PutObject on arn:aws:s3:::reports-bucket/*, a permissions boundary allowing only s3:GetObject and dynamodb:*, and an organization with a deny-list SCP blocking s3:DeleteBucket. The bucket is in the same account and has no bucket policy.
| Request | Explicit deny? | SCP and RCP | Grant | Boundary | Result |
|---|---|---|---|---|---|
s3:GetObject on reports-bucket/a.csv |
None | Allowed by FullAWSAccess | Identity policy | Allows | Allow |
s3:PutObject on reports-bucket/b.csv |
None | Allowed | Identity policy | Does not allow, so implicit deny | Deny |
s3:DeleteBucket on reports-bucket |
SCP deny | n/a | none | n/a | Deny (explicit) |
dynamodb:GetItem on a table |
None | Allowed | No identity policy allows it | Allows, but a boundary cannot grant | Deny (implicit) |
Row two shows a ceiling doing its job: the grant exists but the boundary does not cover it. Row four shows the converse, a boundary that is generous but supplies nothing without a grant. Now change one fact: add a bucket policy naming the role ARN with s3:PutObject. In a same-account evaluation the grant is limited by the boundary for role ARNs, so row two stays Deny. Change the bucket policy principal to the assumed-role session ARN and, per the principal-type rule, the boundary no longer limits the grant, so the request would be allowed. The identical-looking policy flips the answer, which is why principal naming in resource policies deserves review.
Next, consider a cross-account variant. A role in account 2222 calls s3:GetObject on a bucket in 1111. If the bucket policy allows the role but the role’s identity policy does not mention the bucket, the request is denied, since cross-account access needs both sides. If the SCP for 2222 denies S3 outright, the request is denied before the bucket policy is even considered, because the SCP governs the principal’s account. If instead an RCP on 1111 denies requests from principals outside the organization, a request from an external account is denied no matter how permissive the bucket policy is.
Least Privilege in Practice with IAM Access Analyzer
Writing a least-privilege policy by hand is guesswork; maintaining one over a year is worse, because applications change and permissions accrete. IAM Access Analyzer turns least privilege into a feedback loop with evidence at each step.

Figure 4: A closed least-privilege loop. Usage evidence narrows policies, validation gates changes, and unused-access findings drive the next round of cleanup.
The analyzer types
Per the AWS documentation, Access Analyzer has several analyzer types, each answering a different question:
- External access analyzer. Identifies resources shared with entities outside your zone of trust (your organization or a single account). It uses logic-based reasoning over resource-based policies and generates a finding per instance of external access. It is Regional: create one per Region that holds supported resources. New or changed policies are analyzed within about 30 minutes, with periodic scans in addition. Supported resource types include S3 buckets, IAM roles, KMS keys, Lambda functions and layers, SQS queues, Secrets Manager secrets, SNS topics, EBS and RDS snapshots, ECR repositories, EFS file systems and DynamoDB tables and streams.
- Internal access analyzer. Identifies which principals inside your organization or account can reach selected business-critical resources, currently covering S3 buckets, RDS snapshots and DynamoDB tables and streams.
- Unused access analyzer. Continuously monitors IAM roles and users and reports unused roles, access keys and passwords. For active principals it reports unused services and actions based on last-accessed information. Findings do not vary by Region, so one analyzer suffices, and service-linked roles are not analyzed.
Pricing differs by type: unused access is charged per IAM role and user analyzed, per analyzer, per month; internal access is charged per resource monitored; custom policy checks are charged per API request. Consult the current pricing page before enabling organization-wide, since a large estate multiplies per-principal charges.
Policy generation from CloudTrail
Policy generation analyzes CloudTrail logs for the actions and services an IAM user or role used within a date range you specify, and produces a policy reflecting that activity. The workflow is to start a development or staging role with a broad policy, exercise the application thoroughly, generate a policy from the observed calls, and review it before attaching.
Treat the output as a draft, not a verdict. Usage-based generation only knows what ran during the window. A quarterly batch job, a disaster-recovery path or an error-handling branch that never fired will be missing, and the generated policy will break them silently later. Review generated resource ARNs for over-narrow or over-broad scoping, and keep a note of which infrequent paths the window did not cover.
Validation and custom policy checks
Policy validation checks a policy against IAM grammar and AWS best practices, returning security warnings, errors, general warnings and suggestions via the CLI, the API and the JSON editor in the IAM console. Custom policy checks go further and validate against your standards. In the console they can tell you whether an updated policy grants new access compared to the existing version. Through the CLI and API they can verify that specific critical actions are not allowed by a policy.
This is the piece that makes the loop automatable. A pipeline step can call the check against every changed policy and fail the build if a pull request introduces iam:PassRole on *, or grants any new access relative to the version in production. That converts “a human skims a diff” into a deterministic gate.
Terraform: enabling the analyzers
The following Terraform enables an organization-wide unused access analyzer with a 90-day window and an external access analyzer for one Region. Run it from the management account or a delegated administrator account for Access Analyzer. The 90-day value is a reasonable starting point (illustrative; tune to your release cadence).
resource "aws_accessanalyzer_analyzer" "org_unused" {
analyzer_name = "org-unused-access"
type = "ORGANIZATION_UNUSED_ACCESS"
configuration {
unused_access {
unused_access_age = 90
}
}
}
resource "aws_accessanalyzer_analyzer" "org_external" {
analyzer_name = "org-external-access-eu-west-1"
type = "ORGANIZATION"
}
Pair this with an EventBridge rule that routes new findings to your ticketing or chat system. An analyzer nobody reads is shelfware. Define who owns findings per account, set a service-level target for triage, and use archive rules for known-intended sharing such as a public website bucket so that the remaining findings are signal.
Running the loop
The cycle in Figure 4 is repeatable. Generate from usage, validate, review in a pull request via terraform plan, deploy, then let the unused access analyzer tell you in 90 days which granted actions were never called. Remove them, and feed the result back. For human roles managed through IAM Identity Center, the same findings apply to the permission-set roles it creates in each account, so unused-access cleanup lands in the permission set definition rather than in a hand-edited role. For the infrastructure-as-code side of this workflow, including drift detection that catches console-edited policies, see our guide to Terraform state management and drift detection at scale.
Privilege Escalation: The Paths Ceilings Do Not Close by Default
Least privilege is not just about the number of actions; it is about which combinations let a principal grant itself more. Several well-known paths convert a seemingly modest permission into administrator access.
iam:PassRole on a wildcard. iam:PassRole lets a principal hand a role to an AWS service. If a developer can pass any role to Lambda or EC2 and can create a function or instance, they can run code with the most privileged role in the account. Scope iam:PassRole to specific role ARNs and add the iam:PassedToService condition so the role can be passed only to the intended service.
Policy self-editing. iam:CreatePolicyVersion, iam:SetDefaultPolicyVersion, iam:AttachUserPolicy, iam:PutRolePolicy and similar write actions allow a principal to rewrite its own permissions. Any identity holding these on itself is effectively an administrator.
Trust policy edits. iam:UpdateAssumeRolePolicy on a privileged role lets an attacker add themselves as a trusted principal and assume it.
Compute-based pivots. Permissions to modify a Lambda function’s code, an ECS task definition, or an EC2 instance’s user data can execute attacker code under that compute’s attached role. The grant looks like “update a function,” but the effect is “act as the function’s role.”
Ceilings help here only if they are written for it. A permission boundary that allows iam:* leaves every path above open; a boundary that excludes IAM write actions closes most of them. Likewise, an SCP that denies iam:CreateUser and iam:CreateAccessKey in workload accounts removes the long-lived-credential route entirely. The pattern is to deny the dangerous primitives centrally and grant business actions locally.
Trade-offs, Gotchas, and What Goes Wrong
IAM’s layered model buys safety and costs debuggability. These are the failure modes that show up most often in real estates.
The management account blind spot. SCPs do not restrict the management account. Any workload, CI runner or human operating there sits outside your guardrails. Keep it to organization administration only, minimize who can sign in, and monitor it with detective controls since preventive ones cannot reach it.
Allow-list SCPs and the Friday outage. Allow-list SCPs concentrate risk. Because an Allow must exist at every level, a mistaken edit near the root can block an entire organization. Test SCP changes in a sandbox OU, attach them first to a canary OU, and keep the break-glass path outside the SCP’s reach, such as the management account.
Policy size limits. Customer managed policies cap at 6,144 characters (white space not counted). Inline policies are capped in aggregate per entity: 2,048 for a user, 5,120 for a group and 10,240 for a role. A role can have 20 managed policies attached by default, adjustable to 25. Teams hit these limits by pasting resource ARNs one by one; the fix is wildcards on well-structured naming, tag conditions, or splitting policies by function, not disabling validation.
Wildcards that read as safe. "Action": "s3:*" on "Resource": "*" looks bounded by service but reads, writes and deletes every bucket, including ones created later. A permission that is narrow today becomes broad tomorrow when new resources match the pattern.
Implicit versus explicit deny confusion. Implicit denies can be overridden by a grant elsewhere; explicit denies cannot. When you truly need something to be impossible, use an explicit Deny in an SCP, not the absence of an Allow in a local policy.
Conditions that fail open. A Deny with StringNotEquals on a key that is absent from the request context can behave differently from what the author expected. Test negated conditions against requests that lack the key, using the policy simulator or real calls in a sandbox, and prefer Null checks where the intent is “key must be present.”
Testing gaps. Analyzer findings are only as current as their scan. The documentation notes that some changes can take up to 6 hours (multi-Region access points) or up to 24 hours (delivery issues) to be reflected. Do not treat a clean dashboard minutes after a change as proof of correctness.
Debugging across accounts. A denial caused by an SCP or RCP must be investigated with the Organizations administrator, and the principal’s account admin often cannot even see the policy. Agree on a shared read-only view of organization policies for incident responders before you need it.
Practical Recommendations
Start with the model, not the tooling: grants give, ceilings limit, explicit deny wins. Everything else is a consequence. A team that internalizes this can predict most outcomes before running a simulator.
For the organization layer, adopt a deny-list SCP strategy with FullAWSAccess kept in place, and write small, thematic SCPs (region restriction, audit-trail protection, no long-lived IAM users, no leaving the organization) rather than one monolith. Add RCPs for a data perimeter on the services they support, and verify support per service. Keep workloads out of the management account.
For the account layer, use permission boundaries wherever you delegate role creation, and always pair the boundary with the three deny statements that stop it from being removed or edited. Prefer role ARNs over session ARNs in resource policies, and require sts:ExternalId for every third-party trust. For identity sources, prefer federation through IAM Identity Center or OIDC over long-lived IAM users. For the encryption side, see our piece on envelope encryption and KMS key management, since KMS key policies are one of the two resource policy types that must explicitly allow principals.
A starting checklist:
- [ ] SCPs use a deny list;
FullAWSAccessis attached at root; changes are tested in a canary OU. - [ ] Management account hosts no workloads and has minimal human access.
- [ ] Every delegated role-creation path requires the
iam:PermissionsBoundarycondition, with deny statements protecting the boundary. - [ ]
iam:PassRoleis scoped by role ARN andiam:PassedToService. - [ ] All third-party trust policies require a unique
sts:ExternalId; service principals are restricted withaws:SourceArnoraws:SourceAccount. - [ ] Unused access and external access analyzers are enabled, with finding owners and archive rules.
- [ ] Custom policy checks run in CI against every IAM change.
- [ ] Resource policies are reviewed for session-ARN principals and wildcard
aws:PrincipalArnpatterns. - [ ] Pipelines that chain roles complete within one hour or are split.
For teams extending the same policy-as-code discipline to Kubernetes, our comparison of Kyverno and OPA Gatekeeper covers the cluster-side equivalent of these guardrails.
Frequently Asked Questions
What is the order of IAM policy evaluation?
IAM first checks all applicable policies for an explicit Deny, which ends evaluation with a Deny. If none exists, the request must be allowed by RCPs and SCPs where they apply, then by a grant in a resource-based or identity-based policy, and then by any permissions boundary and session policy. If nothing explicitly allows the request, the default implicit deny applies. The result does not depend on policy attachment order.
Does an SCP grant permissions?
No. AWS documents that SCPs do not grant permissions to users or roles; they only set the maximum permissions available in member accounts. You still need identity-based or resource-based policies to grant access. The effective permission is the intersection of what the SCPs and RCPs allow and what the grants allow. SCPs also do not affect the management account or service-linked roles.
What is the difference between a permission boundary and an SCP?
A permission boundary is a managed policy attached to a single IAM user or role that caps what its identity-based policies can grant. An SCP is attached to the organization root, an OU or an account, and caps every principal in the affected member accounts. Neither grants anything. Boundaries are for delegating role creation safely; SCPs are for organization-wide guardrails.
Does an explicit deny always win?
Yes, within IAM evaluation. If the enforcement code finds even one applicable Deny statement in any SCP, RCP, resource-based policy, identity-based policy, permission boundary or session policy, the final decision is Deny, and no Allow elsewhere can override it. Conditions matter: a Deny applies only when its conditions match the request context, so a Deny that does not match is ignored.
How does Access Analyzer help with least privilege?
It supports several steps. The unused access analyzer reports unused roles, access keys, passwords, services and actions. Policy generation builds a draft policy from CloudTrail activity over a date range. Policy validation and custom policy checks gate changes against grammar, best practices and your own standards. External access findings show resources shared outside your zone of trust. Together they form a measure, narrow, verify loop.
Why is cross-account access denied even though the bucket policy allows it?
Cross-account access generally requires permission on both sides. The bucket policy in the resource account allows the principal, but the principal’s own account must also allow the action through an identity-based policy, and that account’s SCPs, permission boundary and session policy must not block it. An RCP in the resource account or an explicit Deny anywhere also ends the request.
Further Reading
- SPIFFE and SPIRE workload identity architecture: how workloads prove identity before IAM decides what they may do.
- Envelope encryption, KMS and HSM key management: key policies and rotation, where resource policies carry the decision.
- Terraform state management and drift detection at scale: delivering IAM changes through reviewed plan and apply pipelines.
- Kyverno vs OPA Gatekeeper for Kubernetes policy-as-code: the cluster-side counterpart to account guardrails.
- AWS IAM User Guide: policy evaluation logic: the authoritative evaluation reference.
- AWS Organizations: SCP evaluation: inheritance and allow-list versus deny-list behavior.
By Riju — about
