Why bother with Organizations
Running everything in one AWS account works until it doesn't: a test script deletes a production bucket, a developer's IAM user has admin everywhere, and the bill can't be split by team. AWS Organizations lets you:
- Run many accounts (prod, dev, security, logging) under one consolidated bill.
- Group accounts into organizational units (OUs) and apply policies to the whole group.
- Set service control policies (SCPs): hard limits that even an account's admin can't bypass.
- Turn on org-wide services like CloudTrail, Config, GuardDuty and IAM Identity Center once.
The building blocks
| Term | What it is |
|---|---|
| Management account | The account that creates the organization and pays the bill. Keep workloads out of it. |
| Member account | Any other account in the org. Created from the org or invited. |
| Root | The top of the OU tree (not the same as the root user). |
| OU | A folder of accounts. Policies attached to an OU apply to every account inside it. |
| SCP | A JSON policy that sets the maximum permissions for accounts under it. It never grants anything. |
A common starter layout:
Root
├── Security (log-archive, security-tooling)
├── Infrastructure (shared-network)
├── Workloads
│ ├── Prod (app1-prod, app2-prod)
│ └── NonProd (app1-dev, app1-test)
└── Sandbox (individual experiments)
Step 1: Create the organization
From the account that will be the management account:
aws organizations create-organization --feature-set ALL
aws organizations describe-organization
ALL features is required for SCPs. If you already have an org with consolidated billing only, enable all features from the console (members must approve).
Step 2: Create OUs
ROOT_ID=$(aws organizations list-roots --query 'Roots[0].Id' --output text)
aws organizations create-organizational-unit --parent-id $ROOT_ID --name Security
aws organizations create-organizational-unit --parent-id $ROOT_ID --name Workloads
aws organizations create-organizational-unit --parent-id $ROOT_ID --name Sandbox
aws organizations list-organizational-units-for-parent --parent-id $ROOT_ID \
--query 'OrganizationalUnits[].[Name,Id]' --output table
Step 3: Create a member account
aws organizations create-account \
--email aws-app1-dev@example.com \
--account-name app1-dev
# Returns a CreateAccountStatus Id; poll it
aws organizations describe-create-account-status --create-account-request-id <car-id>
Each account needs a unique email address. Use a shared mailbox or plus-addressing your mail system supports, not a person's inbox.
The new account gets a role called OrganizationAccountAccessRole that the management account can assume:
aws sts assume-role \
--role-arn arn:aws:iam::<new-account-id>:role/OrganizationAccountAccessRole \
--role-session-name setup
Move it into the right OU:
aws organizations move-account --account-id <new-account-id> \
--source-parent-id $ROOT_ID --destination-parent-id <workloads-ou-id>
Step 4: Attach a first SCP
Enable the policy type, then create and attach a policy. This one stops anyone in Sandbox accounts from leaving the org, disabling CloudTrail, or using regions you don't operate in:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "DenyLeaveOrg",
"Effect": "Deny",
"Action": "organizations:LeaveOrganization",
"Resource": "*"
},
{
"Sid": "ProtectCloudTrail",
"Effect": "Deny",
"Action": ["cloudtrail:StopLogging", "cloudtrail:DeleteTrail"],
"Resource": "*"
},
{
"Sid": "DenyOtherRegions",
"Effect": "Deny",
"NotAction": ["iam:*", "organizations:*", "sts:*", "support:*", "cloudfront:*", "route53:*", "budgets:*"],
"Resource": "*",
"Condition": {
"StringNotEquals": { "aws:RequestedRegion": ["ap-south-1", "us-east-1"] }
}
}
]
}
aws organizations enable-policy-type --root-id $ROOT_ID --policy-type SERVICE_CONTROL_POLICY
POLICY_ID=$(aws organizations create-policy --name sandbox-guardrails \
--type SERVICE_CONTROL_POLICY --description "Sandbox baseline" \
--content file://sandbox-scp.json --query 'Policy.PolicySummary.Id' --output text)
aws organizations attach-policy --policy-id $POLICY_ID --target-id <sandbox-ou-id>
Warning
SCPs don't affect the management account, but they do affect every principal in member accounts, including the account root user. Test a new SCP on the Sandbox OU first. A badly written region-deny can break global services (IAM, Route 53, CloudFront are served from us-east-1), which is why they sit in
NotActionabove.
How SCPs combine with IAM
An action is allowed only if every layer allows it:
SCPs on Root → OU → account (maximum)
∩
IAM identity policy (what the user/role is granted)
∩
Permission boundary / resource policy (if used)
FullAWSAccess is attached everywhere by default, which is why nothing changes until you add Deny SCPs. Keep it attached and use deny-list SCPs; it's simpler than allow-lists.
Useful checks
# Which policies apply to an account?
aws organizations list-policies-for-target --target-id <account-id> --filter SERVICE_CONTROL_POLICY
# Where is an account in the tree?
aws organizations list-parents --child-id <account-id>
# All accounts
aws organizations list-accounts --query 'Accounts[].[Name,Id,Status]' --output table
When a user gets AccessDenied with "with an explicit deny in a service control policy" in the message, it's an SCP, not IAM.
Prevent problems later
- No workloads and no daily logins in the management account. Lock its root user with MFA.
- Use IAM Identity Center for human access instead of IAM users in each account.
- Turn on an organization trail in CloudTrail and send it to a log-archive account.
- Keep SCPs in Git and deploy them through a pipeline; document every Deny with a
Sid. - Set an AWS Budgets alert in each account the day it's created.