Testing AWS Service Control Policies Locally with LocalStack

Learn how to test Service Control Policies with LocalStack by building a local AWS organization, attaching guardrails to a sandbox account, reading denial messages that name the exact policy, and simulating SCP conditions before they reach production.

Testing AWS Service Control Policies Locally with LocalStack

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:

  1. SCPs apply to every principal in a member account, including the account’s root user.
  2. 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

  • lstk
  • Docker
  • jq (optional) for readable JSON output

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:

Terminal window
LOCALSTACK_ENFORCE_IAM=1 lstk start

Step 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:

Terminal window
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_POLICY

Create the sandbox account. On AWS this is an asynchronous process against real billing entities; locally it returns a ready account id immediately:

Terminal window
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.

Terminal window
056929141747

LocalStack 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:

Terminal window
aws configure set profile.sandbox.aws_access_key_id "$SANDBOX_ID"
aws configure set profile.sandbox.aws_secret_access_key test
aws configure set profile.sandbox.region eu-central-1
lstk aws sts get-caller-identity --profile sandbox

This 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:

  • ApprovedRegionsOnly denies everything outside eu-central-1, with NotAction carve-outs for global services.
  • RequireIMDSv2 denies instance launches unless ec2:MetadataHttpTokens is required.
  • RequireTeamTagOnQueues denies queue creation when the team request tag is absent.

Create the policy and attach it to the sandbox account:

Terminal window
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:

Terminal window
lstk aws sqs create-queue --queue-name ingest-jobs --tags team=data-eng \
--profile sandbox --region us-east-1
Terminal window
An error occurred (AccessDenied) when calling the CreateQueue operation:
User: arn:aws:iam::056929141747:root is not authorized to perform: sqs:createqueue
on resource: arn:aws:sqs:us-east-1:056929141747:ingest-jobs with an explicit deny
in a service control policy:
arn:aws:organizations::000000000000:policy/o-c744a2fec2/service_control_policy/p-c64d1f04

The 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:

Terminal window
lstk aws sqs create-queue --queue-name ingest-jobs --profile sandbox

With the tag and the right region, the request passes both statements:

Terminal window
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:

Terminal window
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 sandbox
Terminal window
An error occurred (UnauthorizedOperation) when calling the RunInstances operation:
You are not authorized to perform this operation. User: arn:aws:iam::056929141747:root
is not authorized to perform: ec2:RunInstances with an explicit deny in a service
control policy: arn:aws:organizations::000000000000:policy/o-c744a2fec2/service_control_policy/p-c64d1f04

Require IMDSv2 and the launch goes through:

Terminal window
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:

Terminal window
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:

Terminal window
lstk aws iam create-user --user-name ci-runner --profile sandbox
lstk 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 sandbox

Simulate sqs:CreateQueue from us-east-1 with the team tag present:

Terminal window
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.

About the Author

Harsh Mishra
Harsh Mishra
Engineer at LocalStack

Harsh Mishra is an Engineer at LocalStack and AWS Community Builder. Harsh has previously worked at HackerRank, Red Hat, and Quansight, and specialized in DevOps, Platform Engineering, and CI/CD pipelines.

Launch yourself in the world of local cloud development

Start a free trial