A Service Control Policy applied at the Organization root is the only AWS guardrail that an account owner cannot disable. IAM admins in the member account can grant themselves anything they want, and the SCP still wins. That makes the baseline SCP one of the highest-leverage things you ship — and one of the easiest to get wrong by locking out the security tooling you rely on. This article is the baseline we attach when we onboard a new payer account, plus the test loop that catches the lockout cases before they ship.
How to verify
Start by asking the API what is actually attached at each level — Organizations UI clicks hide more than they show:
aws organizations list-policies --filter SERVICE_CONTROL_POLICY
aws organizations list-roots --query 'Roots[].Id' --output text
aws organizations list-policies-for-target --target-id r-xxxx --filter SERVICE_CONTROL_POLICY
aws organizations describe-effective-policy --policy-type SERVICE_CONTROL_POLICY --target-id <account-id>
describe-effective-policy is the only command that resolves the inheritance chain (root → OU → account) into the single policy that actually applies. If you only ever look at attached policies, you miss the fact that the OU your sandbox account lives in is missing a deny rule that the root already declared.
What’s happening
SCPs are not IAM policies. They never grant permissions — they only set the maximum permissions any principal in a target account can be granted by IAM. The implicit default at the root is FullAWSAccess. Removing that default means no principal in any member account can do anything, which is rarely what you want. The pattern is: keep FullAWSAccess attached at the root, then attach explicit Deny policies for the things you never want anyone to do.
There are five denies that belong in almost every baseline:
- Deny unapproved regions. A workload that runs in us-east-1 and eu-west-1 should not be able to spin up an EC2 instance in ap-southeast-3 because someone clicked the wrong region in the console. Region blast-radius limits also reduce the surface for exfil-to-an-unmonitored-region attacks.
- Deny root user actions. The root user exists; it should never be used. An SCP that denies every action when
aws:PrincipalArnmatches the root pattern prevents accidental root activity even if MFA is somehow bypassed. - Deny IMDSv1 launches. Any
ec2:RunInstancescall that does not setMetadataOptions.HttpTokens=requiredshould fail. New AMIs default to IMDSv2-required since 2024, but Terraform modules from older projects still launch IMDSv1-allowed instances. - Deny disabling CloudTrail / GuardDuty / Config. Member-account admins should not be able to stop the audit trail. The deny list covers
cloudtrail:StopLogging,cloudtrail:DeleteTrail,guardduty:DeleteDetector,config:StopConfigurationRecorder, and friends. - Deny IAM user creation in workload accounts. Identity Center (formerly SSO) is the path; long-lived IAM users with access keys are the regression. The deny covers
iam:CreateUserandiam:CreateAccessKeyoutside a small set of break-glass accounts.
The trap: an SCP that denies a broad action with a narrow exception can lock out the very role that operates the exception. The classic example is a CloudTrail-protection SCP that denies cloudtrail:* except for the OrganizationAccountAccessRole — and then someone uses a different role to run the day-to-day work, hits the deny, and assumes the policy is broken. The fix is to make the exception condition broader (a tag, a path prefix) or to attach the policy to an OU that excludes the operations accounts.
The procedure
-
Create a dedicated
SandboxOU before you start. Move one test account into it. Your iteration loop is “attach to Sandbox OU, test in the test account, then promote to root.” Never test SCPs by attaching them straight to the root.aws organizations create-organizational-unit \ --parent-id r-xxxx --name Sandbox aws organizations move-account \ --account-id 111122223333 \ --source-parent-id r-xxxx \ --destination-parent-id ou-xxxx-yyyy -
Write the baseline SCP. Save as
baseline-deny.json:{ "Version": "2012-10-17", "Statement": [ { "Sid": "DenyUnapprovedRegions", "Effect": "Deny", "NotAction": ["iam:*","organizations:*","route53:*","cloudfront:*","support:*","budgets:*","ce:*","sts:GetCallerIdentity","sts:AssumeRoleWithSAML","sts:AssumeRoleWithWebIdentity"], "Resource": "*", "Condition": { "StringNotEquals": { "aws:RequestedRegion": ["us-east-1","us-east-2","ca-central-1"] } } }, { "Sid": "DenyRootUser", "Effect": "Deny", "Action": "*", "Resource": "*", "Condition": { "StringLike": {"aws:PrincipalArn": "arn:aws:iam::*:root"} } }, { "Sid": "DenyIMDSv1Launches", "Effect": "Deny", "Action": "ec2:RunInstances", "Resource": "arn:aws:ec2:*:*:instance/*", "Condition": { "StringNotEquals": {"ec2:MetadataHttpTokens": "required"} } }, { "Sid": "ProtectAuditTrails", "Effect": "Deny", "Action": [ "cloudtrail:StopLogging", "cloudtrail:DeleteTrail", "cloudtrail:UpdateTrail", "guardduty:DeleteDetector", "guardduty:DisassociateFromMasterAccount", "config:DeleteConfigurationRecorder", "config:StopConfigurationRecorder" ], "Resource": "*" } ] }The
NotActioncarve-out in the region deny is critical — IAM, Organizations, Route 53, CloudFront, Support, Budgets, and Cost Explorer are all global services routed through us-east-1. If you deny them you lock yourself out of console login. -
Attach to Sandbox OU first.
aws organizations create-policy \ --type SERVICE_CONTROL_POLICY \ --name baseline-deny \ --description "Region/root/IMDSv1/audit baseline" \ --content file://baseline-deny.json aws organizations attach-policy \ --policy-id p-xxxxxxxx \ --target-id ou-xxxx-yyyy -
Test from the sandbox account. In a credentialed shell for the sandbox account, run each denied action and confirm it fails:
aws ec2 run-instances --region ap-southeast-3 \ --image-id ami-xxxx --instance-type t3.micro # Expected: AccessDenied — Region denied by SCP aws ec2 run-instances --region us-east-1 \ --image-id ami-xxxx --instance-type t3.micro \ --metadata-options HttpTokens=optional # Expected: AccessDenied — IMDSv1 denied by SCP aws cloudtrail stop-logging --name org-trail # Expected: AccessDenied -
Promote to root once sandbox is clean. Detach from the Sandbox OU, attach to the root, and detach
FullAWSAccessfrom the Sandbox OU only if you have a stricter sandbox-specific policy in mind. The root-level baseline applies to every account in the org from this point on.
Common pitfalls
- The region deny list must include every region your workloads actually use, including DR regions you rarely touch. Forgetting your DR region means a real failover gets blocked.
aws:RequestedRegionis not set for global service calls — IAM, Organizations, Route 53. TheNotActioncarve-out above handles this; do not invert the logic and writeStringEquals: us-east-1or you will block global services from anywhere else.- An SCP applied at the root affects the management (payer) account too. If you deny IAM user creation org-wide, you cannot create an emergency break-glass user in the payer. The standard pattern is to attach the deny at an OU level that excludes the payer.
describe-effective-policyresults are cached for up to 24 hours. After an attach/detach, the API may return the old effective policy for some time — the actual enforcement updates within minutes, but the introspection lags.- An OU with
FullAWSAccessdetached and no other allow attached effectively blocks all access. SCPs require an allow somewhere up the chain.
Stack Harbor operates AWS Organizations for clients whose payer account, OU layout, and SCP baseline are managed as code, with the test loop above wired into a pull-request check that runs against a dedicated sandbox OU before any change hits the root. If your guardrails today are “we trust people not to launch in the wrong region,” that audit is the first deliverable of AWS account management.