The Allow SCP that worked until it didn’t
I run a multi-tenant SaaS platform on AWS with Control Tower managing the organization. Control Tower deploys a region deny guardrail — an SCP that blocks API calls outside your home region. The mechanism is a NotAction deny: it lists services that are allowed to operate globally (IAM, CloudFront, Route 53, a few dozen others), and denies everything else when aws:RequestedRegion doesn’t match your approved list.
This guardrail is one of the first things you hit when you try to do anything interesting. And the documentation says you can’t override a deny with an allow.
The documentation is wrong. Partially.
ELB wasn’t in the list
I needed Elastic Load Balancing to work. ELB is a global service — the API endpoint lives in us-east-1 regardless of where your load balancer sits. But elasticloadbalancing:* wasn’t in Control Tower’s NotAction allowlist.
The obvious move is to edit the managed SCP and add it. This works today and breaks tomorrow, because Control Tower overwrites managed policies on every landing zone update. You’d be signing up for a recurring merge conflict with AWS.
So I tried the thing the docs say shouldn’t work. I created a separate SCP with Effect: Allow for elasticloadbalancing:* and attached it to the same OU.
{
"Version": "2012-10-17",
"Statement": [{
"Sid": "AllowELBAccess",
"Effect": "Allow",
"Action": ["elasticloadbalancing:*"],
"Resource": "*"
}]
}
That’s a standalone SCP, attached alongside the CT guardrail on the same OU. ELB started working cross-region immediately.
Why it works when the docs say it shouldn’t
The AWS documentation repeats a true statement that leads you to a false conclusion: “an explicit deny always overrides an allow.” This is correct for Action denies. If an SCP says Effect: Deny, Action: s3:*, no amount of Allow SCPs will let S3 calls through.
But Control Tower’s guardrail doesn’t use Action. It uses NotAction. The deny targets everything not in the list. When the SCP evaluator encounters a separate Allow SCP for elasticloadbalancing:*, the allow makes that service match against the NotAction exclusion from the evaluator’s perspective. The deny says “deny everything that isn’t A, B, or C” — and the allow effectively adds D to the set of things that aren’t denied.
This is not an override. It’s a semantic interaction between NotAction and Allow that the SCP evaluation engine handles differently from a straight Action deny. The distinction matters because it means you can extend Control Tower’s guardrails without touching the managed policy.
I used the same pattern for SES. Created AllowSESAccess, attached it to the OU, and SES worked cross-region. (SES happened to already be in the NotAction list, so the Allow SCP was redundant in that case — but the pattern held.)
Then Bedrock broke the pattern
I needed Bedrock for inference. Same situation: Bedrock wasn’t in the NotAction list, and I needed it to work. I created an AllowBedrockAccess SCP following the exact same pattern.
It didn’t work.
The API calls were denied. The CloudTrail events showed the region deny guardrail firing on aws:RequestedRegion. The Allow SCP was attached, the service was specified — everything matched the ELB pattern. But the calls were blocked.
The difference is how Bedrock inference profiles route traffic. When you invoke a model through an inference profile in us-east-1, Bedrock physically routes the request to another region — us-east-2, us-west-2 — based on capacity. The API call originates in your home region, but the execution happens elsewhere. And the SCP condition key aws:RequestedRegion catches the actual destination region, not the region where you made the call.
The Allow SCP pattern bypasses the service restriction in the NotAction deny. It does not bypass the region restriction in the Condition block. ELB worked because ELB’s API calls stay in us-east-1 — there’s no cross-region routing under the hood. Bedrock inference profiles physically cross region boundaries, and aws:RequestedRegion fires on the far side.
Two categories I didn’t know existed
There are two categories of services that need to work outside your home region, and they need different solutions.
Global services — ELB, CloudFront, Route 53, and IAM. The API endpoint is in us-east-1 (or has no regional concept). The Allow SCP pattern works because aws:RequestedRegion never sees a non-home region. Create a standalone Allow SCP, attach it to the OU, and you’re done. Control Tower’s managed policy stays untouched.
Cross-region routed services — Bedrock inference profiles, and anything else where the service physically routes your request to another region behind the scenes. The Allow SCP pattern fails because the region condition fires on the actual destination. For these, you have to add the service to the NotAction list in the CT guardrail itself.
I added bedrock:* to the guardrail’s NotAction list directly. This is the edit-the-managed-SCP approach I wanted to avoid, but for cross-region routed services there’s no alternative. The SCP character limit (5,120 bytes) made this tighter than expected — I had to compact the JSON with separators=(',', ':') to fit bedrock:* into the existing policy without blowing past the limit.
import json
policy = json.loads(existing_scp_json)
policy["Statement"][0]["NotAction"].append("bedrock:*")
compact = json.dumps(policy, separators=(',', ':'))
print(len(compact)) # must be <= 5120
That’s a quick validation script to check the compacted policy fits. The stock CT guardrail uses pretty-printed JSON, and stripping the whitespace buys enough room for a few more service entries.
The pattern in production
For any service that needs to work outside your Control Tower home region, the first question is whether the service is global or cross-region routed. If the API calls stay in us-east-1 and the service just happens to manage resources elsewhere, it’s global. If the service physically sends your request to another region as part of normal operation, it’s cross-region routed.
For global services, create a standalone Allow SCP and attach it to the OU. Don’t touch the CT guardrail. This survives landing zone updates, doesn’t risk policy drift, and keeps your customizations isolated and auditable.
For cross-region routed services, you have to modify the guardrail’s NotAction list. Compact the JSON if you’re near the character limit. Accept that landing zone updates will overwrite this and plan for it — I keep the modified policy in version control so I can reapply it after CT updates.
The gap in the documentation
The interaction between Allow SCPs and NotAction denies is real, consistent, and useful. It’s not a bug or an edge case — it’s how the evaluation engine works. But I’ve never seen it documented explicitly. The docs say “deny beats allow” and leave you to discover the NotAction exception on your own.
The cross-region routing behavior is even less documented. Bedrock inference profiles are marketed as a us-east-1 feature — you create the profile in us-east-1, you call the API in us-east-1. The fact that the execution routes to us-east-2 and triggers aws:RequestedRegion conditions is not called out anywhere I’ve found. I discovered it by reading CloudTrail events after the Allow SCP failed.
If you’re running Control Tower and wondering why the Allow SCP trick works for some services and not others, the answer is in the routing. The SCP evaluator doesn’t care about your intent — it cares about where the request actually lands.