A0 — An AWS organization nobody logs into as root
- Track
- AWS
- Level
- Beginner
- Time
- 3–4 h, plus up to 24 h waiting for billing data
- Cost
- $0 if you stay in the Free Tier
- Last verified
- 2026-10-22
Before you start
- A payment card
- An email inbox that supports plus-addressing (Gmail, Fastmail, your own domain)
- A password manager
- Touch ID / a security key, or an authenticator app
- AWS CLI v2
When you finish, nobody — including you — signs in to AWS as root or as an IAM user. You get in through one SSO portal with MFA on every sign-in, your test accounts are fenced off from regions you don’t use, and every account emails you before it costs more than $20.
Your values — fill these in and every command on this page updates; copy buttons copy your version.
What you’ll build
Section titled “What you’ll build”Root (organization)├── management account ← billing, Organizations, Identity Center only. Root: MFA, never used.└── OU "workloads" ← region guard rail applies here ├── dev ← no root credentials at all └── prod ← no root credentials at all
Sign-in: https://<PORTAL_SUBDOMAIN>.awsapps.com/start → user <SSO_USER> + MFA → pick account → role| Decision | Choice | Why |
|---|---|---|
| Account emails | one per account, plus-addressed: <EMAIL_USER>+aws-mgmt@<EMAIL_DOMAIN> |
AWS needs a unique email per account; plus-addressing keeps one inbox |
| Management account | Organizations, Identity Center and billing — nothing else | the only account that can’t be fenced by policies |
| How you sign in | IAM Identity Center, MFA every time | no long-lived keys, no shared root password |
| Member-account root | no credentials at all | “MFA on root” was the old answer; “no root” is the current one |
| Home region | eu-north-1; eu-central-1 and us-east-1 also allowed |
pick your own home region — change it everywhere below |
| Budget | $50/month per account, alerts at $20 actual, $50 actual, $50 forecast | a learning account should never surprise you |
1. Management account and root lockdown
Section titled “1. Management account and root lockdown”-
Go to aws.amazon.com → Create an AWS account with email + password. Email
<EMAIL_USER>+aws-mgmt@<EMAIL_DOMAIN>, support plan Basic (free).Sign-up asks for a Free or Paid plan. Either works: creating an organization in section 2 switches the account to Paid, and leftover credits carry over. It also ends the Explore AWS credit activities on the console home page — do those first if you want them.
-
Store a generated 32+ character root password in your password manager. You type it a few more times today, then never again.
-
Signed in as root: account menu → Security credentials → Assign MFA device. Register two devices — a passkey (Touch ID or security key) and an authenticator app. One device is a lockout waiting to happen.
-
Open
https://console.aws.amazon.com/billing/home#/account(it isn’t in the Billing menu) → IAM user and role access to Billing information → Edit → Activate IAM Access. Without this, your SSO user can’t see costs or budgets. -
Billing and Cost Management → Billing preferences, two sections, each with its own Edit:
- Alert preferences → Receive AWS Free Tier usage alerts. Leave CloudWatch billing alerts off — it can never be switched off again.
- Invoice delivery → PDF invoices by email.
-
Cost Anomaly Detection → check a default AWS services monitor exists; if not, create one with a daily email. If it says Cost Explorer isn’t enabled yet, open Cost Explorer once and come back tomorrow — don’t wait for it.
-
Sign out of root.
2. Organization, member accounts and guard rails
Section titled “2. Organization, member accounts and guard rails”Sign in as root one last time — the SSO portal doesn’t exist yet.
-
AWS Organizations → Create an organization (all features).
-
AWS accounts → Add an AWS account → Create an AWS account twice:
dev, email<EMAIL_USER>+aws-dev@<EMAIL_DOMAIN>prod, email<EMAIL_USER>+aws-prod@<EMAIL_DOMAIN>
Keep the role name
OrganizationAccountAccessRole.One of my creates failed · my run, 2026-10-09
The first
devcreate failed withEMAIL_ALREADY_EXISTS— that address already belonged to another AWS account. Nothing half-created was left behind; a retry with a fresh address worked. Check with:Terminal window aws organizations list-create-account-status --states FAILED \--query 'CreateAccountStatuses[].[AccountName,FailureReason]' --output text -
Organizational units → on Root: Actions → Create new →
workloads. Movedevandprodinto it. The management account stays directly under Root. -
IAM → Root access management (its own menu item, not under Account settings) → Enable → tick Root credentials management and Privileged root actions in member accounts.
-
Policies → Service control policies → Enable → Create policy, two policies.
DenyLeaveOrganization— attach to Root (every account inherits it):DenyLeaveOrganization.json {"Version": "2012-10-17","Statement": [{ "Effect": "Deny", "Action": "organizations:LeaveOrganization", "Resource": "*" }]}RestrictRegions— attach toworkloadsonly. Never to Root: policies never restrict the management account anyway, and you’d only confuse yourself.RestrictRegions.json {"Version": "2012-10-17","Statement": [{"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": ["eu-north-1", "eu-central-1", "us-east-1"] }}}]}The
NotActionlist is AWS’s published example for deny access based on the requested Region (checked 2026-10-09), without its break-glass exemption. These are global services that always go throughus-east-1.Attach: open the policy → Targets → Attach.
I found a third policy I'd forgotten · my run, 2026-10-22
The policy list showed an extra
DenyLeaveAndCloseAccountfrom an earlier attempt, still attached. It couldn’t be deleted (PolicyInUseException) until I detached it from every target. Leave onlyDenyLeaveOrganization,RestrictRegionsand AWS’sFullAWSAccess— never removeFullAWSAccess: policies only take permissions away, so without it every account is denied everything.
3. IAM Identity Center — the only way in
Section titled “3. IAM Identity Center — the only way in”-
Switch the console region to your home region first (top right). There’s no region field — Identity Center is created wherever the console is. Then IAM Identity Center → Enable → keep the Organization instance.
-
Settings → Authentication → Multi-factor authentication → Configure:
- Prompt users for MFA: Every time they sign in (always-on)
- Types: Security keys and built-in authenticators + Authenticator apps
- Users without a device: Require them to register at sign-in
-
Users → Add user: username
<SSO_USER>, send an email with password setup instructions. Open the email and set the password within 7 days — the link expires. -
Multi-account permissions → Permission sets → Create: predefined
AdministratorAccess, session 4 h. Create a second:ReadOnlyAccess, 8 h. -
Multi-account permissions → AWS accounts → tick all three → Assign users or groups →
<SSO_USER>→ both permission sets. -
Dashboard → AWS access portal URLs → Edit → subdomain
<PORTAL_SUBDOMAIN>. -
Sign out of root. In a private window open
https://<PORTAL_SUBDOMAIN>.awsapps.com/start, sign in as<SSO_USER>with MFA →prod→ AdministratorAccess → Management console.My first sign-in failed · my run, 2026-10-21
“We couldn’t verify your sign-in credentials.” I’d typed a different username from the one I created, and the password manager filled a password from another AWS login. The portal doesn’t tell you a username doesn’t exist. Check the exact username under Identity Center → Users, and use Reset password → one-time password if in doubt.
4. CLI profiles on your machine — no access keys
Section titled “4. CLI profiles on your machine — no access keys”aws --version # need aws-cli/2.xexport AWS_DEFAULT_REGION=eu-north-1
aws configure sso --profile mgmt# SSO session name: lab# SSO start URL: https://<PORTAL_SUBDOMAIN>.awsapps.com/start# SSO region: eu-north-1# → browser opens, approve → pick the management account → AdministratorAccessaws configure sso --profile dev # reuse session "lab", pick devaws configure sso --profile prod # reuse session "lab", pick prod
aws sso login --profile devfor p in mgmt dev prod; do echo "== $p"; aws sts get-caller-identity --profile "$p" --query '[Account,Arn]' --output text; donetest -f ~/.aws/credentials && echo "STOP: static credentials present" || echo "ok: no static keys"Expected output (and my pager problem) · my run, 2026-10-22
== mgmt111111111111 arn:aws:sts::111111111111:assumed-role/AWSReservedSSO_AdministratorAccess_…/jane== dev222222222222 arn:aws:sts::222222222222:assumed-role/AWSReservedSSO_AdministratorAccess_…/jane== prod333333333333 arn:aws:sts::333333333333:assumed-role/AWSReservedSSO_AdministratorAccess_…/janeok: no static keysMy first run printed only the == mgmt headers, exit code 0, no errors. The CLI’s output pager was swallowing everything. aws … --no-cli-pager proved it; the permanent fix is export AWS_PAGER="" in your shell profile.
5. Budgets — same JSON in every account
Section titled “5. Budgets — same JSON in every account”Budgets are per account, so alerts fire where the spend happens. These two files contain no IDs or emails — commit them.
mkdir -p budgets && cd budgetsexport ALERT_EMAIL="<ALERT_EMAIL>"
cat > monthly-cap.json <<'JSON'{ "BudgetName": "monthly-cap", "BudgetType": "COST", "TimeUnit": "MONTHLY", "BudgetLimit": { "Amount": "50", "Unit": "USD" }, "CostTypes": { "IncludeCredit": false, "IncludeRefund": false, "UseAmortized": false }}JSON
cat > monthly-cap-notifications.tmpl.json <<'JSON'[ { "Notification": { "NotificationType": "ACTUAL", "ComparisonOperator": "GREATER_THAN", "Threshold": 20, "ThresholdType": "ABSOLUTE_VALUE" }, "Subscribers": [ { "SubscriptionType": "EMAIL", "Address": "ALERT_EMAIL" } ] }, { "Notification": { "NotificationType": "ACTUAL", "ComparisonOperator": "GREATER_THAN", "Threshold": 50, "ThresholdType": "ABSOLUTE_VALUE" }, "Subscribers": [ { "SubscriptionType": "EMAIL", "Address": "ALERT_EMAIL" } ] }, { "Notification": { "NotificationType": "FORECASTED", "ComparisonOperator": "GREATER_THAN", "Threshold": 50, "ThresholdType": "ABSOLUTE_VALUE" }, "Subscribers": [ { "SubscriptionType": "EMAIL", "Address": "ALERT_EMAIL" } ] }]JSON
sed "s/ALERT_EMAIL/$ALERT_EMAIL/g" monthly-cap-notifications.tmpl.json > /tmp/notifications.jsonfor p in mgmt dev prod; do ACCOUNT=$(aws sts get-caller-identity --profile "$p" --query Account --output text) aws budgets create-budget --profile "$p" --account-id "$ACCOUNT" \ --budget file://monthly-cap.json \ --notifications-with-subscribers file:///tmp/notifications.json && echo "budget created in $p"donerm /tmp/notifications.jsonEmail subscribers need no confirmation. On a brand-new account create-budget can fail for up to 24 h while billing activates — rerun tomorrow; DuplicateRecordException means it already exists.
6. Prove it
Section titled “6. Prove it”Run every check. A guard rail you’ve never seen fire is a guess.
for p in mgmt dev prod; do echo "== $p"; aws iam list-users --profile "$p" --query 'Users[].UserName' --output text; doneaws ec2 describe-instances --profile dev --region ap-southeast-1for p in mgmt dev prod; do A=$(aws sts get-caller-identity --profile "$p" --query Account --output text) aws budgets describe-notifications-for-budget --profile "$p" --account-id "$A" --budget-name monthly-cap \ --query 'Notifications[].[NotificationType,Threshold]' --output textdoneWhat the guard rail looks like when it fires · my run, 2026-10-22
An error occurred (UnauthorizedOperation) when calling the DescribeInstances operation:You are not authorized to perform this operation. User: arn:aws:sts::222222222222:assumed-role/AWSReservedSSO_AdministratorAccess_…/jane is not authorized to perform: ec2:DescribeInstanceswith an explicit deny in a service control policy: arn:aws:organizations::…:policy/o-…/service_control_policy/p-…Done when
Section titled “Done when”- Signing in to the portal asks for MFA before showing any account (test in a private window)
- IAM → Root access management shows
devandprod: Not present -
list-usersprints nothing in all three accounts -
~/.aws/credentialsdoesn’t exist - Each account has
monthly-capwith alerts at ACTUAL 20, ACTUAL 50, FORECASTED 50 - A call to a non-allowed region in
devfails with explicit deny in a service control policy
Clean-up and cost
Section titled “Clean-up and cost”Nothing here costs money on its own. Keep it — every later AWS runbook builds on it. If you abandon the lab: close dev and prod from Organizations → AWS accounts → Close, then close the management account last.
What I’d do differently
Section titled “What I’d do differently”- Decide the Identity Center username and the portal subdomain before starting, and write them down — the subdomain is permanent.
- Use email addresses on a domain you own for every account. An employer mailbox is a recovery risk.
- Delete any experiment policies the same day you create them.
Next: A1 — IAM cross-account roles and least privilege (coming when I’ve run it).