Most AWS footprints start the same way: one account, one AdministratorAccess user, a handful of resources. Two years later that account holds production, staging, three data pipelines, a forgotten EKS cluster in ap-southeast-2, and an IAM tab with 180 roles nobody dares to delete. Blast radius is total, cost attribution is a spreadsheet exercise, and the only "guardrail" is a Slack message asking people not to touch things.
This article walks the path out: splitting that monolith into an AWS Organizations structure governed by Control Tower, designing an OU layout that survives contact with reality, and — the hard part — writing Service Control Policies (SCPs) that actually block what you want without locking out your own automation on a Friday evening.
Context: why a single account stops scaling
The single-account model fails on four axes simultaneously:
- Isolation. IAM is the only boundary. One over-permissive role, one compromised CI token, and the attacker reaches production. There is no
DenyAllExceptMyTeamthat survives aniam:PassRolemistake. - Quotas. Service limits (VPCs per region, Lambda concurrency, EIPs) are per account. A batch job in staging starves production.
- Cost and ownership. Tag-based allocation works until someone forgets a tag. Account-based allocation always works.
- Compliance. Auditors want to see segregation of duties. "We have IAM policies" is a much weaker answer than "production lives in an account where only three roles exist."
Multi-account solves all four, but it introduces a new problem: governance at scale. Thirty accounts with no central controls is worse than one account. That's what Control Tower and SCPs are for.
Designing the OU structure first
Do not create accounts before you have an OU design. Moving accounts between OUs later is possible but every SCP change becomes a migration event.
A structure that works for most mid-sized organizations:
Root ├── Security (Log Archive, Audit) <- Control Tower managed ├── Infrastructure (Network, Shared Services) ├── Workloads │ ├── Prod (prod-payments, prod-web, ...) │ ├── NonProd (staging, qa) │ └── Sandbox (dev playgrounds, 30-day TTL) ├── Suspended (accounts being decommissioned) └── PolicyStaging (one throwaway account to test SCPs) Three rules that pay off later:
- OUs map to policy boundaries, not to org charts. Teams reorganize every 18 months; the difference between "prod" and "sandbox" controls does not.
- Keep nesting shallow. AWS allows five levels below the root; two or three is enough. SCP inheritance is intersective — every level you add makes debugging an
AccessDeniedharder. - Always keep a
SandboxOU with loose SCPs and aPolicyStagingOU with strict ones. You need somewhere to break things on purpose.
Bootstrap the organization and the OUs:
# From the future management account, with ALL features (required for SCPs) aws organizations create-organization --feature-set ALL ROOT_ID=$(aws organizations list-roots --query 'Roots[0].Id' --output text) echo "Root: ${ROOT_ID}" for OU in Security Infrastructure Workloads Suspended PolicyStaging; do aws organizations create-organizational-unit \ --parent-id "${ROOT_ID}" \ --name "${OU}" \ --query 'OrganizationalUnit.[Id,Name]' --output text done # Nested OUs under Workloads WL_ID=$(aws organizations list-organizational-units-for-parent \ --parent-id "${ROOT_ID}" \ --query "OrganizationalUnits[?Name=='Workloads'].Id" --output text) for SUB in Prod NonProd Sandbox; do aws organizations create-organizational-unit --parent-id "${WL_ID}" --name "${SUB}" done Enable SCPs explicitly — they are off by default even with FeatureSet=ALL on older organizations:
aws organizations enable-policy-type \ --root-id "${ROOT_ID}" \ --policy-type SERVICE_CONTROL_POLICY Control Tower: what it gives you, what it doesn't
Control Tower is an opinionated wrapper around Organizations, CloudTrail, Config, IAM Identity Center and SSM. It creates the Log Archive and Audit accounts, deploys an organization-level CloudTrail trail, and manages a catalog of controls (formerly guardrails) that are implemented as SCPs or Config rules.
# Inspect the landing zone aws controltower list-landing-zones aws controltower get-landing-zone --landing-zone-identifier <arn> # Enable a preventive control on an OU aws controltower enable-control \ --control-identifier "arn:aws:controltower:eu-west-1::control/AWS-GR_RESTRICT_ROOT_USER" \ --target-identifier "arn:aws:organizations::111122223333:ou/o-abc123/ou-aaaa-11111111" aws controltower list-enabled-controls \ --target-identifier "arn:aws:organizations::111122223333:ou/o-abc123/ou-aaaa-11111111" What Control Tower does not do: your business-specific restrictions. Region locking beyond the governed regions, denying specific instance families, forcing encryption on new buckets, protecting your Terraform state role — all of that is your own SCPs. Control Tower also reserves names: never hand-edit a policy starting with aws-guardrails- or attach your own policies to the Security OU without checking drift.
One structural gotcha: SCPs never apply to the management account. Treat it as a bare account that hosts nothing but Organizations, billing and the landing zone. No workloads, no CI role, no exceptions.
Writing SCPs that don't block the wrong thing
The evaluation model
An SCP is a filter, not a grant. The effective permissions of a principal are the intersection of what IAM allows and what every SCP on the path from the root to the account allows. If the root has FullAWSAccess attached (the default) and an OU attaches a deny policy, the deny wins — deny always wins, everywhere in the chain.
Practical consequences:
- Write deny-lists, not allow-lists. Allow-lists (
"Effect": "Allow"with a restricted action set) break every time AWS renames an API or you adopt a new service. KeepFullAWSAccessattached and layerDenystatements. - Budget your quota. Five SCPs maximum per root/OU/account, 5120 characters each including whitespace. Minify your JSON in CI; group related statements into themed policies (
scp-region-lock,scp-identity-protection,scp-data-protection). - Always carve out an exemption principal. Every deny statement should let a break-glass role and the Control Tower execution role through, or your next incident becomes a support ticket.
Region lock, done correctly
{ "Version": "2012-10-17", "Statement": [ { "Sid": "DenyOutsideApprovedRegions", "Effect": "Deny", "NotAction": [ "iam:*", "organizations:*", "sts:*", "route53:*", "cloudfront:*", "support:*", "budgets:*", "ce:*", "health:*", "artifact:*", "waf:*", "shield:*", "globalaccelerator:*", "account:*" ], "Resource": "*", "Condition": { "StringNotEquals": { "aws:RequestedRegion": ["eu-west-1", "eu-west-3"] }, "ArnNotLike": { "aws:PrincipalArn": [ "arn:aws:iam::*:role/AWSControlTowerExecution", "arn:aws:iam::*:role/OrgBreakGlass" ] } } } ] } The NotAction block is not optional. Global services are served from us-east-1; without the exclusions, a region lock on eu-west-1 makes iam:CreateRole fail with a cryptic explicit-deny message.
Protecting the baseline
The second policy every organization needs stops accounts from dismantling their own guardrails:
{ "Version": "2012-10-17", "Statement": [ { "Sid": "ProtectBaselineRoles", "Effect": "Deny", "Action": [ "iam:AttachRolePolicy", "iam:DeleteRole", "iam:DeleteRolePermissionsBoundary", "iam:DetachRolePolicy", "iam:PutRolePolicy", "iam:UpdateAssumeRolePolicy" ], "Resource": [ "arn:aws:iam::*:role/AWSControlTowerExecution", "arn:aws:iam::*:role/aws-controltower-*", "arn:aws:iam::*:role/OrgSecurityAudit" ], "Condition": { "ArnNotLike": { "aws:PrincipalArn": "arn:aws:iam::*:role/OrgBreakGlass" } } }, { "Sid": "ProtectTrailAndConfig", "Effect": "Deny", "Action": [ "cloudtrail:StopLogging", "cloudtrail:DeleteTrail", "config:DeleteConfigurationRecorder", "config:StopConfigurationRecorder", "guardduty:DeleteDetector", "guardduty:DisassociateFromMasterAccount" ], "Resource": "*" } ] } Managing SCPs as code with Terraform
Clicking SCPs in the console means no review, no diff, no rollback. Keep the JSON in Git, render it with Terraform, and attach by OU name resolved from a data source.
data "aws_organizations_organization" "this" {} data "aws_organizations_organizational_units" "workloads" { parent_id = data.aws_organizations_organization.this.roots[0].id } resource "aws_organizations_policy" "region_lock" { name = "scp-region-lock" description = "Restrict API calls to eu-west-1 and eu-west-3" type = "SERVICE_CONTROL_POLICY" # jsonencode(jsondecode(...)) strips whitespace -> stays under 5120 chars content = jsonencode(jsondecode(templatefile("${path.module}/policies/region-lock.json", { allowed_regions = jsonencode(var.allowed_regions) }))) } resource "aws_organizations_policy_attachment" "region_lock_prod" { policy_id = aws_organizations_policy.region_lock.id target_id = var.prod_ou_id } Run it from the management account with a dedicated role and a remote state bucket that lives in the Security OU, not in the management account.
Validating SCPs before they reach production
This is the step teams skip, and it is the one that prevents outages.
1. Static validation with IAM Access Analyzer
Access Analyzer understands the SCP grammar, including the fact that Resource must be * for most statements and that NotAction plus Allow is a footgun:
aws accessanalyzer validate-policy \ --policy-type SERVICE_CONTROL_POLICY \ --policy-document file://policies/region-lock.json \ --query 'findings[].{type:findingType,issue:issueCode,detail:findingDetails}' \ --output table Wire this into CI and fail the build on any ERROR or SECURITY_WARNING finding.
2. Inspect what is actually deployed
describe-effective-policy does not support SCPs (only tag, backup and AI opt-out policies), so reconstruct the effective set by walking the hierarchy:
POLICY_ID=p-0123456789 aws organizations describe-policy --policy-id "${POLICY_ID}" \ --query 'Policy.Content' --output text | jq . # Where is it attached? aws organizations list-targets-for-policy --policy-id "${POLICY_ID}" \ --query 'Targets[].{Name:Name,Type:Type,Id:TargetId}' --output table # All SCPs applying to one account, root to leaf ACCOUNT_ID=444455556666 aws organizations list-policies-for-target \ --target-id "${ACCOUNT_ID}" --filter SERVICE_CONTROL_POLICY \ --query 'Policies[].Name' --output text 3. Blast-test in the staging OU
Attach the new policy to PolicyStaging first, then replay your real workloads: a Terraform apply, a CI deployment, a cluster autoscaler scale-up. Then query CloudTrail Lake for explicit denials:
SELECT eventTime, userIdentity.arn, eventSource, eventName, errorMessage FROM <event-data-store-id> WHERE errorCode = 'AccessDenied' AND errorMessage LIKE '%explicit deny in a service control policy%' AND eventTime > '2024-05-01 00:00:00' ORDER BY eventTime DESC LIMIT 50 Zero rows after a full deployment cycle means the policy is safe to promote.
Driving it from OpenShift: the control plane you already run
If your platform team already operates OpenShift or Kubernetes, you can keep the SCP lifecycle there instead of bolting on another CI system.
Crossplane's AWS provider exposes Organizations resources natively, so an SCP becomes a reconciled object:
apiVersion: organizations.aws.upbound.io/v1beta1 kind: Policy metadata: name: scp-region-lock spec: forProvider: name: scp-region-lock type: SERVICE_CONTROL_POLICY description: Restrict API calls to approved regions contentSecretRef: namespace: landing-zone name: scp-region-lock key: policy.json providerConfigRef: name: aws-management-account --- apiVersion: organizations.aws.upbound.io/v1beta1 kind: PolicyAttachment metadata: name: scp-region-lock-prod spec: forProvider: policyIdSelector: matchLabels: scp: region-lock targetId: ou-aaaa-11111111 providerConfigRef: name: aws-management-account Run kubectl explain policy.spec.forProvider --api-version=organizations.aws.upbound.io/v1beta1 against your installed provider version before copying field names — the upjet-generated schema evolves between releases.
The validation step fits a Tekton task cleanly, using IRSA (or OpenShift's AWS STS integration) so no static credentials ever land in the cluster:
apiVersion: v1 kind: ServiceAccount metadata: name: scp-validator namespace: landing-zone annotations: eks.amazonaws.com/role-arn: arn:aws:iam::111122223333:role/ScpValidatorRole --- apiVersion: tekton.dev/v1 kind: Task metadata: name: validate-scp namespace: landing-zone spec: params: - name: policyPath type: string default: policies/region-lock.json workspaces: - name: source steps: - name: access-analyzer image: public.ecr.aws/aws-cli/aws-cli:2.17.0 workingDir: $(workspaces.source.path) script: | #!/usr/bin/env bash set -euo pipefail FINDINGS=$(aws accessanalyzer validate-policy \ --policy-type SERVICE_CONTROL_POLICY \ --policy-document "file://$(params.policyPath)" \ --query "length(findings[?findingType=='ERROR' || findingType=='SECURITY_WARNING'])" \ --output text) echo "blocking findings: ${FINDINGS}" test "${FINDINGS}" -eq 0 - name: size-check image: public.ecr.aws/docker/library/alpine:3.20 workingDir: $(workspaces.source.path) script: | #!/bin/sh set -eu apk add --no-cache jq >/dev/null SIZE=$(jq -c . "$(params.policyPath)" | wc -c) echo "minified size: ${SIZE} / 5120" [ "${SIZE}" -le 5120 ] Bind the pipeline ServiceAccount to a restricted SCC and give ScpValidatorRole only access-analyzer:ValidatePolicy plus read-only Organizations actions. Apply happens from a separate, manually approved pipeline with a role that can write policies.
Security best practices
- Keep the management account empty. No workloads, no pipelines, no human daily access. SCPs do not protect it, so the only defence is having nothing there to attack.
- Break-glass, tested quarterly. One IAM role per critical account, MFA-protected, credentials in a sealed envelope or an offline vault, excluded from every SCP deny via
aws:PrincipalArn. Alarm on everyAssumeRoleagainst it. - Delegate, don't centralize access. Use IAM Identity Center permission sets scoped per OU rather than cross-account
AdministratorAccessroles. Permission sets are deployable as code and revocable in one place. - Layer SCPs with permission boundaries. SCPs cap what an account can do; permission boundaries cap what a developer-created role can do inside it. Require a boundary on
iam:CreateRolevia an SCP condition oniam:PermissionsBoundary. - Deny
organizations:LeaveOrganizationin every workload OU. An account that leaves the org loses all guardrails instantly. - Centralize logs in the Log Archive account with S3 Object Lock in compliance mode and bucket policies that deny deletion, including to the account root.
- Alert on policy changes. EventBridge rule on
CreatePolicy,UpdatePolicy,AttachPolicy,DetachPolicyfromorganizations.amazonaws.com, routed to your SOC channel. A silent SCP detach is the perfect pre-attack move. - Rotate your region lock when expanding. Adding a region is a policy change reviewed like any other, not a console click.
Key takeaways
Breaking up a single AWS account is less about the migration mechanics than about the governance you build on the other side. The sequence that works:
- Design the OU layout before creating any account; map OUs to policy boundaries, keep the tree two or three levels deep, and always provision a sandbox and a policy-staging OU.
- Let Control Tower own the baseline — Log Archive, Audit, organization trail, managed controls — and keep the management account sterile.
- Write SCPs as deny-lists on top of
FullAWSAccess, respect the five-policy and 5120-character limits, exclude global services from region locks, and build an exemption path for break-glass and automation roles. - Store policies in Git, render them with Terraform or Crossplane, and never edit in the console.
- Validate before deploying:
aws accessanalyzer validate-policyin CI,aws organizations describe-policyandlist-policies-for-targetto audit what is live, then a full workload replay in the staging OU with a CloudTrail Lake query for explicit denials.
An SCP that blocks an attacker and an SCP that blocks your deployment pipeline look identical in the console. The only difference is whether you tested it.


