I'm implementing a zero-trust posture for a new REST API, and the staging environment is on a public Application Load Balancer. The requirement is straightforward: only allow traffic from our known staging clients and developer tooling, while blocking all other public internet traffic. AWS WAF is the gatekeeper.
My initial thought is to use a combination of IP set rules and perhaps a geo-block rule as a final catch-all. Here's the core of my planned rule group logic, in order of evaluation:
1. **Allow list for staging VPC & CI/CD pipelines:** An IP set rule allowing the CIDR blocks of our staging VPC and the static IPs of our build servers.
2. **Allow list for developer VPN:** A second IP set rule allowing the IP range of our corporate VPN.
3. **Explicit deny for all other traffic:** A `Geo match` rule to block all requests from *any* country code. This acts as a final, explicit block.
```json
{
"Name": "staging-allowlist",
"Priority": 1,
"Statement": {
"IPSetReferenceStatement": {
"ARN": "arn:aws:wafv2:.../ipset/staging-ips"
}
},
"Action": {
"Allow": {}
},
"VisibilityConfig": {
"SampledRequestsEnabled": true,
"CloudWatchMetricsEnabled": true,
"MetricName": "staging-allowlist"
}
},
{
"Name": "block-all-other-countries",
"Priority": 10,
"Statement": {
"GeoMatchStatement": {
"CountryCodes": ["US", "CA", "GB", "DE", "JP", "CN", "RU", "KR", "IN", "BR", "AU", "FR", "ES", "IT", "NL", "SE", "NO", "DK", "FI", "PL", "CZ", "HU", "RO", "BG", "GR", "PT", "BE", "CH", "AT", "IE", "MX", "AR", "CL", "CO", "PE", "VE", "NZ", "SG", "MY", "ID", "PH", "TH", "VN", "ZA", "NG", "EG", "SA", "AE", "IL", "TR", "UA", "RS", "HR", "SI", "SK", "LT", "LV", "EE", "IS", "LU", "CY", "MT", "AD", "MC", "SM", "VA", "LI", "AL", "BA", "ME", "MK", "XK", "BY", "MD", "AM", "AZ", "GE", "KZ", "UZ", "TM", "KG", "TJ", "MN", "AF", "PK", "BD", "LK", "NP", "BT", "MV", "MM", "LA", "KH", "TL", "BN", "PG", "FJ", "SB", "VU", "NC", "PF", "WF", "GU", "MH", "FM", "MP", "PW", "CK", "NU", "TK", "TO", "WS", "KI", "TV", "NR", "NF", "AQ", "BV", "TF", "HM", "GS", "CC", "CX", "UM", "VI", "VG", "AI", "AW", "BM", "KY", "FK", "GI", "MS", "PN", "SH", "TC", "AD", "AE", "AF", "AG", "AI", "AL", "AM", "AO", "AQ", "AR", "AS", "AT", "AU", "AW", "AX", "AZ", "BA", "BB", "BD", "BE", "BF", "BG", "BH", "BI", "BJ", "BL", "BM", "BN", "BO", "BQ", "BR", "BS", "BT", "BV", "BW", "BY", "BZ", "CA", "CC", "CD", "CF", "CG", "CH", "CI", "CK", "CL", "CM", "CN", "CO", "CR", "CU", "CV", "CW", "CX", "CY", "CZ", "DE", "DJ", "DK", "DM", "DO", "DZ", "EC", "EE", "EG", "EH", "ER", "ES", "ET", "FI", "FJ", "FK", "FM", "FO", "FR", "GA", "GB", "GD", "GE", "GF", "GG", "GH", "GI", "GL", "GM", "GN", "GP", "GQ", "GR", "GS", "GT", "GU", "GW", "GY", "HK", "HM", "HN", "HR", "HT", "HU", "ID", "IE", "IL", "IM", "IN", "IO", "IQ", "IR", "IS", "IT", "JE", "JM", "JO", "JP", "KE", "KG", "KH", "KI", "KM", "KN", "KP", "KR", "KW", "KY", "KZ", "LA", "LB", "LC", "LI", "LK", "LR", "LS", "LT", "LU", "LV", "LY", "MA", "MC", "MD", "ME", "MF", "MG", "MH", "MK", "ML", "MM", "MN", "MO", "MP", "MQ", "MR", "MS", "MT", "MU", "MV", "MW", "MX", "MY", "MZ", "NA", "NC", "NE", "NF", "NG", "NI", "NL", "NO", "NP", "NR", "NU", "NZ", "OM", "PA", "PE", "PF", "PG", "PH", "PK", "PL", "PM", "PN", "PR", "PS", "PT", "PW", "PY", "QA", "RE", "RO", "RS", "RU", "RW", "SA", "SB", "SC", "SD", "SE", "SG", "SH", "SI", "SJ", "SK", "SL", "SM", "SN", "SO", "SR", "SS", "ST", "SV", "SX", "SY", "SZ", "TC", "TD", "TF", "TG", "TH", "TJ", "TK", "TL", "TM", "TN", "TO", "TR", "TT", "TV", "TW", "TZ", "UA", "UG", "UM", "US", "UY", "UZ", "VA", "VC", "VE", "VG", "VI", "VN", "VU", "WF", "WS", "YE", "YT", "ZA", "ZM", "ZW"]
}
},
"Action": {
"Block": {}
},
"VisibilityConfig": {
"SampledRequestsEnabled": true,
"CloudWatchMetricsEnabled": true,
"MetricName": "geo-block-all"
}
}
```
Is this the most efficient and maintainable approach? The geo-block rule feels like a blunt instrument, but it's the only way I've found to create a default-deny after specific allows. I'm also considering the cost implication of the managed rule group for the IP sets. Has anyone benchmarked the latency impact of a large, ordered rule group like this versus a simpler two-rule setup (Allow IP set -> Block all)?
benchmark or bust
benchmark or bust
Your JSON snippet got cut off, but I think I get the idea. Using a Geo match rule to block *all* countries as a final catch-all is clever. I hadn't thought of using it that way, I always figured geo rules were for allowing specific places.
One quick thing: make sure your IP set for the staging VPC includes the CIDR correctly. I messed that up once and locked myself out. Had to use the console to fix it, which was not fun.
How are you handling the IPs for the CI/CD pipelines if they aren't static? Or are they on something like a fixed NAT gateway?
You're right, that geo-block catch-all trick is super handy. I've used it for exactly this staging lockdown scenario.
> How are you handling the IPs for the CI/CD pipelines if they aren't static?
Good catch. We ran into that with GitHub Actions. The solution was to use their API to fetch their IP ranges dynamically. We set up a small Lambda that runs on a schedule, fetches the latest meta JSON, and updates the WAF IP set automatically. It's a bit more work, but you never get a surprise block during a deploy.
Also, +1 on the VPC CIDR warning. Triple-checking those entries saved me a weekend once 😅
Data doesn't lie, but dashboards sometimes do.