
If you've ever asked "why can this role still do that, I thought I locked it down," there's a good chance the answer is buried in one of three different AWS guardrails that all sound similar but apply at completely different layers. This post is a decision guide, not a glossary: for a given problem, which one actually fixes it.
The three guardrails, and where each one lives
These three mechanisms get confused because they all restrict what an identity can do, but they live in different parts of AWS and are evaluated at different points:
- Identity-based policy — attached directly to a user, group, or role. Defines what that identity can do. This is the baseline everyone starts with.
- Permission boundary — attached to a single IAM user or role. Sets the maximum permissions that identity can ever have, no matter what its identity-based policies grant. Scoped to one account, one identity at a time.
- Service Control Policy (SCP) — attached to an AWS Organizations account, organizational unit (OU), or the whole organization root. Sets the maximum permissions for every identity in that account or OU, including the root user. Requires AWS Organizations — it doesn't exist without it.
- Resource-based policy — attached to the resource itself (an S3 bucket, a KMS key, an SQS queue, a Lambda function). Defines who — potentially from outside your account — can access that specific resource.
The first thing to internalize: permission boundaries and SCPs never grant anything. They only cap what's possible. The actual permission still has to come from an identity-based or resource-based policy that says "allow." All of these are combined by AWS's policy evaluation logic, and an explicit Deny anywhere in the stack wins.
Decision point 1: Is this about a resource being reachable from another account, or the public internet?
If the question is "can an external account, or anyone unauthenticated, reach my S3 bucket / KMS key / SQS queue" — that's a resource-based policy problem. Identity policies, boundaries, and SCPs are all about your own identities; they can't grant access to outside principals. Only the resource's own policy can do that.
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "AllowPartnerAccountRead",
"Effect": "Allow",
"Principal": { "AWS": "arn:aws:iam::111122223333:root" },
"Action": ["s3:GetObject"],
"Resource": "arn:aws:s3:::shared-reports-bucket/*"
}
]
}
Attach that to the bucket with aws s3api put-bucket-policy. Note this grants access to account 111122223333 — but the calling principal in that account still needs an identity-based policy of its own allowing s3:GetObject too. Cross-account access needs both sides to say yes.
Decision point 2: Is this "no one in this account should ever be able to do X, even an admin"?
That's an SCP question. SCPs are the only one of these three that can restrict the AWS account root user and every IAM admin in that account — because they're enforced by AWS Organizations, outside the account entirely. A classic example: blocking any account in a sandbox OU from ever leaving a specific set of regions.
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "DenyOutsideApprovedRegions",
"Effect": "Deny",
"NotAction": [
"iam:*",
"organizations:*",
"route53:*",
"support:*",
"cloudfront:*"
],
"Resource": "*",
"Condition": {
"StringNotEquals": {
"aws:RequestedRegion": ["us-east-1", "us-west-2"]
}
}
}
]
}
Attach it at the OU level:
aws organizations attach-policy \
--policy-id p-exampleid \
--target-id ou-abcd-11112222
SCPs cannot grant permissions — an account with only an SCP and no identity policies can do nothing at all. SCPs only ever narrow what's already allowed by the policies inside the account. If you need every account in an OU to be unable to disable CloudTrail, delete a specific IAM role, or operate outside approved regions regardless of who's logged in, SCP is the only one of the three that reaches that far.
Decision point 3: Is this "this one specific role should never be able to do X, even if someone attaches AdministratorAccess to it later"?
That's a permission boundary. Boundaries are the right tool when you're delegating the ability to create or manage IAM roles to someone — a platform team, a CI pipeline, a self-service tool — and you want a hard ceiling on what any role they create can ever do, even if they make a mistake or get careless with AdministratorAccess.
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "DeveloperBoundary",
"Effect": "Allow",
"Action": [
"s3:*",
"dynamodb:*",
"lambda:*",
"logs:*"
],
"Resource": "*"
},
{
"Sid": "DenyIamAndOrgChanges",
"Effect": "Deny",
"Action": [
"iam:*",
"organizations:*",
"account:*"
],
"Resource": "*"
}
]
}
Create it and apply it to a role being created by a self-service pipeline:
aws iam create-policy \
--policy-name developer-boundary \
--policy-document file://boundary.json
aws iam create-role \
--role-name app-team-dev-role \
--assume-role-policy-document file://trust-policy.json \
--permissions-boundary arn:aws:iam::123456789012:policy/developer-boundary
Now even if someone later attaches AdministratorAccess as the identity policy on app-team-dev-role, the effective permissions are the intersection of that identity policy and the boundary — so the role still can't touch IAM or Organizations, because the boundary never allowed it.
Putting it together: the evaluation order
When a request comes in, AWS evaluates, broadly, in this order, and any explicit Deny at any layer ends the request immediately:
- SCPs (if the account is in an AWS Organization) — must allow, or the request is denied before anything else is checked.
- Resource-based policy (if the target resource has one) and identity-based policy — at least one must explicitly allow the action, for the relevant case (same-account vs cross-account).
- Permission boundary (if the calling identity has one) — must also allow, independently of the identity policy.
- Session policies, if the call was made through an assumed role with an inline session policy.
In practice that means: an SCP denying a region makes a permission boundary irrelevant for a call to that region, because the SCP already stopped it. A permission boundary that doesn't cover a given action makes a generous identity policy irrelevant for that action, because the boundary caps it first.
A quick mapping table, in plain language
- "Nobody in this entire account, even the root user, should do X." → SCP.
- "This one role should never exceed X, no matter what gets attached to it later." → Permission boundary.
- "An account/user outside my AWS account needs to reach this specific bucket/key/queue." → Resource-based policy.
- "This person/role should be able to do X." → Plain identity-based policy — none of the other three apply.
Common pitfalls
- Expecting an SCP to grant access. It never does. Teams sometimes attach a permissive SCP expecting it to unlock an action — it won't, if the account's own identity policies don't already allow it.
- Forgetting the boundary has to allow the action too. Setting a generous identity policy and a narrow boundary, then being surprised the role can't do something the identity policy clearly allows — the boundary caps it, and the effective permission is always the intersection.
- Using a resource policy for an internal, same-account problem. If everyone needing access is already in your account, an identity-based policy is simpler and easier to audit than threading a resource policy's
Principalblock. - Assuming SCPs apply outside AWS Organizations. A single standalone AWS account with no Organization has no SCPs available at all — this is an Organizations-only feature.
Troubleshooting
When an action is denied and you can't tell which layer caused it, aws iam simulate-principal-policy evaluates identity policies and boundaries together (though it does not account for SCPs or resource policies), and CloudTrail's event history shows an errorCode like AccessDenied with a message that often names the specific policy type involved. For SCP-related denials specifically, the error message commonly includes the phrase "explicit deny in a service control policy," which immediately tells you to look at the Organizations console rather than the account's own IAM policies.
Where to go next
Once you're comfortable with these three, the next layer worth learning is session policies — inline policies passed at the moment a role is assumed via sts:AssumeRole, which narrow permissions even further for that one session only, without touching the role's permanent configuration at all.

Experienced DevOps Engineer proficient in Python, Docker, Kubernetes, CI/CD, and cloud technologies. Specialized in automating processes and building scalable infrastructure for seamless development and deployment.


