Security & Identity

AWS IAM Policies: The Complete Engineer's Guide

Identity-based, resource-based, SCPs, RCPs, permissions boundaries, session policies, and VPC endpoint policies — what each type controls, what actually grants access, what only restricts it, and exactly how AWS evaluates all of them together into a single Allow or Deny.

AWS IAM Security SCPs RCPs Permissions Boundaries Organizations Access Control

What's in this article

  1. Why seven policy types exist
  2. Identity-based policies
  3. Resource-based policies
  4. Permissions boundaries
  5. Service Control Policies (SCPs)
  6. Resource Control Policies (RCPs)
  7. Session policies
  8. VPC endpoint policies
  9. How AWS evaluates all seven together
  10. Enterprise patterns
  11. Quick reference
01

Why seven policy types exist

IAM starts with a simple idea: attach a policy to an identity, and that policy describes what the identity can do. But AWS has grown into a platform with hundreds of services, multi-account organisations, cross-account architectures, federated identities, and compliance requirements that need hard guardrails that no single team can override. A single policy type cannot model all of that.

Each of the seven policy types solves a distinct problem at a different layer of the stack. Understanding what problem each type solves — who attaches it, who it applies to, whether it grants or only restricts — is what separates engineers who configure IAM from engineers who design it.

IAM policy types — scope and purpose at a glance
IAM policy types reference table Policy Type Attached To Grants? Primary Purpose Identity-Based Users, Roles, Groups Yes Define what a principal can do Resource-Based Resources (S3, SQS, KMS…) Yes Define who can access a resource Permissions Boundary Users and Roles only No Cap maximum effective permissions SCP (Org) OUs and Accounts No Restrict all principals in the account RCP (Org) OUs and Accounts No Restrict access to resources in scope Session Policy Active sessions (AssumeRole) No Scope-down a temporary credential VPC Endpoint Policy VPC interface/gateway endpoints No Filter access by network path

The most important thing to internalise before going further: only identity-based policies and resource-based policies actually grant access. Every other policy type — SCPs, RCPs, permissions boundaries, session policies, VPC endpoint policies — can only restrict. They narrow the permission space; they cannot expand it. This asymmetry is the foundation of the AWS evaluation model.

02

Identity-based policies

Identity-based policies are attached directly to IAM principals — users, groups, or roles — and describe what those principals are allowed (or explicitly denied) to do. They are the most common policy type and the starting point for almost every IAM configuration.

Managed vs. inline

Identity-based policies come in two forms. Managed policies exist as standalone IAM resources with their own ARN. They can be attached to multiple principals and are versioned — up to ten versions per policy, with easy rollback. AWS ships its own managed policies (AmazonS3ReadOnlyAccess, PowerUserAccess, etc.) for convenience, but these are almost always too broad for production workloads. Customer-managed policies are the right choice: you control them, you version them, and updating one policy propagates immediately to every role it is attached to.

Inline policies are embedded directly in the IAM principal and have no independent existence — deleting the principal deletes the policy. They make sense only when a policy must be specific to exactly one entity and should never be shared. In practice, customer-managed policies are almost always preferable: they are reusable, versionable, and visible in IAM policy reports and Access Analyzer findings.

The implicit deny. Identity-based policies operate on a default-deny model. Anything not explicitly allowed is implicitly denied. You do not need to write Effect: Deny for every action you want to block — silence is already a deny. Explicit denies are for situations where you want to override an Allow that might come from elsewhere.
LearnCloud FinOps — discount code for FinOpsX event and FinOps certifications

Discount code for FinOpsX event and FinOps certifications — LEARNCLOUDX26 | LEARNCLOUD

Simple example — read access to a specific S3 bucket

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "ReadBucketObjects",
      "Effect": "Allow",
      "Action": [
        "s3:GetObject",
        "s3:ListBucket"
      ],
      "Resource": [
        "arn:aws:s3:::my-app-assets",
        "arn:aws:s3:::my-app-assets/*"
      ]
    }
  ]
}

Two resource ARNs are required here: one for s3:ListBucket (which operates on the bucket itself) and one with a trailing /* for object-level actions like s3:GetObject. Using only my-app-assets/* silently breaks ListBucket — one of the most common IAM mistakes.

Real-world example — developer role scoped to one environment

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "LambdaDeployDev",
      "Effect": "Allow",
      "Action": [
        "lambda:CreateFunction",
        "lambda:UpdateFunctionCode",
        "lambda:UpdateFunctionConfiguration",
        "lambda:GetFunction",
        "lambda:InvokeFunction",
        "lambda:ListFunctions",
        "lambda:PublishVersion",
        "lambda:CreateAlias",
        "lambda:UpdateAlias"
      ],
      "Resource": "arn:aws:lambda:ap-southeast-2:111122223333:function:dev-*",
      "Condition": {
        "StringEquals": {
          "aws:RequestedRegion": "ap-southeast-2"
        }
      }
    },
    {
      "Sid": "ECRReadForDeploy",
      "Effect": "Allow",
      "Action": [
        "ecr:GetAuthorizationToken",
        "ecr:BatchCheckLayerAvailability",
        "ecr:GetDownloadUrlForLayer",
        "ecr:BatchGetImage"
      ],
      "Resource": "*"
    },
    {
      "Sid": "HardDenyProduction",
      "Effect": "Deny",
      "Action": "*",
      "Resource": "arn:aws:lambda:ap-southeast-2:111122223333:function:prod-*"
    }
  ]
}

The explicit Deny on production resources is belt-and-suspenders. Without it, someone could theoretically attach a second managed policy to this role that grants broader access, and the Allow would take effect. The explicit Deny guarantees production functions remain unreachable from this role regardless of what other policies are attached — because explicit Deny always wins.

03

Resource-based policies

Resource-based policies are attached to resources rather than identities. Where identity-based policies answer "what can this principal do?", resource-based policies answer "who can access this resource?". The defining structural difference is the Principal element — resource-based policies explicitly name who the policy applies to.

Not every AWS service supports resource-based policies. The most commonly used ones: S3 (bucket policies), SQS (queue policies), SNS (topic policies), KMS (key policies), Lambda (resource-based policy for invocation), Secrets Manager, API Gateway, ECR (repository policies), EventBridge event buses, and IAM roles (trust policies). The IAM role trust policy — which defines who can assume a role — is technically a resource-based policy on the role itself.

Cross-account access logic

This is where resource-based policies become indispensable. The evaluation rules differ meaningfully between same-account and cross-account access.

Cross-account access — what's required on each side
Cross-account IAM access requirements Account A — Resource Owner S3 Bucket / KMS Key / SQS Queue Resource-Based Policy Principal: arn:aws:iam::ACCT-B:role/MyRole Effect: Allow Action: s3:GetObject Account B — Principal IAM Role (MyRole) Identity-Based Policy Resource: arn:aws:s3:::acct-a-bucket/* Effect: Allow Action: s3:GetObject BOTH REQUIRED

For same-account access: either an identity-based policy or a resource-based policy is sufficient — whichever allows the action. For cross-account access: both sides must explicitly allow it. The resource-based policy in Account A must name Account B's principal, and an identity-based policy in Account B must allow the action on Account A's resource. Neither side alone is enough.

Simple example — S3 bucket policy with secure transport enforcement

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "AllowAppRoleReadWrite",
      "Effect": "Allow",
      "Principal": {
        "AWS": "arn:aws:iam::111122223333:role/MyAppRole"
      },
      "Action": [
        "s3:GetObject",
        "s3:PutObject",
        "s3:DeleteObject"
      ],
      "Resource": "arn:aws:s3:::my-data-bucket/*"
    },
    {
      "Sid": "DenyInsecureTransport",
      "Effect": "Deny",
      "Principal": "*",
      "Action": "s3:*",
      "Resource": [
        "arn:aws:s3:::my-data-bucket",
        "arn:aws:s3:::my-data-bucket/*"
      ],
      "Condition": {
        "Bool": {
          "aws:SecureTransport": "false"
        }
      }
    }
  ]
}

Real-world example — cross-account KMS key with service restriction

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "KeyAdministration",
      "Effect": "Allow",
      "Principal": {
        "AWS": "arn:aws:iam::SECURITY-ACCOUNT:root"
      },
      "Action": "kms:*",
      "Resource": "*"
    },
    {
      "Sid": "CrossAccountDecrypt",
      "Effect": "Allow",
      "Principal": {
        "AWS": [
          "arn:aws:iam::APP-ACCOUNT-1:role/DataProcessingRole",
          "arn:aws:iam::APP-ACCOUNT-2:role/DataProcessingRole"
        ]
      },
      "Action": [
        "kms:Decrypt",
        "kms:DescribeKey",
        "kms:GenerateDataKey"
      ],
      "Resource": "*",
      "Condition": {
        "StringEquals": {
          "kms:ViaService": [
            "s3.ap-southeast-2.amazonaws.com",
            "secretsmanager.ap-southeast-2.amazonaws.com"
          ]
        }
      }
    }
  ]
}

The kms:ViaService condition is doing critical work: it permits decryption only when the call originates from S3 or Secrets Manager on behalf of the calling role — not from direct KMS API calls. This prevents a compromised credential from calling kms:Decrypt directly to extract plaintext key material.

04

Permissions boundaries

A permissions boundary is a customer-managed IAM policy attached to a user or role that defines the maximum permissions that entity can ever have. It does not grant anything — it only limits. The effective permissions are always the intersection of the identity-based policies and the boundary:

Effective Permissions = Identity Policy ∩ Permissions Boundary

If the identity policy allows s3:* but the boundary only permits s3:GetObject and s3:PutObject, the effective permissions are just those two actions — no matter how broad the identity policy is. Adding s3:DeleteObject to the identity policy without also updating the boundary has zero effect.

Permissions boundaries do not restrict resource-based policies. If a resource-based policy grants a principal cross-account access to a resource, the boundary does not block that access. Boundaries only constrain what identity-based policy grants can be exercised.

The enterprise use case: delegated role creation

Permissions boundaries solve one of the hardest problems in enterprise IAM: how do you let application teams create their own IAM roles — for Lambda functions, ECS tasks, EC2 instance profiles — without letting them create roles with more permissions than they themselves have?

Without boundaries, a developer with iam:CreateRole and iam:AttachRolePolicy can create a role with AdministratorAccess and effectively escalate their own privileges. With boundaries, you require that every role they create must have a specific boundary policy attached — and that boundary limits what the created role can ever do. The platform team controls the boundary definition; the app team controls the role within that boundary.

The platform team's policy — enforcing the boundary on role creation

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "AllowRoleCreationWithMandatoryBoundary",
      "Effect": "Allow",
      "Action": [
        "iam:CreateRole",
        "iam:PutRolePolicy",
        "iam:AttachRolePolicy",
        "iam:DetachRolePolicy"
      ],
      "Resource": "*",
      "Condition": {
        "StringEquals": {
          "iam:PermissionsBoundary":
            "arn:aws:iam::111122223333:policy/AppTeamBoundary"
        }
      }
    },
    {
      "Sid": "DenyBoundaryModification",
      "Effect": "Deny",
      "Action": [
        "iam:DeleteRolePermissionsBoundary",
        "iam:CreatePolicy",
        "iam:CreatePolicyVersion",
        "iam:DeletePolicy",
        "iam:DeletePolicyVersion"
      ],
      "Resource": "arn:aws:iam::111122223333:policy/AppTeamBoundary"
    }
  ]
}

The iam:PermissionsBoundary condition rejects any role creation call that does not specify the boundary ARN. The second statement prevents the app team from modifying or deleting the boundary itself — otherwise they could simply update the boundary to include AdministratorAccess and the protection collapses.

The boundary policy itself — limits on what app-team roles can do

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "AllowWorkloadServices",
      "Effect": "Allow",
      "Action": [
        "s3:*",
        "dynamodb:*",
        "sqs:*",
        "sns:*",
        "lambda:*",
        "logs:*",
        "cloudwatch:*",
        "xray:*",
        "ssm:GetParameter",
        "ssm:GetParameters",
        "secretsmanager:GetSecretValue"
      ],
      "Resource": "*"
    },
    {
      "Sid": "HardDenyIAMAndOrgs",
      "Effect": "Deny",
      "Action": [
        "iam:*",
        "organizations:*",
        "account:*"
      ],
      "Resource": "*"
    }
  ]
}

Any role the app team creates with this boundary can never touch IAM or Organizations — even if someone accidentally attaches AdministratorAccess to it. The Deny in the boundary overrides any Allow from the identity policy for those actions.

05

Service Control Policies (SCPs)

SCPs are an AWS Organizations feature. They define the maximum permissions available to all IAM principals — including the root user — in a given AWS account or OU. They do not grant permissions. They only limit what can ever be allowed within their scope.

The management account is NOT subject to SCPs. This is the most consequential misconception about SCPs. The management account of an AWS organization is permanently exempt. This is a strong reason to keep the management account completely empty of workloads — it has no SCP guardrails, ever.

Allow-list vs. deny-list mode

AWS Organizations attaches a FullAWSAccess SCP to every OU and account by default. This allows everything — it does not grant, it simply does not restrict. In practice you operate in one of two modes.

Deny-list mode (most common): keep FullAWSAccess and add Deny statements for what you want to block. You only enumerate the things you forbid, and everything else continues to work as identity-based policies allow. Easy to maintain; a new AWS service is accessible by default.

Allow-list mode: remove FullAWSAccess and replace with an SCP that explicitly allows only the services you have approved. More restrictive, harder to maintain, but appropriate for highly regulated accounts where a new AWS service should be unreachable until explicitly approved.

Simple example — prevent accounts leaving the organisation

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "PreventLeavingOrg",
      "Effect": "Deny",
      "Action": "organizations:LeaveOrganization",
      "Resource": "*"
    }
  ]
}

Real-world example — region lockdown (Australia only)

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "DenyOutsideApprovedRegions",
      "Effect": "Deny",
      "NotAction": [
        "iam:*",
        "organizations:*",
        "account:*",
        "cloudfront:*",
        "route53:*",
        "route53domains:*",
        "sts:*",
        "support:*",
        "trustedadvisor:*",
        "health:*",
        "billing:*",
        "budgets:*",
        "ce:*",
        "cur:*",
        "aws-marketplace:*",
        "globalaccelerator:*",
        "waf:*",
        "shield:*"
      ],
      "Resource": "*",
      "Condition": {
        "StringNotEquals": {
          "aws:RequestedRegion": [
            "ap-southeast-2",
            "us-east-1"
          ]
        }
      }
    }
  ]
}

NotAction is required here because global services like IAM, CloudFront, Route 53, and STS always appear as us-east-1 API calls — even when used globally. Without excluding them from the condition, you break those services everywhere. us-east-1 must also be in the allowed regions list for the same reason.

Apply SCPs in layers at the OU level, not on individual accounts. Baseline guardrails (no leaving org, CloudTrail protection, GuardDuty protection) go at the Root OU. Region restrictions go at the Workloads OU. Stricter production controls (no backup deletion, require encryption) go at the Production OU. This way every account inherits the appropriate cumulative set.
06

Resource Control Policies (RCPs)

RCPs were introduced by AWS in late 2023. They are the resource-side counterpart to SCPs. Where an SCP says "what principals in this account can do", an RCP says "what can be done to resources in this account". This distinction is fundamental — they are complementary, not overlapping.

RCPs are attached at the organization, OU, or account level — the same attachment points as SCPs — and apply to all resources of supported types in scope. As of 2025, supported resource types are Amazon S3, AWS STS, AWS KMS, Amazon SQS, and AWS Secrets Manager. The supported set is expanding.

The data exfiltration gap that RCPs close

SCPs prevent your own principals from doing bad things with resources. But they cannot stop a compromised credential from sending data to an S3 bucket in an attacker's account. The attacker's bucket is outside your organization's SCP scope — SCPs have no reach there. An open bucket policy on your bucket combined with a compromised credential pointing at an external destination is an exfiltration path that SCPs alone cannot close.

RCPs address this by letting you specify at the organization level that your own resources can only be accessed from identities within your organization. The restriction applies regardless of individual bucket policies, regardless of what identity policies allow.

Simple example — prevent S3 access from outside the organisation

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "EnforceOrgBoundaryOnS3",
      "Effect": "Deny",
      "Principal": "*",
      "Action": "s3:*",
      "Resource": "*",
      "Condition": {
        "StringNotEquals": {
          "aws:PrincipalOrgID": "o-xxxxxxxxxxxx"
        },
        "BoolIfExists": {
          "aws:PrincipalIsAWSService": "false"
        }
      }
    }
  ]
}

The BoolIfExists: aws:PrincipalIsAWSService: false condition is necessary to exempt AWS services — CloudFront, AWS Backup, S3 Replication — that legitimately need access to your buckets. Without it, you break those service integrations.

Real-world example — full data perimeter on S3

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "LimitS3DataAccessToOrg",
      "Effect": "Deny",
      "Principal": "*",
      "Action": [
        "s3:GetObject",
        "s3:PutObject",
        "s3:DeleteObject",
        "s3:GetBucketPolicy",
        "s3:PutBucketPolicy",
        "s3:PutBucketAcl"
      ],
      "Resource": "*",
      "Condition": {
        "StringNotEquals": {
          "aws:PrincipalOrgID": "o-xxxxxxxxxxxx"
        },
        "BoolIfExists": {
          "aws:PrincipalIsAWSService": "false"
        },
        "Null": {
          "aws:PrincipalAccount": "false"
        }
      }
    }
  ]
}
RCPs and SCPs are complementary, not overlapping. An SCP restricts what your principals can do (including with resources outside your org). An RCP restricts what any principal can do to your resources (including principals outside your org). Together they form a data perimeter. Add a VPC endpoint policy and you cover all three vectors: principal, resource, and network path.
07

Session policies

When you assume an IAM role, you receive temporary credentials valid for the configured session duration. By default, those credentials carry the full permissions of the role's identity-based policies. Session policies let you scope those credentials down further — at the moment of assumption, for the lifetime of that specific session only.

Session policies are passed as a parameter in AssumeRole, AssumeRoleWithSAML, AssumeRoleWithWebIdentity, or GetFederationToken API calls. They can be inline JSON (up to 2,048 characters) or a managed policy ARN. The session's effective permissions are the intersection of the role's identity policies and the session policy — the session policy can only remove permissions, never add new ones.

Simple example — scope a CI/CD session to one environment

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": [
        "lambda:UpdateFunctionCode",
        "lambda:UpdateFunctionConfiguration",
        "lambda:PublishVersion"
      ],
      "Resource": "arn:aws:lambda:ap-southeast-2:111122223333:function:dev-*"
    }
  ]
}

This session policy is passed when the CI/CD pipeline calls AssumeRole. Even if the deployment role has full Lambda access across all environments, this session can only update functions in the dev- namespace. Production functions are unreachable from this credential regardless of what else the role allows.

Real-world example — tenant-scoped DynamoDB access in a SaaS application

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": [
        "dynamodb:GetItem",
        "dynamodb:PutItem",
        "dynamodb:UpdateItem",
        "dynamodb:DeleteItem",
        "dynamodb:Query",
        "dynamodb:BatchGetItem",
        "dynamodb:BatchWriteItem"
      ],
      "Resource": "arn:aws:dynamodb:ap-southeast-2:111122223333:table/AppData",
      "Condition": {
        "ForAllValues:StringEquals": {
          "dynamodb:LeadingKeys": ["tenant-abc-123"]
        }
      }
    }
  ]
}

In a multi-tenant SaaS application, one shared IAM role accesses the entire DynamoDB table. When a user authenticates via Cognito or a custom IdP, the backend calls AssumeRoleWithWebIdentity and passes a session policy scoped to only that tenant's partition key prefix. Every tenant receives separate, isolated credentials. One role, one table, complete row-level isolation — implemented entirely at the IAM session layer without any application-level enforcement.

Session policies are ephemeral by design. They are generated at call time from runtime context (the authenticated user's attributes, the deployment target, the tenant ID) and exist only for the session duration. This makes them ideal for attribute-based access control where the scope is computed dynamically rather than pre-configured statically in IAM.
08

VPC endpoint policies

When you create a VPC endpoint — either a Gateway endpoint (S3, DynamoDB) or an Interface endpoint (most other services) — you can attach a resource-based policy to the endpoint itself. This policy acts as an additional layer of filtering on all traffic passing through that endpoint. Even if a principal has full IAM permission to perform an action, the call is denied if it travels through the endpoint and the endpoint policy does not allow it.

The default endpoint policy is allow-all, meaning by default endpoint policies add no restriction. Their value is in locking them down. The most common use case is ensuring that S3 traffic through your VPC endpoint can only reach buckets within your own organization — not external buckets in an attacker's account.

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "AllowOrgBucketsOnly",
      "Effect": "Allow",
      "Principal": "*",
      "Action": "s3:*",
      "Resource": "*",
      "Condition": {
        "StringEquals": {
          "aws:ResourceOrgID": "o-xxxxxxxxxxxx"
        }
      }
    }
  ]
}

Any request through this endpoint to an S3 bucket outside your organization is denied at the network layer — regardless of the requester's IAM permissions and regardless of that bucket's policy. This is the network control of the data perimeter: combined with SCPs (principal control) and RCPs (resource control), it covers all three vectors and makes data exfiltration extremely difficult even with fully compromised credentials.

09

How AWS evaluates all seven together

On every API call, AWS runs all applicable policies through a deterministic evaluation algorithm and arrives at a single answer: Allow or Deny. Understanding this algorithm transforms IAM from a collection of configuration files into a coherent security model you can reason about and debug systematically.

Each layer below acts as a filter. A request that fails any check stops there with a Deny. Only if it passes every applicable layer does it reach the final stage — where at least one policy must explicitly grant an Allow.

IAM policy evaluation — complete decision flow
IAM policy evaluation decision flow Incoming AWS API Request 1 Explicit Deny Check — applies to all policy types Any attached policy (any type) contains an explicit Deny for this action and resource? An explicit Deny anywhere permanently terminates evaluation — it overrides every Allow. YES → DENY NO ↓ 2 Service Control Policy (SCP) Account is in AWS Organizations? An SCP explicitly denies this action, or no SCP allows it (allowlist mode)? SCPs restrict — they never grant access on their own. YES → DENY NO ↓ 3 Resource Control Policy (RCP) An RCP applies to this resource type and explicitly denies access, or no RCP allows this action? RCPs restrict what any principal can do to your resources. YES → DENY NO ↓ 4 VPC Endpoint Policy Request travels through a VPC endpoint that has a custom (non-default) policy, and that policy does not allow this action? Only evaluated when using an endpoint. YES → DENY NO ↓ 5 Permissions Boundary The principal has a permissions boundary set, and this action is outside the boundary's allowed set? Boundaries only constrain identity-based policy grants. YES → DENY NO ↓ 6 Session Policy These credentials carry an attached session policy, and that session policy does not allow this action? Session policies only restrict — they never expand role permissions. YES → DENY NO ↓ 7 Identity-Based or Resource-Based Policy — the only stage that grants access Does any identity-based policy or resource-based policy grant an explicit Allow for this action on this resource? For cross-account, both sides must grant Allow. NO → DENY YES ↓ Access Granted All applicable policy layers passed — the API call is allowed to proceed. An explicit Deny at any layer immediately terminates with Deny and cannot be overridden by an Allow anywhere else.

Same-account vs. cross-account at Stage 7

The final grant stage works differently depending on whether the principal and resource are in the same AWS account.

For same-account access: either an identity-based policy or a resource-based policy granting Allow is sufficient — whichever one allows the action first satisfies the requirement. Both together is fine too; they combine with OR logic for same-account requests.

For cross-account access: both sides must explicitly grant Allow. The resource-based policy in Account A must name Account B's principal and allow the action. And an identity-based policy in Account B must allow the action on Account A's resource. One side alone is not sufficient for most services.

The core mental model: Stages 1–6 only restrict. Nothing in those stages can grant you access. Stage 7 is the only place access is actually granted. When debugging an unexpected Deny, first verify you have a grant (Stage 7), then work upwards through the restriction layers until you find what's blocking it.
10

Enterprise patterns

OU structure with SCPs at every level

A well-designed AWS organisation uses SCPs as descending, cumulative guardrails. Policies at outer OUs restrict what is possible at inner OUs — a child OU cannot override or loosen a Deny at a parent OU. The typical structure looks like this:

Root OU
  └── Baseline SCP: no leaving org, protect CloudTrail, enforce GuardDuty
      │
      ├── Workloads OU
      │     └── Region SCP: allow only ap-southeast-2 + us-east-1 (global services)
      │         │
      │         ├── Production OU
      │         │     └── Strict SCP: deny backup deletion, require CMK tags,
      │         │             deny disabling Config rules, deny public S3 ACLs
      │         │
      │         └── Non-Production OU
      │               └── Relaxed SCP: most services available, no billing limits
      │
      ├── Sandbox OU
      │     └── Sandbox SCP: cap spend, deny VPC peering to prod, deny Route53 changes
      │
      └── Security OU
            └── Minimal SCP: security tooling needs broader access, carefully scoped

Every account in Production inherits Baseline + Region + Strict. A developer who gains access to a production account cannot disable CloudTrail, cannot create resources in disallowed regions, and cannot delete backup vaults — regardless of what identity policies they hold.

Centralised logging with cross-account resource policies

Many accounts write CloudTrail, VPC Flow Logs, and application logs to a single Security account. This requires both sides of the cross-account resource-based policy pattern to be in place.

In each workload account, the relevant service roles have identity-based policies that allow s3:PutObject on the security account's logging bucket ARN. In the Security account, the S3 bucket policy uses aws:PrincipalOrgID as the condition rather than enumerating every account ARN — new accounts added to the organisation automatically gain the ability to write logs without any policy change. The Security account also carries an RCP ensuring the logging bucket can only be accessed from within the organisation, and an SCP that denies even security administrators from deleting objects in the logging bucket, preventing log tampering.

CI/CD pipeline with layered access control

A production-grade deployment pipeline uses all three principal-facing control types together. The platform team creates a DeploymentRole per environment with an identity-based policy scoped to the environment's resource naming prefix. That role has a permissions boundary that prevents any iam:* actions — so even a compromised pipeline cannot create permissive new roles to escalate from.

When the pipeline runs, it calls AssumeRole on the deployment role and passes a session policy scoped to only the specific service being deployed. A pipeline deploying order-service receives credentials that can only update Lambda functions and push ECR images matching *order-service*. The session policy is generated at runtime from the pipeline context. If the pipeline is compromised, the blast radius is bounded to exactly one service in one environment — not the entire deployment role's scope.

The three-layer data perimeter

A complete data perimeter defends against exfiltration with controls at all three layers simultaneously:

SCP — principal control: "Our principals may only interact with S3 buckets within our organisation." A Deny SCP on s3:PutObject when s3:ResourceOrgID does not match your org ID means compromised credentials cannot write data to an external bucket.

RCP — resource control: "Our S3 buckets may only be accessed by principals within our organisation." An RCP with a Deny on external PrincipalOrgID blocks even a bucket policy that might be accidentally too open from being exploited from outside the org.

VPC endpoint policy — network control: "Traffic through our VPC's S3 endpoint can only reach buckets in our organisation." Any call through the endpoint to an external S3 bucket is denied at the network layer before IAM evaluation even matters.

These three together mean a fully compromised credential, operating from inside your VPC, cannot successfully exfiltrate data to an external S3 bucket. It is blocked by the SCP (can't send to external bucket), the endpoint policy (can't route to external bucket via the endpoint), and the attacker's own external bucket does not have an RCP that allows your credentials anyway.

Attribute-based access control (ABAC) with tag conditions

For large-scale environments where maintaining per-resource ARNs in policies is impractical, ABAC uses resource tags and principal tags as the access control criteria. A developer role tagged Environment=dev is allowed access to all resources tagged Environment=dev through a single identity-based policy using aws:ResourceTag conditions. When a new resource is created and tagged correctly, it is automatically accessible to the right roles without any policy change. Combine ABAC with permissions boundaries to ensure that even a broadly-scoped tag condition cannot grant access to production-tagged resources.

11

Quick reference

🪪
Identity-Based Policy

Attached to users, roles, groups. Grants access. The primary mechanism for defining what a principal can do. Use customer-managed policies in production. Implicit deny for anything not listed — you don't need to deny what you haven't allowed.

🪣
Resource-Based Policy

Attached to resources (S3, KMS, SQS, Lambda…). Grants access. Requires a Principal element. For cross-account access: resource-based policy on the resource side AND identity policy on the principal side are both required.

🔒
Permissions Boundary

Attached to users or roles only. Does not grant. Sets the maximum allowed permissions — effective permissions = identity policy ∩ boundary. Does not restrict resource-based policy grants. The key tool for safe delegation of IAM role creation.

🏢
SCP (AWS Organizations)

Applied to OUs and accounts. Does not grant. Restricts maximum permissions for all principals including root. Does not apply to the management account. Use deny-list mode with FullAWSAccess unless you need strict allowlist control.

🛡️
RCP (AWS Organizations)

Applied to OUs and accounts. Does not grant. Restricts what any principal (including external) can do to your resources. Complements SCPs. Core tool for data perimeter. Supported on S3, STS, KMS, SQS, Secrets Manager (expanding).

⏱️
Session Policy

Passed at AssumeRole time. Does not grant. Restricts permissions for this session only — effective = role policies ∩ session policy. Generated at runtime from context. Ideal for dynamic, tenant-scoped, or environment-scoped temporary credentials.

🌐
VPC Endpoint Policy

Attached to VPC Gateway or Interface endpoints. Default: allow all. Restricts by network path — principals with full IAM permission are still blocked if the endpoint policy denies it. The network layer of the data perimeter.


The most common mistakes to avoid

⚠️
Mistaking "SCP Allow" for a grant

An SCP Allow statement does not grant access. It means the action is not blocked by the SCP. The principal still needs an identity-based or resource-based policy to actually be allowed to perform the action.

⚠️
Workloads in the management account

The management account is exempt from SCPs — all guardrails applied to the rest of the org do not apply here. Running workloads there creates an unguarded attack surface with no SCP protection, ever.

⚠️
The s3:ListBucket ARN mistake

ListBucket applies to the bucket (arn:aws:s3:::bucket). Object actions apply to objects (arn:aws:s3:::bucket/*). Granting only the /* ARN silently breaks ListBucket. Always include both ARNs when you need both.

⚠️
Forgetting aws:PrincipalIsAWSService in RCPs

An RCP that denies external principals without this condition also blocks AWS services (CloudFront, Backup, Replication) that need to access your resources. Always include BoolIfExists: aws:PrincipalIsAWSService: false.

⚠️
Forgetting to deny boundary modification

A permissions boundary only prevents privilege escalation if the developer cannot modify the boundary policy itself. Always include explicit Deny statements on boundary modification actions in the developer's identity policy.

⚠️
Missing NotAction in region SCPs

Global services (IAM, CloudFront, Route 53, STS) always appear as us-east-1. A region-restriction SCP without a NotAction list breaks these services everywhere. Always use the global service exclusion list from AWS documentation.

I hope you found this useful, please share it!

✓ Link copied to clipboard
Mayank Pandey

About the Author

Mayank Pandey

AWS Community Hero and Cloud Architect with 15+ years of experience. AWS Solutions Architect Professional, FinOps Practitioner, and AWS Authorized Instructor. Creator of the KnowledgeIndia YouTube channel (80,000+ subscribers). Based in Melbourne, Australia.