Introduction
Service control policies (SCPs) are organization-level guardrails in AWS Organizations. They limit the permissions available to affected accounts and organizational units (OUs). That reach is what makes teams nervous about touching them. A bad SCP breaks every account under it at once, there is no audit mode to preview the effect, and the recommended rollout is moving accounts into a staging OU a few at a time.
Debugging is not much easier after rollout. The usual workflow for finding the policy behind an AccessDenied involves sts decode-authorization-message and a manual search through every SCP in the console. The IAM policy simulator doesn’t help either: per AWS’s own documentation, it can’t test SCPs that contain any conditions, and conditions are exactly what region locks, IMDSv2 rules, and tag requirements are made of.
LocalStack for AWS 2026.06.0 added SCP evaluation to the IAM enforcement engine. In this tutorial we build a throwaway organization, attach a guardrails SCP to a sandbox account, replay a workload against it, read denial messages that name the exact policy, and simulate condition-based SCPs that the AWS simulator can’t.
How SCPs work
An SCP is attached to the organization root, an organizational unit, or an account. It grants nothing by itself. It defines the maximum permissions below its attachment point, and identity-based or resource-based policies can only allow what every SCP on the path also allows. An explicit deny at any level wins, and a level without a matching allow is an implicit deny.
Every organization starts with the AWS-managed FullAWSAccess policy attached everywhere, which allows everything. In practice, SCPs are deny statements that carve exceptions out to it.
Two properties matter for testing:
- SCPs apply to every principal in a member account, including the account’s root user.
- SCPs never apply to the management account, so testing a guardrail from the wrong account proves nothing.
With IAM enforcement enabled, LocalStack evaluates SCPs together with identity-based policies, resource-based policies, and permissions boundaries, for single-account and cross-account requests. Denied requests report which principal was denied, which action and resource were involved, whether the deny was explicit or implicit, and which policy made the decision. The policy simulator runs through the same engine.
Prerequisites
The commands use lstk aws, which runs the AWS CLI against LocalStack with test credentials. You can also use awslocal, if you prefer.
Step 1: Start LocalStack with IAM enforcement
SCP evaluation is part of the IAM enforcement engine, which is off by default. Start LocalStack with it enabled:
LOCALSTACK_ENFORCE_IAM=1 lstk startStep 2: Build a local organization
Our scenario: a platform team hands every product team a sandbox account, and wants to rehearse the sandbox guardrails before they reach the real organization.
Create the organization and enable SCPs as a policy type; without this step, create-policy returns a PolicyTypeNotEnabledException:
lstk aws organizations create-organization --feature-set ALL
ROOT_ID=$(lstk aws organizations list-roots --query 'Roots[0].Id' --output text)lstk aws organizations enable-policy-type --root-id "$ROOT_ID" \ --policy-type SERVICE_CONTROL_POLICYCreate the sandbox account. On AWS this is an asynchronous process against real billing entities; locally it returns a ready account id immediately:
SANDBOX_ID=$(lstk aws organizations create-account \ --account-name meridian-sandbox --email sandbox@meridian.example \ --query CreateAccountStatus.AccountId --output text)echo "$SANDBOX_ID"This will return the account id for the sandbox account.
056929141747LocalStack addresses a member account by using its account id as the access key id. lstk aws pins its own credentials, so the way to act as the sandbox is a CLI profile:
aws configure set profile.sandbox.aws_access_key_id "$SANDBOX_ID"aws configure set profile.sandbox.aws_secret_access_key testaws configure set profile.sandbox.region eu-central-1
lstk aws sts get-caller-identity --profile sandboxThis will return the caller identity for the sandbox account.
{"UserId": "056929141747", "Account": "056929141747", "Arn": "arn:aws:iam::056929141747:root"}Step 3: Write and attach the guardrails
One SCP, three statements, all condition-based. Save this as sandbox-guardrails.json:
{ "Version": "2012-10-17", "Statement": [ { "Sid": "ApprovedRegionsOnly", "Effect": "Deny", "NotAction": ["iam:*", "sts:*", "organizations:*"], "Resource": "*", "Condition": {"StringNotEquals": {"aws:RequestedRegion": ["eu-central-1"]}} }, { "Sid": "RequireIMDSv2", "Effect": "Deny", "Action": "ec2:RunInstances", "Resource": "arn:aws:ec2:*:*:instance/*", "Condition": {"StringNotEquals": {"ec2:MetadataHttpTokens": "required"}} }, { "Sid": "RequireTeamTagOnQueues", "Effect": "Deny", "Action": "sqs:CreateQueue", "Resource": "*", "Condition": {"Null": {"aws:RequestTag/team": "true"}} } ]}A few things to note:
ApprovedRegionsOnlydenies everything outsideeu-central-1, withNotActioncarve-outs for global services.RequireIMDSv2denies instance launches unlessec2:MetadataHttpTokensisrequired.RequireTeamTagOnQueuesdenies queue creation when theteamrequest tag is absent.
Create the policy and attach it to the sandbox account:
SCP_ARN=$(lstk aws organizations create-policy --name sandbox-guardrails \ --description "Region lock, IMDSv2, and team tag guardrails" \ --type SERVICE_CONTROL_POLICY \ --content file://sandbox-guardrails.json \ --query Policy.PolicySummary.Arn --output text)
lstk aws organizations attach-policy --policy-id "${SCP_ARN##*/}" --target-id "$SANDBOX_ID"The default FullAWSAccess policy stays attached alongside; it provides the allow that these deny statements carve out of.
Step 4: Replay the workload
Create a queue in the wrong region:
lstk aws sqs create-queue --queue-name ingest-jobs --tags team=data-eng \ --profile sandbox --region us-east-1An error occurred (AccessDenied) when calling the CreateQueue operation:User: arn:aws:iam::056929141747:root is not authorized to perform: sqs:createqueueon resource: arn:aws:sqs:us-east-1:056929141747:ingest-jobs with an explicit denyin a service control policy:arn:aws:organizations::000000000000:policy/o-c744a2fec2/service_control_policy/p-c64d1f04The message names the principal, the action, the resource, the fact that the deny was explicit, the policy type, and the SCP’s ARN. On AWS, a member account can’t even list the SCPs that govern it.
An untagged queue in the approved region fails the same way, this time tripped by the tag statement:
lstk aws sqs create-queue --queue-name ingest-jobs --profile sandboxWith the tag and the right region, the request passes both statements:
lstk aws sqs create-queue --queue-name ingest-jobs --tags team=data-eng --profile sandbox{"QueueUrl": "http://sqs.eu-central-1.localhost.localstack.cloud:4566/056929141747/ingest-jobs"}Step 5: Enforce IMDSv2
Launch an instance that still allows IMDSv1:
AMI=$(lstk aws ec2 describe-images --profile sandbox --query 'Images[0].ImageId' --output text)
lstk aws ec2 run-instances --image-id "$AMI" --instance-type t3.micro \ --metadata-options HttpTokens=optional --profile sandboxAn error occurred (UnauthorizedOperation) when calling the RunInstances operation:You are not authorized to perform this operation. User: arn:aws:iam::056929141747:rootis not authorized to perform: ec2:RunInstances with an explicit deny in a servicecontrol policy: arn:aws:organizations::000000000000:policy/o-c744a2fec2/service_control_policy/p-c64d1f04Require IMDSv2 and the launch goes through:
lstk aws ec2 run-instances --image-id "$AMI" --instance-type t3.micro \ --metadata-options HttpTokens=required --profile sandbox \ --query 'Instances[0].{InstanceId: InstanceId, HttpTokens: MetadataOptions.HttpTokens}'This will return the instance id and the HTTP tokens for the instance.
{"InstanceId": "i-f627c144929c159c7", "HttpTokens": "required"}Step 6: The management account is exempt
The same call the region lock denied for the sandbox, run as the management account:
lstk aws sqs create-queue --queue-name mgmt-ops --region us-east-1{"QueueUrl": "http://sqs.us-east-1.localhost.localstack.cloud:4566/000000000000/mgmt-ops"}SCPs never apply to the management account, on AWS and here. Guardrails have to be verified from a member account.
Step 7: Simulate SCP conditions
All three guardrail statements carry conditions, so none of them can be tested in AWS’s policy simulator. LocalStack’s simulate-principal-policy runs through the same enforcement engine as live requests, conditions included.
The simulation source must be an IAM entity, so create a user in the sandbox account:
lstk aws iam create-user --user-name ci-runner --profile sandboxlstk aws iam put-user-policy --user-name ci-runner --policy-name ci-allow \ --policy-document '{"Version": "2012-10-17", "Statement": [{"Effect": "Allow", "Action": ["sqs:*", "ec2:*"], "Resource": "*"}]}' \ --profile sandboxSimulate sqs:CreateQueue from us-east-1 with the team tag present:
lstk aws iam simulate-principal-policy \ --policy-source-arn "arn:aws:iam::${SANDBOX_ID}:user/ci-runner" \ --action-names sqs:CreateQueue \ --resource-arns "*" \ --context-entries \ "ContextKeyName=aws:RequestedRegion,ContextKeyValues=us-east-1,ContextKeyType=string" \ "ContextKeyName=aws:RequestTag/team,ContextKeyValues=data-eng,ContextKeyType=string" \ --profile sandbox \ --query 'EvaluationResults[0].{EvalDecision: EvalDecision, Org: OrganizationsDecisionDetail}'{"EvalDecision": "explicitDeny", "Org": {"AllowedByOrganizations": false}}Swap us-east-1 for eu-central-1 and the same call returns:
{"EvalDecision": "allowed", "Org": {"AllowedByOrganizations": true}}Drop the aws:RequestTag/team context entry and even eu-central-1 returns explicitDeny: the tag rule fires in simulation exactly as it would in production.
Summary
We built an AWS organization locally, attached a guardrails SCP with region, IMDSv2, and tag conditions to a sandbox account, and verified all three rules from denial messages that name the responsible policy ARN. The policy simulator evaluated the same conditions that AWS’s simulator documents as untestable. Every check is a plain CLI call, and SCP evaluation is synchronous, so wrapping the flow into a CI script is straightforward.
The same engine also gained numeric and negated string and ARN condition operators, the iam:PolicyArn condition key, S3 tag-based condition keys, and SCP evaluation for cross-account requests. See the release notes and the Organizations documentation for supported features and limitations.




