Skip to content
Cloud & Infra Advanced Tutorial

Enforce Guardrails Across AWS Accounts with Organizations and SCPs

Stand up an AWS Organization whose SCPs block region sprawl and public S3 in every member account.

Emeka Okafor
Emeka Okafor
Security Editor · Aug 3, 2026 · 7 min read
Enforce Guardrails Across AWS Accounts with Organizations and SCPs

What you'll build

A working AWS Organization with a Workloads OU, one member account inside it, and two service control policies (SCPs) attached: one that denies every AWS Region except the two you approve, and one that makes S3 Block Public Access impossible to turn off. Every account you drop into that OU inherits both guardrails automatically.

graph TD
    R[Org root] --> M[Management account<br/>SCPs do NOT apply here]
    R --> OU[Workloads OU<br/>SCP: DenyNonApprovedRegions<br/>SCP: ProtectS3PublicAccessBlock]
    OU --> A[Production account]
    OU --> B[Future accounts inherit both]

Prerequisites

  • An AWS account with administrator credentials. It becomes the management account, and SCPs never apply to it — use a dedicated account, not one running workloads.
  • AWS CLI v2 (verified against 2.36.x — check with aws --version). All commands below were verified against the AWS Organizations API as of August 2026.
  • A unique email address for the new member account. Plus-addressing works: you+prod@example.com counts as unused.
  • IAM permissions in the management account for organizations:*, sts:AssumeRole, and iam:CreateServiceLinkedRole.
  • Shell examples are bash/zsh (macOS/Linux). On Windows, use WSL or adjust the variable syntax.

1. Create the organization

Run this from the management account:

aws organizations create-organization --feature-set ALL

ALL (the default) is required — the consolidated-billing-only feature set doesn't support SCPs. Grab the root ID and confirm the SCP policy type is enabled on it:

ROOT_ID=$(aws organizations list-roots --query 'Roots[0].Id' --output text)
aws organizations list-roots --query 'Roots[0].PolicyTypes'

If SERVICE_CONTROL_POLICY isn't listed with "Status": "ENABLED", enable it (one-time, per root):

aws organizations enable-policy-type \
  --root-id "$ROOT_ID" \
  --policy-type SERVICE_CONTROL_POLICY

2. Create an OU and a member account

Create the OU that will carry the guardrails:

OU_ID=$(aws organizations create-organizational-unit \
  --parent-id "$ROOT_ID" --name Workloads \
  --query 'OrganizationalUnit.Id' --output text)

Create a member account. Account creation is asynchronous, so you get back a request ID to poll:

CAR_ID=$(aws organizations create-account \
  --email you+prod@example.com --account-name "Production" \
  --query 'CreateAccountStatus.Id' --output text)

aws organizations describe-create-account-status \
  --create-account-request-id "$CAR_ID" \
  --query 'CreateAccountStatus.[State,AccountId,FailureReason]'

Poll until State is SUCCEEDED (usually under a minute), then move the new account from the root into the OU:

ACCOUNT_ID=<AccountId from the previous command>
aws organizations move-account --account-id "$ACCOUNT_ID" \
  --source-parent-id "$ROOT_ID" --destination-parent-id "$OU_ID"

Organizations preconfigures the member account with a role named OrganizationAccountAccessRole that trusts the management account — you'll use it to test later.

3. Write the region guardrail

SCPs don't grant anything; they cap what identity policies can grant. Every root and OU keeps the AWS-managed FullAWSAccess policy attached (deny-list strategy), and your SCP carves denies out of it. This policy — adapted from AWS's own example — denies every action outside us-east-1 and eu-west-1, while exempting global services (IAM, Route 53, CloudFront, STS, and friends) whose control planes live in us-east-1 and would otherwise break. Save as deny-regions.json:

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "DenyNonApprovedRegions",
            "Effect": "Deny",
            "NotAction": [
                "a4b:*", "acm:*", "aws-marketplace-management:*",
                "aws-marketplace:*", "aws-portal:*", "budgets:*", "ce:*",
                "chime:*", "cloudfront:*", "config:*", "cur:*",
                "directconnect:*", "ec2:DescribeRegions",
                "ec2:DescribeTransitGateways", "ec2:DescribeVpnGateways",
                "fms:*", "globalaccelerator:*", "health:*", "iam:*",
                "importexport:*", "kms:*", "mobileanalytics:*",
                "networkmanager:*", "organizations:*", "pricing:*",
                "route53:*", "route53domains:*", "route53-recovery-cluster:*",
                "route53-recovery-control-config:*",
                "route53-recovery-readiness:*", "s3:GetAccountPublic*",
                "s3:ListAllMyBuckets", "s3:ListMultiRegionAccessPoints",
                "s3:PutAccountPublic*", "shield:*", "sts:*", "support:*",
                "trustedadvisor:*", "waf-regional:*", "waf:*", "wafv2:*",
                "wellarchitected:*"
            ],
            "Resource": "*",
            "Condition": {
                "StringNotEquals": {
                    "aws:RequestedRegion": ["us-east-1", "eu-west-1"]
                }
            }
        }
    ]
}

Swap in your own approved Regions. AWS's original adds an ArnNotLike condition on aws:PrincipalARN to exempt a break-glass role — add that back once you have one.

4. Write the S3 public-access guardrail

An SCP can't flip S3 Block Public Access on — SCPs only restrict. The pattern is: turn on account-level Block Public Access in each member account once, then use an SCP to deny anyone the ability to loosen it. Set it in the member account via the preconfigured role:

read -r AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY AWS_SESSION_TOKEN <<< "$(aws sts assume-role \
  --role-arn "arn:aws:iam::${ACCOUNT_ID}:role/OrganizationAccountAccessRole" \
  --role-session-name scp-test \
  --query 'Credentials.[AccessKeyId,SecretAccessKey,SessionToken]' --output text)"
export AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY AWS_SESSION_TOKEN

aws s3control put-public-access-block --account-id "$ACCOUNT_ID" \
  --public-access-block-configuration \
  BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true

unset AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY AWS_SESSION_TOKEN

Now the lock. Save as protect-s3-bpa.json (denying the Put actions also blocks the delete operations, which authorize against the same actions):

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "ProtectS3PublicAccessBlock",
            "Effect": "Deny",
            "Action": [
                "s3:PutAccountPublicAccessBlock",
                "s3:PutBucketPublicAccessBlock"
            ],
            "Resource": "*"
        }
    ]
}

5. Create and attach both SCPs

Back in the management account:

REGION_POLICY_ID=$(aws organizations create-policy \
  --name DenyNonApprovedRegions \
  --description "Deny all Regions except us-east-1 and eu-west-1" \
  --type SERVICE_CONTROL_POLICY --content file://deny-regions.json \
  --query 'Policy.PolicySummary.Id' --output text)

S3_POLICY_ID=$(aws organizations create-policy \
  --name ProtectS3PublicAccessBlock \
  --description "Prevent disabling S3 Block Public Access" \
  --type SERVICE_CONTROL_POLICY --content file://protect-s3-bpa.json \
  --query 'Policy.PolicySummary.Id' --output text)

aws organizations attach-policy --policy-id "$REGION_POLICY_ID" --target-id "$OU_ID"
aws organizations attach-policy --policy-id "$S3_POLICY_ID" --target-id "$OU_ID"

Attaching to the OU scopes the blast radius while you test; once you trust the policies, attach them to $ROOT_ID instead to cover every current and future account. Whatever you do, don't detach FullAWSAccess (policy ID p-FullAWSAccess) from the root or OU — SCPs use an intersection model, so removing it denies effectively everything in every account underneath.

Verify it works

Confirm the attachments:

aws organizations list-policies-for-target \
  --target-id "$OU_ID" --filter SERVICE_CONTROL_POLICY \
  --query 'Policies[].Name'

Expected:

[
    "FullAWSAccess",
    "DenyNonApprovedRegions",
    "ProtectS3PublicAccessBlock"
]

Assume the role in the member account again (same assume-role/read/export block as step 4), then hit a blocked Region with a harmless read:

aws ec2 describe-instances --region sa-east-1

Expected — note the SCP called out explicitly:

An error occurred (UnauthorizedOperation) when calling the DescribeInstances
operation: You are not authorized to perform this operation. User:
arn:aws:sts::222222222222:assumed-role/OrganizationAccountAccessRole/scp-test
is not authorized to perform: ec2:DescribeInstances with an explicit deny in a
service control policy. Encoded authorization failure message: ...

The same command with --region us-east-1 returns normally ("Reservations": []). Now try to loosen Block Public Access — even as an admin role, it must fail:

aws s3control put-public-access-block --account-id "$ACCOUNT_ID" \
  --public-access-block-configuration \
  BlockPublicAcls=false,IgnorePublicAcls=false,BlockPublicPolicy=false,RestrictPublicBuckets=false

Expected:

An error occurred (AccessDenied) when calling the PutPublicAccessBlock operation:
User: arn:aws:sts::222222222222:assumed-role/OrganizationAccountAccessRole/scp-test
is not authorized to perform: s3:PutAccountPublicAccessBlock with an explicit deny
in a service control policy

Both guardrails hold against a full-admin role in the member account. That's the point of SCPs: they cap even administrators.

Troubleshooting

PolicyTypeNotEnabledException when calling AttachPolicy or CreatePolicy — the SCP policy type isn't enabled on this root ("This operation can be performed only for enabled policy types"). Run the enable-policy-type command from step 1. Console-created orgs enable it automatically; CLI-created orgs sometimes need it explicitly.

FinalizingOrganizationException: AWS Organizations couldn't perform the operation because your organization hasn't finished initializing. This can take up to an hour. Try again later. — hit on create-account right after create-organization. Not your fault; wait (typically minutes, up to an hour) and retry. Contact AWS Support if it persists past an hour.

describe-create-account-status shows "State": "FAILED" with "FailureReason": "EMAIL_ALREADY_EXISTS" — the email is already tied to another AWS account. Every account needs a globally unique address; use plus-addressing (you+prod2@example.com) and rerun create-account.

Your SCP "doesn't work" when you test it — you're testing from the management account. SCPs never apply to the management account, no matter where they're attached. Always test from a member account via OrganizationAccountAccessRole, and confirm the account actually sits under the OU with aws organizations list-parents --child-id "$ACCOUNT_ID".

Next steps

Promote the policies from the OU to the root once they've soaked, and add a break-glass role exemption via an ArnNotLike condition on aws:PrincipalARN. The aws-samples SCP repository has vetted policies for the next tier of guardrails: protecting CloudTrail and GuardDuty from tampering, requiring encrypted S3 uploads, and denying root-user actions. Pair SCPs with resource control policies (RCPs), which enforce the resource side of the perimeter — like requiring that S3 access comes only from your org. Watch the quotas: SCPs max out at 5,120 characters and five attached policies per target. And when the account count grows past a handful, look at AWS Control Tower, which manages OUs, guardrails, and account vending on top of the exact primitives you just wired by hand.

Sources & further reading

  1. Creating an organization with AWS Organizations — docs.aws.amazon.com
  2. Enabling a policy type - AWS Organizations — docs.aws.amazon.com
  3. CreateAccount - AWS Organizations API Reference — docs.aws.amazon.com
  4. Service control policy examples — docs.aws.amazon.com
  5. AWS Service Control Policy Examples — github.com
Emeka Okafor
Written by
Emeka Okafor · Security Editor

Emeka has spent over a decade tracking threat actors, vulnerability disclosures, and the evolving landscape of application security, bringing a sharp continent-spanning perspective to his reporting. He's known for translating dense CVE advisories into clear, actionable context that developers and security teams alike actually read.

Discussion 0

Join the discussion

Sign in or create an account to comment and vote.

No comments yet

Be the first to weigh in.

Related Reading