Here is a question worth sitting with: your AWS Organization has SCPs attached. Your security runbook says "SCPs enforced at OU level." Your last compliance audit checked a box labeled "preventive controls implemented." But when did someone actually call an API that those SCPs are supposed to block, from a member account, and verify the request was denied?
Most teams cannot answer that. The SCPs exist. The policy document is real. The enforcement is not.
The Three Structural Gaps That Undermine SCP Enforcement
AWS Service Control Policies operate as a permission boundary on IAM principals in member accounts. The maximum effective permissions for any principal are the intersection of what the SCP allows and what the principal's IAM policies grant. In theory, this is a hard guardrail. In practice, we consistently see the same three failure modes.
Gap 1: The policy is attached to the wrong unit
AWS Organizations has a hierarchical structure: root → management account / organizational units → member accounts. SCPs attached to a parent OU apply to all child OUs and accounts within it. SCPs attached directly to the root apply organization-wide. But the management account itself is exempt from SCPs — this is documented AWS behavior that is consistently misunderstood.
The practical consequence: if your critical workloads ever run in the management account (it happens more than it should), every SCP you've written is irrelevant to them. The management account has full permissions regardless of what you've attached to the root.
Check where your accounts sit. Run:
aws organizations list-accounts-for-parent \
--parent-id <your-root-id> \
--query 'Accounts[*].{Id:Id,Name:Name}'
If you get results back, those accounts sit directly at the root. They're receiving your root-level SCPs — but also double-check nothing production-adjacent is in the management account itself.
Gap 2: The SCP has unintentional wildcards or implicit allow-all
The default SCP at the root of any new Organization is FullAWSAccess: a single statement that allows * on *. This does not mean all actions are allowed — it means the SCP is not restricting anything. The actual allow/deny still comes from IAM. But it means the SCP provides zero preventive control.
Teams create custom SCPs and attach them to child OUs without removing or replacing FullAWSAccess at the root. The child OU's custom SCP restricts certain actions. But a principal in a member account that's also in the parent scope of FullAWSAccess gets the union of attached SCPs — and SCPs use an implicit deny model, so having any SCP that allows an action means the deny from a more specific SCP lower in the tree must be explicit.
This is the most common gap we see. The SCP author intended to deny s3:DeleteBucket for all accounts in the Workloads OU. The SCP does have a deny statement. But FullAWSAccess at the root doesn't make that deny disappear — SCPs use explicit deny, so the deny wins. Wait, doesn't that mean it works? Not when your deny SCP also has a poorly scoped condition:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "DenyS3Delete",
"Effect": "Deny",
"Action": "s3:DeleteBucket",
"Resource": "*",
"Condition": {
"StringNotEquals": {
"aws:RequestedRegion": "us-east-1"
}
}
}
]
}
Read that condition carefully. This denies s3:DeleteBucket only when the request is not in us-east-1. Which means s3:DeleteBucket in us-east-1 is unconstrained. The intent was to restrict to us-east-1, but the condition logic is inverted. This exact pattern — condition logic that reads as restriction but actually permits the dangerous action — appears regularly in SCPs inherited from blog posts and open-source policy libraries.
Gap 3: The SCP is never tested against real API calls
AWS IAM has a policy simulator. It is useful. But it operates on simulated API calls, not real ones. There is a difference: simulated calls do not exercise service-specific condition keys that only resolve during actual request processing. For complex SCPs that use conditions on aws:PrincipalTag, aws:RequestedRegion, or resource-level conditions, the simulator gives you a probabilistic result, not a definitive one.
The only reliable test is: make the call, see if it's denied. This means having a test account in your org, SCPs applied to it, and a harness that calls the blocked actions from that account's IAM principals. If you don't have this, you have attestation, not verification.
What a Minimal Verification Harness Looks Like
Testing SCP denial doesn't require much infrastructure. What it requires is intent. The simplest approach:
import boto3
import json
def test_scp_denial(role_arn, action, resource, expected_denied=True):
"""
Assumes a role in a member account and attempts an action.
Returns True if the action was denied (matching expected_denied).
"""
sts = boto3.client('sts')
creds = sts.assume_role(
RoleArn=role_arn,
RoleSessionName='scp-test-session'
)['Credentials']
session = boto3.Session(
aws_access_key_id=creds['AccessKeyId'],
aws_secret_access_key=creds['SecretAccessKey'],
aws_session_token=creds['SessionToken']
)
# Derive the service client from the action namespace
service = action.split(':')[0]
client = session.client(service)
try:
# This will raise ClientError if denied
# Action-specific call needed per service
raise NotImplementedError(f"Add call for {action}")
except client.exceptions.ClientError as e:
error_code = e.response['Error']['Code']
is_denied = error_code in ('AccessDenied', 'UnauthorizedAccess')
return is_denied == expected_denied
The limitation of this approach is the NotImplementedError: you need an actual API call for each action you're testing. There's no generic "attempt this IAM action" API. That's intentional work — each SCP deny statement requires its own test case.
We're not suggesting every team needs automated SCP testing in CI (though we do think it's worth building). The point is that testing SCPs manually once per quarter, by actually making the API calls that should be blocked, takes an hour and will almost certainly surface at least one gap.
The Inheritance Model Is Not Additive — It's Intersectional
One conceptual error worth addressing directly: SCPs in AWS Organizations are not additive. A member account does not accumulate permissions from all SCPs across its parent chain. It gets the intersection: the effective maximum permissions are bounded by the SCP at each level of the hierarchy, combined with an implicit deny for anything not explicitly allowed.
This means:
- If the root SCP allows
*(FullAWSAccess), and a child OU SCP allows onlyec2:*, principals in accounts under that child OU can only call EC2 APIs — regardless of what their IAM policies grant. - An explicit deny at any level of the hierarchy cannot be overridden by an allow at a lower level. Explicit deny always wins.
- If you attach zero SCPs to an account directly but it's in an OU with a restrictive SCP, the restrictive SCP applies. Accounts don't need SCPs directly attached to be affected.
This model is correct and well-documented. The problem is that org structures evolve over time — accounts get moved, OUs get restructured, new SCPs get attached to parent nodes — and nobody re-validates that the intersection of all SCPs still produces the intended effective policy.
Three Things to Check This Week
If your org hasn't tested SCP enforcement recently, here's a prioritized starting point. None of these require new tooling.
First: Pull the effective SCP for three representative accounts — one in each of your main OUs. Use the Organizations console or aws organizations list-policies-for-target --filter SERVICE_CONTROL_POLICY for each target. Read the actual JSON. Check for inverted conditions. Check for wildcard resources that might be broader than intended.
Second: Identify your highest-value deny statements — the actions that if performed would constitute a security incident (disabling CloudTrail, deleting S3 encryption config, modifying VPC flow log settings). For each, pick a test account, assume a role with broad IAM permissions in it, and make the API call. It should fail.
Third: Map every account in your org to its effective SCP inheritance chain. Look for accounts that are direct children of root (excluding the management account — those need a different approach). Accounts directly under root only get root-level SCPs, which is frequently just FullAWSAccess.
The Management Account Problem Has No SCP Solution
This deserves its own paragraph. The management account is exempt from SCPs by design. AWS made this choice deliberately — if an SCP misconfiguration could lock out the management account, recovery would require AWS support intervention. So AWS simply doesn't apply SCPs to it.
The security implication is that any IAM principal in the management account has the ability to perform any action permitted by their IAM policies, with no SCP guardrail. If your management account has IAM users (it shouldn't), roles with broad trust policies, or cross-account access that's broader than it should be — those are not constrained by SCPs.
The correct response to this is not to work around it with SCPs (you can't). It's to minimize what runs in the management account. Ideally: nothing except the AWS Organizations management functions and the centralized logging/audit trail. Any workload that ended up there should be migrated to a member account where SCPs apply.
SCPs are a genuinely useful preventive control when they're correctly scoped, attached to the right units, written with correct condition logic, and tested against real API calls. The gap between "we have SCPs" and "our SCPs enforce what we intend" is where incidents live. Closing that gap doesn't require new tools — it requires treating SCPs as code that needs to be tested, not documentation that needs to be filed.