AWS Security8 min read

    Test an AWS Region-Lock SCP Locally Before You Attach It to Production

    Tarek Cheikh

    Founder & AWS Cloud Architect

    LocalEmu logo and cover image

    A Region-restriction SCP is a common guardrail in AWS Organizations, and a mistake in it is easy to make. Forget one global service and you block it everywhere. Mistype one Region and a team loses it. The standard advice is to test on a small OU first. This post gives you a lab that costs nothing and touches no AWS account, so you can find the mistake before the OU does.

    You will run AWS's own sample policy against a member account in LocalEmu, an open-source AWS emulator that can enforce IAM and Organizations policies. You will get a table of allowed and denied calls by service and Region, then turn it into a check that exits with an error when the policy does not do what you meant.

    What the policy does

    The sample is Deny-access-to-AWS-based-on-the-requested-AWS-region.json. It denies every action when aws:RequestedRegion is not in your list (eu-central-1 and eu-west-1 in the sample), except for a NotAction list of approved global services, such as IAM, Organizations, STS, Route 53 and CloudFront. A second condition in the same statement lets one privileged role bypass the restriction. AWS's own README warns that the example "might not include all of the latest global AWS services or operations", so the list is yours to review. That is exactly the kind of thing worth testing.

    1. Start LocalEmu with IAM enforcement

    You need Python 3.11 or newer. IAM enforcement is opt-in, and with it on, setup calls need a key listed in ROOT_ACCESS_KEYS. The script below uses the well-known example key from the AWS documentation. This lab was run with localemu 1.4.0.

    pip install localemu==1.4.0 boto3 requests
    IAM_ENFORCEMENT=1 ROOT_ACCESS_KEYS=AKIAIOSFODNN7EXAMPLE localemu start

    2. Download the policy

    This URL is pinned to a commit, so the lab keeps working if the repository changes. The repository is MIT-0 licensed.

    curl -o region-lock.json https://raw.githubusercontent.com/aws-samples/service-control-policy-examples/088df0b0812c55bb0b90824c155776dfdf690404/Region-controls/Deny-access-to-AWS-based-on-the-requested-AWS-region.json

    3. The lab

    The script builds an organization with a member account inside a sandbox OU, because SCPs do not affect the management account (more on that below). It works as a role in the member account, runs the same four calls in four Regions before the policy exists, attaches the policy to the OU, and runs them again. The expected result is written from the policy's intent: regional services only in the two allowed Regions, IAM everywhere. One deliberate change to the sample: its privileged-role bypass is filled with a role name that does not exist, so the matrix tests the Region rule only (the last section explains why). Save it as scp_lab.py next to region-lock.json:

    import json
    import sys
    import boto3
    import requests
    from botocore.exceptions import ClientError
    
    EP = "http://localhost:4566"
    ROOT = dict(aws_access_key_id="AKIAIOSFODNN7EXAMPLE",
                aws_secret_access_key="wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY")
    ALLOWED = {"eu-west-1", "eu-central-1"}          # the Regions in the policy
    REGIONS = ["eu-west-1", "eu-central-1", "us-east-1", "ap-southeast-1"]
    
    def client(service, region="eu-west-1", **creds):
        return boto3.client(service, endpoint_url=EP, region_name=region, **creds)
    
    # 1. An organization with a member account inside a sandbox OU
    org = client("organizations", **ROOT)
    org.create_organization(FeatureSet="ALL")
    root_id = org.list_roots()["Roots"][0]["Id"]
    org.enable_policy_type(RootId=root_id, PolicyType="SERVICE_CONTROL_POLICY")
    org.create_account(Email="member@example.com", AccountName="member")
    member = next(a["Id"] for a in org.list_accounts()["Accounts"] if a["Name"] == "member")
    ou = org.create_organizational_unit(ParentId=root_id, Name="sandbox")["OrganizationalUnit"]["Id"]
    org.move_account(AccountId=member, SourceParentId=root_id, DestinationParentId=ou)
    
    # 2. Work as a role in the member account
    c = client("sts", **ROOT).assume_role(
        RoleArn=f"arn:aws:iam::{member}:role/OrganizationAccountAccessRole",
        RoleSessionName="lab")["Credentials"]
    creds = dict(aws_access_key_id=c["AccessKeyId"],
                 aws_secret_access_key=c["SecretAccessKey"],
                 aws_session_token=c["SessionToken"])
    
    # 3. The calls to test: three regional services and one global one (IAM)
    def calls(phase):
        def s3_create_bucket(region):
            kw = {} if region == "us-east-1" else {
                "CreateBucketConfiguration": {"LocationConstraint": region}}
            client("s3", region, **creds).create_bucket(
                Bucket=f"scp-lab-{phase}-{region}-{member}", **kw)
        return {
            "s3 CreateBucket":     s3_create_bucket,
            "dynamodb ListTables": lambda r: client("dynamodb", r, **creds).list_tables(),
            "sqs ListQueues":      lambda r: client("sqs", r, **creds).list_queues(),
            "iam ListUsers":       lambda r: client("iam", r, **creds).list_users(),
        }
    
    def run(label, phase):
        results = {}
        print(f"\n{label}")
        print(f"{'call':<22}" + "".join(f"{r:<16}" for r in REGIONS))
        for name, call in calls(phase).items():
            row = f"{name:<22}"
            for region in REGIONS:
                try:
                    call(region)
                    results[(name, region)] = True
                except ClientError as e:
                    results[(name, region)] = False
                    assert e.response["Error"]["Code"] == "AccessDenied", e
                row += f"{'allowed' if results[(name, region)] else 'DENIED':<16}"
            print(row)
        return results
    
    # 4. Before the SCP: everything is allowed
    before = run("Before the SCP", "before")
    assert all(before.values()), "baseline should allow every call"
    
    # 5. AWS's sample Region-restriction SCP, with its two placeholders filled in
    policy = open("region-lock.json").read()
    policy = policy.replace("${Account}", member).replace("[PRIVILEGED_ROLE]", "NoSuchRole")
    pid = org.create_policy(Name="region-lock", Description="lab",
                            Type="SERVICE_CONTROL_POLICY",
                            Content=policy)["Policy"]["PolicySummary"]["Id"]
    org.attach_policy(PolicyId=pid, TargetId=ou)
    
    # 6. Expected outcome, from the policy's intent: regional services only in the
    #    allowed Regions, IAM (listed in NotAction) everywhere
    def expected(name, region):
        return name == "iam ListUsers" or region in ALLOWED
    
    after = run("After the SCP", "after")
    bad = [(k, v) for k, v in after.items() if v != expected(*k)]
    for (name, region), got in bad:
        print(f"UNEXPECTED: {name} in {region} was {'allowed' if got else 'denied'}")
    
    # 7. Why was one of them denied?
    d = requests.get(EP + "/_localemu/iam/decisions",
                     params={"action": "s3:CreateBucket", "decision": "EXPLICIT_DENY"}).json()
    print("\nExample decision:", json.dumps(d["decisions"][-1], indent=2))
    sys.exit(1 if bad else 0)

    4. Read the result

    Keep LocalEmu running in its terminal and run python scp_lab.py from a second one. The script creates the organization, so run it once per fresh LocalEmu: to run it again, stop LocalEmu and start it again with a new HOME or a fresh install directory (a second run on the same instance fails with AlreadyInOrganizationException). The baseline allows everything:

    call                  eu-west-1       eu-central-1    us-east-1       ap-southeast-1  
    s3 CreateBucket       allowed         allowed         allowed         allowed         
    dynamodb ListTables   allowed         allowed         allowed         allowed         
    sqs ListQueues        allowed         allowed         allowed         allowed         
    iam ListUsers         allowed         allowed         allowed         allowed         

    After the policy is attached to the OU:

    call                  eu-west-1       eu-central-1    us-east-1       ap-southeast-1  
    s3 CreateBucket       allowed         allowed         DENIED          DENIED          
    dynamodb ListTables   allowed         allowed         DENIED          DENIED          
    sqs ListQueues        allowed         allowed         DENIED          DENIED          
    iam ListUsers         allowed         allowed         allowed         allowed         

    Regional services work in eu-west-1 and eu-central-1 and fail everywhere else. IAM keeps working in every Region because iam:* is in the NotAction list. The script prints a sample decision at the end and exits with 0.

    5. Break the policy on purpose

    A check is only useful if it can fail. Run these two, each against a fresh LocalEmu as explained above, and each in its own folder holding a modified copy of the policy and a copy of the script (the script reads region-lock.json from the current directory):

    mkdir broken && cp scp_lab.py broken/

    A mistyped Region. Replace eu-central-1 with eu-north-1 in the allowed list:

    sed 's/"eu-central-1"/"eu-north-1"/' region-lock.json > broken/region-lock.json
    (cd broken && python scp_lab.py)
    UNEXPECTED: s3 CreateBucket in eu-central-1 was denied
    UNEXPECTED: dynamodb ListTables in eu-central-1 was denied
    UNEXPECTED: sqs ListQueues in eu-central-1 was denied

    A forgotten global service. Delete the iam:* line from the NotAction list:

    sed '/"iam:\*",/d' region-lock.json > broken/region-lock.json
    (cd broken && python scp_lab.py)
    UNEXPECTED: iam ListUsers in us-east-1 was denied
    UNEXPECTED: iam ListUsers in ap-southeast-1 was denied

    In both cases the script exits with 1, so it can run in a pipeline and stop a pull request that changes the policy in the wrong way.

    6. Find out why a call was denied

    When a call fails and you do not know which policy did it, ask LocalEmu. GET /_localemu/iam/decisions returns recent decisions, filtered by principal_arn, action, resource or decision. For a denied bucket creation it returned (abbreviated, the real entry also has an id, a timestamp and the account id):

    {
      "decision": "EXPLICIT_DENY",
      "principal_arn": "arn:aws:sts::933400785829:assumed-role/OrganizationAccountAccessRole/lab",
      "action": "s3:CreateBucket",
      "resource": "arn:aws:s3:::scp-lab-after-us-east-1-933400785829",
      "reason": "Organizations SCP deny",
      "source": "scp",
      "matched_statement": null
    }

    The log is an in-memory ring buffer of 2000 entries, not an audit trail.

    Where a local result differs from AWS

    Treat a local pass as a strong hint, not as proof. Two differences showed up while building this lab:

    • The management account. AWS documents that SCPs "don't affect users or roles in the management account". In our test LocalEmu applied an SCP to an IAM user in the management account anyway (the organization's root access keys were not blocked). Always test as a member account, as the script does.
    • The privileged-role exception. AWS documents that for roles aws:PrincipalArn holds the role ARN, not the session ARN. In our test the sample's bypass did not exempt an assumed role, for two reasons. LocalEmu compared the pattern against the session ARN (arn:aws:sts::...:assumed-role/...), and it treated the key name as case-sensitive: the sample spells it aws:PrincipalARN, and AWS documents that condition key names are not case-sensitive. Only the session ARN pattern with the spelling aws:PrincipalArn let the role through, which is the opposite of what AWS does with the role ARN. That is why the script fills the placeholder with a role name that does not exist: it leaves the bypass out of the test instead of giving a wrong answer. Check that part of your policy in AWS.

    For the last step, AWS's own guidance applies: test in a separate organization or an OU that represents production, then roll out to broader OUs over time.

    Make it yours

    • Replace ALLOWED and REGIONS with your own Regions.
    • Add the services your teams actually use to calls(), one line each, and add the global services you rely on to the policy's NotAction list if the matrix shows them blocked.
    • Keep region-lock.json in the repository that owns your SCPs and run the script on every change.

    The same approach works for any SCP: attach it to an OU in the lab, state what each call should do, and let the exit code tell you. The SCP glossary entry covers the basics, and the LocalEmu tool page and documentation have the install options, including Docker. If you find something LocalEmu gets wrong, the repository takes issues and pull requests.

    Tarek Cheikh

    Go Deeper: The State of AWS Security 2026

    This article is just the start. Get the full picture with our free whitepaper - 8 chapters covering IAM, S3, VPC, monitoring, agentic AI security, compliance, and a prioritized action plan with 50+ CLI commands.

    SCPAWS OrganizationsLocalEmuGuardrailsIAMLocal Development

    KloudSec, built by the team behind Toc Consulting

    See AWS Security checked automatically, continuously

    What you just read is a manual, one-time check. KloudSec runs this and 400+ more automated checks across 90+ AWS services, continuously, agentless, live in about 5 minutes.

    Try KloudSec free

    Toc Consulting: AWS Security & Cloud Architecture

    Want expert help with AWS Security?

    Our team helps engineering teams secure and architect AWS the right way: assessment in week one, a prioritized action plan in week two.