Enforcing Sanctions Compliance with AWS WAF: Geo-Blocking Russia, Belarus, and Contested Ukrainian Regions



TL;DR

We built a shared Firewall using AWS WAF Web ACL that sits in front of all our CloudFront distributions and blocks all traffic from Russia, Belarus and five Russian-occupied regions in Ukraine fulfilling the customers legal and compliance requirements. One config file controls everything — which countries get blocked, which apps are protected. If the WAF ever gets disabled, CloudFront’s native geo-restriction still has our back as a fallback. WAF ACL Rules are copy-paste ready below for AWS CLI or Console.

Why we had to deal with Geo-Blocking in the first place

If you’re running web apps that serve European customers, you can’t just ignore EU sanctions. Specifically the ones that came after Russia’s invasion of Ukraine — they require you to actually restrict access from sanctioned territories. For our customer it’s not a nice-to-have thing, it was a legal requirement.

We had four different apps running behind CloudFront and at some point we and the customer realized: we need to block this consistently across all of them. Doing it per-distribution would have been a nightmare to maintain. So we went with a shared Web Application Firewall (or WAF in short) approach instead.

The Setup: The one Firewall to block them all

The idea is simple — instead of configuring geo-restrictions on every single CloudFront distribution individually, we create one Web ACL in us-east-1 (thats where CloudFront-scoped WAFs have to live) and associate it with all distributions that need it.

What’s protected

Application What it does How it’s protected
Firmware Download Serves firmware files from S3 via CloudFront CloudFront + WAF
Static Web App Static web app on S3 for end users CloudFront + WAF
EC2 App Customer program running on EC2 CloudFront + WAF
Domain Redirects Various domain redirect services CloudFront + WAF

All four share the same Web ACL. You can toggle each one on or off with a single boolean — nothing fancy.

How the Rules on the WAF actually work

The Web ACL has four rules that get evaluated in priority order. We intentionally split them into count rules (for observability and collection) and block rules (for actual enforcement). Here’s the breakdown:

Rule 1: GeoMatchBlockedCountriesCollector (Priority 10) — Count Only

Action: COUNT
Match: Requests from RU, BY

This one doesn’t block anything. It just counts requests coming from Russia and Belarus so we get CloudWatch metrics and sampled requests. Basically we wanted to see whats happening before we started actually blocking things.

Rule 2: GeoMatchUkraineRegionsCollector (Priority 15) — Count Only

Action: COUNT
Match: Requests from UA

Same thing but for Ukrainian traffic. Since we’re not blocking all of Ukraine (only certain occupied regions), this helps us understand the overall volume coming from there.

Rule 3: BlockBlockedCountries (Priority 20) — Block

Action: BLOCK (HTTP 403)
Match: awswaf:clientip:geo:country:RU OR awswaf:clientip:geo:country:BY

This is where the actual blocking happens. If your IP resolves to Russia or Belarus — 403 Forbidden, nein, non, nada, niet.

Rule 4: BlockSpecificUkraineRegions (Priority 30) — Block

Action: BLOCK (HTTP 403)
Match: awswaf:clientip:geo:country:UA
  AND (awswaf:clientip:geo:region:UA-09    -- Luhansk
    OR awswaf:clientip:geo:region:UA-14    -- Donetsk
    OR awswaf:clientip:geo:region:UA-43    -- Crimea
    OR awswaf:clientip:geo:region:UA-23    -- Zaporizhzhia
    OR awswaf:clientip:geo:region:UA-65)   -- Kherson

This one is a bit more nuanced. We can’t just block all of Ukraine — there’s plenty of legitimate users there. So the rule uses an AND/OR compound: the request has to come from Ukraine AND from one of the five occupied regions. Everyone else in Ukraine is fine. The region codes are based on the ISO 3166-2:UA standard.

Blocked Ukrainian Regions Reference

ISO 3166-2 Code Region Status
UA-09 Luhansk Oblast Russian-occupied since 2014, fully occupied since 2022
UA-14 Donetsk Oblast Russian-occupied since 2014, largely occupied since 2022
UA-43 Autonomous Republic of Crimea Annexed by Russia since 2014
UA-23 Zaporizhzhia Oblast Partially occupied since 2022 (entire region blocked*)
UA-65 Kherson Oblast Partially occupied since 2022 (entire region blocked*)

Zaporizhzhia and Kherson are only partially under Russian occupation — parts of these oblasts are still under Ukrainian control. However, AWS WAF operates at the ISO 3166-2 region level and can’t distinguish between the occupied and non-occupied parts within a region. Since there’s no finer granularity available, we block the entire region as the safer compliance choice. Not blocking at all would risk serving sanctioned territories, which isn’t acceptable from a sanctions perspective.

These regions fall under EU sanctions due to Russian military occupation. Traffic from the rest of Ukraine is completely unaffected.

Configuration: It’s all in one file

The whole geo-blocking policy lives in a single TypeScript config:

// config/geo-blocking.ts
export const geoBlockingConfig: GeoBlockingConfig = {
  enable: true,
  blockedCountries: ['RU', 'BY'],
  blockedUaRegions: ['UA-09', 'UA-14', 'UA-43', 'UA-23', 'UA-65'],
  targets: {
    firmware: true,
    webApp: true,
    redirects: true,
    ec2App: true,
  },
};

Need to add another country (even though we all hope not!)? One line. Want to turn off geo-blocking for one app? Flip a boolean. Sanctions are over (and we have peace)? Turn the feature off. The CDK stack picks it up and does the rest.

The AWS WAF Rules (Copy-Paste Ready)

Here’s the full Web ACL as JSON — you can feed this directly into aws wafv2 create-web-acl (seen below) or import it through the Console. Just make sure you deploy it to us-east-1 (because that’s where CloudFront lives):

{
  "Name": "SanctionsGeoBlockingAcl",
  "Scope": "CLOUDFRONT",
  "Description": "Shared CloudFront Web ACL enforcing sanctions-based geo blocking - RU, BY, contested UA regions",
  "DefaultAction": { "Allow": {} },
  "VisibilityConfig": {
    "SampledRequestsEnabled": true,
    "CloudWatchMetricsEnabled": true,
    "MetricName": "SanctionsGeoBlockingAcl"
  },
  "Rules": [
    {
      "Name": "GeoMatchBlockedCountriesCollector",
      "Priority": 10,
      "Action": { "Count": {} },
      "Statement": {
        "GeoMatchStatement": {
          "CountryCodes": ["RU", "BY"]
        }
      },
      "VisibilityConfig": {
        "SampledRequestsEnabled": true,
        "CloudWatchMetricsEnabled": true,
        "MetricName": "GeoMatchBlockedCountriesCollector"
      }
    },
    {
      "Name": "GeoMatchUkraineRegionsCollector",
      "Priority": 15,
      "Action": { "Count": {} },
      "Statement": {
        "GeoMatchStatement": {
          "CountryCodes": ["UA"]
        }
      },
      "VisibilityConfig": {
        "SampledRequestsEnabled": true,
        "CloudWatchMetricsEnabled": true,
        "MetricName": "GeoMatchUkraineRegionsCollector"
      }
    },
    {
      "Name": "BlockBlockedCountries",
      "Priority": 20,
      "Action": { "Block": {} },
      "Statement": {
        "OrStatement": {
          "Statements": [
            {
              "LabelMatchStatement": {
                "Scope": "LABEL",
                "Key": "awswaf:clientip:geo:country:RU"
              }
            },
            {
              "LabelMatchStatement": {
                "Scope": "LABEL",
                "Key": "awswaf:clientip:geo:country:BY"
              }
            }
          ]
        }
      },
      "VisibilityConfig": {
        "SampledRequestsEnabled": true,
        "CloudWatchMetricsEnabled": true,
        "MetricName": "BlockBlockedCountries"
      }
    },
    {
      "Name": "BlockSpecificUkraineRegions",
      "Priority": 30,
      "Action": { "Block": {} },
      "Statement": {
        "AndStatement": {
          "Statements": [
            {
              "LabelMatchStatement": {
                "Scope": "LABEL",
                "Key": "awswaf:clientip:geo:country:UA"
              }
            },
            {
              "OrStatement": {
                "Statements": [
                  {
                    "LabelMatchStatement": {
                      "Scope": "LABEL",
                      "Key": "awswaf:clientip:geo:region:UA-09"
                    }
                  },
                  {
                    "LabelMatchStatement": {
                      "Scope": "LABEL",
                      "Key": "awswaf:clientip:geo:region:UA-14"
                    }
                  },
                  {
                    "LabelMatchStatement": {
                      "Scope": "LABEL",
                      "Key": "awswaf:clientip:geo:region:UA-43"
                    }
                  },
                  {
                    "LabelMatchStatement": {
                      "Scope": "LABEL",
                      "Key": "awswaf:clientip:geo:region:UA-23"
                    }
                  },
                  {
                    "LabelMatchStatement": {
                      "Scope": "LABEL",
                      "Key": "awswaf:clientip:geo:region:UA-65"
                    }
                  }
                ]
              }
            }
          ]
        }
      },
      "VisibilityConfig": {
        "SampledRequestsEnabled": true,
        "CloudWatchMetricsEnabled": true,
        "MetricName": "BlockSpecificUkraineRegions"
      }
    }
  ]
}

Create the Web ACL via CLI (must be us-east-1 for CloudFront, and above JSON saved as sanctions-geo-blocking-acl.json):

aws wafv2 create-web-acl \
  --region us-east-1 \
  --cli-input-json file://sanctions-geo-blocking-acl.json

Then associate it with your CloudFront distribution. One thing that tripped us up: associate-web-acl does not work for CloudFront. You have to update the distribution config directly:

# Get current distribution config
aws cloudfront get-distribution-config \
  --id <DISTRIBUTION_ID> > dist-config.json

# Edit dist-config.json: set "WebACLId" to your Web ACL ARN you will get from above command
# Remove the "ETag" from the body and pass it as --if-match instead

aws cloudfront update-distribution \
  --id <DISTRIBUTION_ID> \
  --if-match <ETAG> \
  --distribution-config file://dist-config.json

Monitoring — and the numbers to prove it works

All WAF rules have CloudWatch metrics and sampled request logging enabled. And because we know that “trust me, it works” doesn’t cut it in a compliance and legal context, here are the actual numbers from production.

Real-world traffic data (March 2026)

Metric Count
Total requests processed ~46 million
Blocked from Russia (RU) ~1,230,000
Blocked from Belarus (BY) ~74,000
Blocked from occupied UA regions ~11,700
Legitimate Ukraine traffic (allowed) ~497,000
Total blocked ~1,320,000
Daily average RU blocks ~38,000

A few things stand out here:

  • The WAF is blocking roughly 38,000 requests per day from Russia alone — mostly firmware update checks from the customer’s devices still operating there. These are automated Wget calls hitting firmwaredownload.example.eu.
  • Belarus generates significantly less traffic (~6% of Russia’s volume), but its still being caught consistently.
  • The Ukraine region blocking works exactly as intended: ~497,000 legitimate Ukrainian requests passed through, while ~11,700 from occupied regions were blocked. Thats a 97.7% pass rate for Ukraine overall — meaning we’re not over-blocking.
  • Looking at the occupied region breakdown from sampled requests: Zaporizhzhia (UA-23) generates the most traffic, followed by Luhansk (UA-09) and Crimea (UA-43).

What the sampled requests actually look like

The WAF captures sampled requests with full metadata. Here’s a real blocked request from the logs:

Client IP:  95.xx.xx.xx
Country:    RU
Region:     RU-KGD (Kaliningrad)
Host:       firmwaredownload.example.eu
URI:        /firmwaredownload/firmware/device_model/update_check.txt
Method:     GET
User-Agent: Wget
Action:     BLOCK
Labels:     awswaf:clientip:geo:country:RU, awswaf:clientip:geo:region:RU-KGD

And a blocked request from an occupied Ukrainian region:

Client IP:  91.xx.xx.xx
Country:    UA
Region:     UA-14 (Donetsk)
Host:       firmwaredownload.example.eu
URI:        /firmwaredownload/firmware/device_model/config.json
Method:     GET
User-Agent: Wget
Action:     BLOCK
Labels:     awswaf:clientip:geo:country:UA, awswaf:clientip:geo:region:UA-14

Both blocked with a 403, both logged with full geo-labels. Exactly what we wanted.

The count-only rules (Priority 10 and 15) turned out to be super useful — they give you traffic data from sanctioned regions without interfering with the block rules. Really helpful for making decisions about whether to adjust the policy.

What if the WAF goes down?

We thought about this. If the WAF gets disabled for whatever reason (troubleshooting, cost reasons, AWS region outage, whatever), each CloudFront distribution falls back to CloudFront’s built-in much simpler geo-restriction:

geoRestriction: cloudfront.GeoRestriction.denylist('RU', 'BY')

It’s less granular — you lose the region-level blocking for Ukraine and you dont get the metrics — but sanctions compliance stays intact. Good enough as an additional safety net. So we take it!

What we learned along the way

  1. Centralize your policy. Having one shared Web ACL for all services saved us from configuration drift. One config file, one source of truth — no chance of one app being out of sync.
  2. Count before you block. The observability-only rules were a lifesaver during rollout. We could see exactly which requests would get blocked before we actually flipped the switch. Highly recommend this approach.
  3. You need labels for region-level blocking. AWS WAF can’t match region codes in a GeoMatchStatement directly — you have to use a Count rule to generate geo-labels first, then match on them with LabelMatchStatement. This is the only way to do sub-country blocking. We used the same label-based pattern for country blocking too, just to keep things consistent.
  4. Plan for the WAF being off. The CloudFront geo-restriction fallback was an afterthought honestly, but turned out to be really important. Defense in depth applies to compliance too, not just security.
  5. Region-level blocking is trickier than you’d think. Blocking whole countries is straightforward. Blocking specific regions within a country needs compound AND/OR statements and you absolutely need to test with the right ISO 3166-2 codes. We had a typo in one of the region codes during development that took us a while to find.

Wrapping up

Even in the 21st century wars just aren’t going away it seems. Therefore sanctions compliance isn’t going away as well, and if you’re hosting apps on AWS that is serving European customers, you need to deal with it. The shared WAF approach worked really well for us — adding or removing a country is a one-line config change, and everything flows. Nothing magical, just solid infrastructure as code doing what it does best.

If you need developers and consulting to support you on your next AWS project, don’t hesitate to contact us, tecRacer.

— Nadim

See also


Built with AWS CDK in TypeScript (but not a requirement!). AI was used to help with english language and formulations of this blog and to help calculate the real-world traffic data.

Title Photo by Eric Prouzet on Unsplash

Similar Posts You Might Enjoy

Building a deduplication machine

A few months ago we supported a customer with a data migration project and one of the most important aspect of the migration was to make sure data duplicates were not reproduced in the new data layer but instead copied only once and to have duplicates of a file listed as references in the new data layer. To solve the uniqueness challenge we built a deduplication machine mainly using Amazon S3 and DynamoDB. - by Franck Awounang Nekdem

Building Type-Safe Serverless Applications with Prisma, Lambda, and Private RDS

Running Prisma ORM in AWS Lambda with RDS in a private VPC is a good pattern for building type-safe serverless applications. However, documentation on this setup is sparse, and developers typically encounter two critical challenges that aren’t immediately obvious: correctly bundling Prisma’s binary dependencies for Lambda’s environment and automating database migrations through CDK without external CI/CD orchestration. - by Stefan Kröker

Working with AWS European Sovereign Cloud (ESC): Terraform, IaC, and what's different

If you manage AWS infrastructure with code, the European Sovereign Cloud adds a new partition to think about. Different endpoints, separate IAM, its own console. This guide covers what works out of the box, what needs changes, and the patterns that help when you deploy across both ESC and commercial AWS. - by Timur Galeev