Step 22 of 25
Edge protection: WAF & DDoS
Deploy and tune a WAF without breaking users, virtual-patch while a fix ships, and absorb L3 to L7 DDoS attacks at the edge.
Your public hostname receives attack traffic all day: scanners probing for injection, bots stuffing stolen passwords into the login form, and occasionally a flood big enough to take you offline. The edge (a CDN, load balancer or proxy in front of your application) is where you filter that traffic before it costs you CPU, money or data. This lesson covers what a web application firewall (WAF) inspects and what it can't fix, managed rules and tuning, virtual patching, rate and bot rules, the three kinds of DDoS attack, keeping attackers from bypassing the edge, and a runbook for an attack in progress.
What a WAF inspects, and what it can't fix
A WAF is a layer 7 filter: it parses each HTTP request and evaluates rules against its parts before allowing, blocking, challenging or logging it.
| Part of the request | Example rules |
|---|---|
| Method, path, query string | Path traversal (../), SQL fragments in ?q=, probes for /.env |
| Headers and cookies | Scanner user agents, oversized headers, injection in cookie values |
| Body (form, JSON, XML, multipart) | SQL injection or XSS payloads, dangerous uploads |
| Connection metadata | Client IP, country, ASN, IP reputation, TLS fingerprint (JA3/JA4) |
| Request rate | Too many requests from one IP, session or API key |
It can run at the CDN edge (Cloudflare, CloudFront with AWS WAF, Azure Front Door), on a cloud load balancer (AWS WAF on an ALB, Google Cloud Armor), or in your own proxy with ModSecurity or Coraza (OWASP's Go engine for Caddy, Envoy and Traefik). The concepts are the same everywhere.
The figure shows the layout this lesson builds towards. Watch where each request stops: the injection attempt never reaches the origin, and neither does the attempt to go around the edge.
Now the limits. A WAF sees requests, not intent, and knows nothing about your authorisation model or business rules:
| Vulnerability | Can a WAF help? |
|---|---|
| SQL injection, XSS, traversal with common payloads | Partly: known patterns, not every encoding or nested JSON trick |
| A known CVE you can't patch today | Yes, temporarily, with a virtual patch |
| IDOR and broken access control | No: GET /invoices/1043 from the wrong user looks legitimate |
| Business logic (negative quantities, coupon reuse) | No: the requests are well formed |
| Weak password storage, missing MFA | No |
| Credential stuffing and scraping | Partly, through rate and bot rules |
A WAF is a compensating control. It buys time and cuts noise, but the fix for every vulnerability in the secure coding lesson is still in the code. If anyone argues a parameterised query is unnecessary "because the WAF blocks SQL injection", the WAF has made you less secure.
Managed rules: CRS, paranoia levels and anomaly scoring
You start from a managed rule set and tune it. The most important open one is the OWASP Core Rule Set (CRS), used by ModSecurity and Coraza and the basis of Azure WAF's Default Rule Set, Cloud Armor's preconfigured rules and Cloudflare's OWASP ruleset. AWS WAF has its own managed groups, such as AWSManagedRulesCommonRuleSet and AWSManagedRulesSQLiRuleSet. CRS rule IDs encode the attack: 930 file inclusion, 932 command execution, 941 XSS, 942 SQL injection.
Paranoia levels (PL1 to PL4) set strictness. PL1, the default, has very few false positives. PL2 catches more evasions and suits sites with sensitive data. PL3 and PL4 need substantial tuning. Higher levels catch more attacks and block more real users.
Anomaly scoring means CRS doesn't block on the first match. Each matching rule adds points by severity (critical 5, error 4, warning 3, notice 2), and rule 949110 blocks once the total reaches the inbound threshold, 5 by default. One critical match blocks; so do two warnings. Both settings live in crs-setup.conf (CRS 4 names):
SecAction "id:900000,phase:1,pass,nolog,setvar:tx.blocking_paranoia_level=1"
SecAction "id:900110,phase:1,pass,nolog,\
setvar:tx.inbound_anomaly_score_threshold=5,\
setvar:tx.outbound_anomaly_score_threshold=4"CRS 4 also has a detection paranoia level: set it above the blocking level and the extra rules log without blocking, so you can measure what PL2 would cost before enabling it.
Tuning false positives and virtual patching
Never switch a WAF straight to blocking on a busy application. You'll block customers, someone will turn the WAF off, and it won't come back. Roll out in stages:
- Detection only:
SecRuleEngine DetectionOnlyin ModSecurity and Coraza, or Count mode for AWS WAF rule groups. Everything is logged, nothing blocked. - Collect a business cycle of real traffic, including month-end jobs and admin workflows.
- Fix each false positive with the narrowest exclusion that removes it.
- Block (
SecRuleEngine On), optionally with a high threshold such as 100 at first, stepping down to 5.
Worked example: SQL in support tickets
Customers paste error output into POST /api/tickets. After a week in detection mode, the logs show this (abridged) on dozens of genuine tickets:
ModSecurity: Warning. detected SQLi using libinjection with fingerprint 's&sos' [id "942100"]
[msg "SQL Injection Attack Detected via libinjection"]
[data "Matched Data: s&sos found within ARGS:message: ERROR: column \"total\" does not exist ... SELECT id, total FROM orders WHERE"]
[severity "CRITICAL"] [uri "/api/tickets"]
ModSecurity: Warning. [id "949110"] [msg "Inbound Anomaly Score Exceeded (Total Score: 5)"] [uri "/api/tickets"]The rule is right that the field contains SQL; it just isn't an attack. Your options, widest first:
| Option | Effect | Verdict |
|---|---|---|
| Disable rule 942100 | No libinjection SQL detection anywhere | Far too wide |
SecRuleUpdateTargetById 942100 "!ARGS:message" | Ignores every message parameter on every path | Too wide |
ctl:ruleRemoveById=942100 on /api/tickets | Ignores the rule for every field on that path | Still too wide |
ctl:ruleRemoveTargetById=942100;ARGS:message on /api/tickets | Ignores one field on one path | Right |
The narrow version is a runtime exclusion placed before CRS (in REQUEST-900-EXCLUSION-RULES-BEFORE-CRS.conf):
# Support tickets contain pasted SQL error output. The handler stores it with a
# parameterised INSERT and renders it escaped. Ticket SEC-412, reviewed 2026-10-02.
SecRule REQUEST_FILENAME "@streq /api/tickets" \
"id:10010,\
phase:1,\
pass,\
nolog,\
ctl:ruleRemoveTargetById=942100;ARGS:message"REQUEST_FILENAME is the path without the query string, @streq requires an exact match, and ctl changes CRS's behaviour for this request only. IDs 1 to 99,999 are reserved for your own rules; CRS uses 900,000 and up. If several SQL rules fire on the field, ctl:ruleRemoveTargetByTag=attack-sqli;ARGS:message covers the whole 942 family.
The comment matters. Every exclusion is a hole you chose to open; it's safe here only because the code behind it is correct (parameterised and output-encoded). In AWS WAF, the equivalent is a scope-down statement on the managed rule group, or setting one rule to Count and re-blocking its label everywhere except that path.
Virtual patching
A virtual patch is a temporary rule that blocks exploitation of one known vulnerability while the real fix ships. Suppose GET /api/export?format= passes format into a shell command (the command injection from the secure coding lesson). The release takes days; this takes minutes:
# VIRTUAL PATCH for SEC-431 (command injection in export). Remove when v2.14.1 is live.
SecRule REQUEST_FILENAME "@streq /api/export" \
"id:10100,\
phase:2,\
deny,\
status:403,\
log,\
msg:'Virtual patch SEC-431: export format allowlist',\
chain"
SecRule ARGS:format "!@rx ^(?:csv|json)$" "t:none"With chain, both rules must match: the path is /api/export and some format argument isn't exactly csv or json. An allowlist of valid values beats listing dangerous characters. Test it with format=csv (allowed), format=csv;id (blocked), and encoded and repeated variants. Give every virtual patch an owner and an expiry in the ticket, or it becomes a permanent rule nobody understands.
Rate limits, bots and IP reputation
Rate-based rules count requests per key over a window and act above a limit. The api-gateways lesson covers rate-limiting algorithms and per-client quotas; at the edge, rate rules are the blunt first line against floods and password guessing. This AWS WAF rule (as written in the console's JSON editor) blocks any IP sending more than 100 login requests in 5 minutes:
{
"Name": "login-rate-limit",
"Priority": 10,
"Statement": {
"RateBasedStatement": {
"Limit": 100,
"EvaluationWindowSec": 300,
"AggregateKeyType": "IP",
"ScopeDownStatement": {
"ByteMatchStatement": {
"SearchString": "/login",
"FieldToMatch": { "UriPath": {} },
"TextTransformations": [{ "Priority": 0, "Type": "LOWERCASE" }],
"PositionalConstraint": "STARTS_WITH"
}
}
}
},
"Action": { "Block": {} },
"VisibilityConfig": {
"SampledRequestsEnabled": true,
"CloudWatchMetricsEnabled": true,
"MetricName": "login-rate-limit"
}
}The scope-down statement counts only matching requests, so heavy browsing doesn't trip the login limit. EvaluationWindowSec accepts 60, 120, 300 or 600. Besides IP, AggregateKeyType can be FORWARDED_IP, CONSTANT (one global counter) or CUSTOM_KEYS (a header, cookie or query argument, so you can limit per session or API key). Check your peak legitimate rate first: an office NAT or mobile carrier can put thousands of users behind one IP.
Bots are harder. Credential stuffing, scraping and card testing run through residential proxies, so each IP sends only a few requests. Bot management (AWS WAF Bot Control, Cloudflare Bot Management, reCAPTCHA Enterprise with Cloud Armor, Azure's Bot Manager rules) combines signals: verified good crawlers, TLS and header fingerprints (python-requests doesn't look like Chrome whatever its User-Agent says), JavaScript or proof-of-work challenges a real browser solves invisibly, and behaviour such as one client trying thousands of usernames. Prefer challenge to block when unsure: a real person passes and carries on.
Geo and IP reputation rules block or challenge by country, ASN (hosting providers hitting a consumer login page) or reputation lists such as AWSManagedRulesAmazonIpReputationList. They cut noise cheaply but are blunt: attackers rent servers anywhere, and customers travel and use VPNs. Use them to reduce volume and as emergency levers, not as a security boundary.
DDoS: three layers of attack
A distributed denial-of-service (DDoS) attack uses thousands or millions of sources, usually a botnet, to make you unavailable, so no single address can be blocked.
| Type | Exhausts | Examples | Measured in |
|---|---|---|---|
| Volumetric (L3/L4) | Network bandwidth | UDP floods; reflection and amplification via DNS, NTP or memcached, where a small spoofed request triggers a large response aimed at you | Bits per second, now multiple Tbps |
| Protocol (L3/L4) | Connection state in servers, firewalls, load balancers | SYN floods, ACK floods, fragmented packets | Packets per second |
| Application (L7) | CPU, database, threads | HTTP floods on search or login; random query strings that bust the cache; Slowloris; HTTP/2 Rapid Reset (CVE-2023-44487) | Requests per second |
You can't stop the first two on your own servers. If your origin has a 10 Gbps uplink and 400 Gbps arrives, the link is full before your firewall sees a packet. Defence has to happen on a network bigger than the attack. Large CDNs and cloud edges announce the same IPs from hundreds of locations (anycast), so internet routing splits the attack across all of them; each location scrubs spoofed UDP and SYN floods before forwarding anything; and every cached response is a request the origin never handles.
L7 floods look like real requests, so they need the L7 tools: rate rules, challenges, signatures and caching. The major providers bundle these:
| Provider | Network DDoS (L3/4) | WAF and L7 | Origin protection |
|---|---|---|---|
| AWS | Shield Standard, free and automatic; Shield Advanced adds L7 auto-mitigation, a response team and cost protection | AWS WAF on CloudFront, ALB, API Gateway; Bot Control | CloudFront origin-facing prefix list; VPC origins; secret header |
| Cloudflare | Unmetered mitigation on all plans | Managed and OWASP rulesets; rate limiting; Bot Management | Authenticated Origin Pulls (mTLS); Cloudflare Tunnel |
| Azure | Built-in infrastructure protection; DDoS Protection tiers for public IPs | Azure WAF on Front Door or Application Gateway; Bot Manager rules | AzureFrontDoor.Backend service tag plus X-Azure-FDID check; Private Link |
| Google Cloud | Always on for external load balancers; Cloud Armor Enterprise adds response support and bill protection | Cloud Armor with CRS-based rules, throttle and rate_based_ban, Adaptive Protection | Backends with no public IP, firewall allowing only Google front ends |
Autoscaling cuts both ways. Scaling out under an L7 flood may keep you up, but you pay for every replica serving bots; the autoscaling lesson covers this "denial of wallet" and the limits that bound it. Filter first, scale second: traffic blocked at the CDN is cheap, traffic absorbed with compute isn't.
Protecting the origin
None of this helps if attackers can reach the origin directly. Origin IPs leak through old DNS records, email headers and historical DNS services. Close the gap in two layers.
1. Accept connections only from the edge. On AWS, reference CloudFront's managed prefix list in the load balancer's security group, so it stays current without scripts:
data "aws_ec2_managed_prefix_list" "cloudfront" {
name = "com.amazonaws.global.cloudfront.origin-facing"
}
resource "aws_vpc_security_group_ingress_rule" "alb_https_from_cloudfront" {
security_group_id = aws_security_group.alb.id
description = "HTTPS from CloudFront only"
prefix_list_id = data.aws_ec2_managed_prefix_list.cloudfront.id
from_port = 443
to_port = 443
ip_protocol = "tcp"
}Better still, give the origin no public address: CloudFront VPC origins, Front Door Private Link and Cloudflare Tunnel all connect the edge to a private origin.
2. Prove each request came through your configuration. CDN ranges are shared: an attacker can create their own distribution or zone pointing at your origin and arrive from the very ranges you allowed, without your WAF rules. So also require something only your configuration sends: a client certificate (Cloudflare Authenticated Origin Pulls with a per-zone certificate, not the global one), an origin secret header such as X-Origin-Verify checked by the load balancer and rotated from a secrets manager, or a provider ID such as Front Door's X-Azure-FDID.
Runbook: an ongoing L7 attack
Write this in advance, keep it with your incident runbooks, and follow the incident lifecycle: declare early, assign an incident commander, keep a timeline.
- Confirm it's an attack. Requests per second, origin latency and errors up; cache hit ratio down. Rule out a flash crowd (a launch, a marketing email) before blocking anyone.
- Declare and staff. Page the edge configuration owner, and open a case with your provider's DDoS response team if your plan includes one.
- Characterise the traffic. Group edge logs by path, ASN, country, user agent, TLS fingerprint and header patterns. Attacks usually hammer one expensive path with a shared fingerprint.
- Mitigate in escalating steps, watching each change's effect: cache more (longer lifetimes, ignore unknown query strings); rate-limit the targeted path; challenge the matching fingerprint or ASN; block a precise signature; as a last resort, challenge the whole site.
- Check the origin. Any traffic in origin logs that didn't come through the edge means a protection gap. Cap autoscaling.
- Communicate on a fixed schedule, and expect adaptation: attackers change paths and tools in waves.
- Recover and learn. Remove temporary rules deliberately, keep the attack signatures as detections, and ask in the postmortem why that endpoint was expensive enough to target.
Practice
Acme runs shop.example.com on CloudFront with AWS WAF in front of an ALB, and an internal portal behind Coraza with CRS 4. Three findings:
- On the portal, rule 941100 (XSS via libinjection) blocks product managers saving HTML in the
descriptionfield ofPOST /admin/products. Descriptions are sanitised server-side with an allowlist HTML sanitiser. - On the shop, a credential-stuffing run sent 40,000 attempts to
POST /account/loginfrom 9,000 IPs overnight. - A penetration tester reached the ALB directly at its public DNS name and bypassed the WAF.
For each, write the rule or configuration you'd add and state the residual risk.
Show solution
1. A scoped exclusion, one rule, one field, one path, placed before CRS:
# Product descriptions are rich HTML, sanitised server-side with an allowlist
# sanitiser before storage. Ticket SEC-455, approved by appsec 2026-10-09.
SecRule REQUEST_FILENAME "@streq /admin/products" \
"id:10020,\
phase:1,\
pass,\
nolog,\
ctl:ruleRemoveTargetById=941100;ARGS:description"If other XSS rules fire too, use ctl:ruleRemoveTargetByTag=attack-xss;ARGS:description. Residual risk: the sanitiser is now the only control on that field, so it needs XSS test cases, and the admin path needs strong authentication.
2. Layered bot defence. 40,000 attempts from 9,000 IPs is about 4 or 5 per IP, so an IP rate limit alone won't stop it. Add one as a floor: the lesson's rule with "Limit": 20, "SearchString": "/account/login" and "PositionalConstraint": "EXACTLY". Then add what actually targets this attack: AWS WAF's account takeover prevention rule group (AWSManagedRulesATPRuleSet), which inspects login requests and checks credentials against a stolen-credential database; Bot Control or a challenge for clients failing fingerprint checks; the IP reputation group; and per-account throttling in the application. Residual risk: stuffing succeeds wherever passwords are reused, so MFA is the real fix.
3. Origin protection. Restrict the ALB security group to the CloudFront origin-facing prefix list (the lesson's Terraform) and remove any 0.0.0.0/0 rule. Because every CloudFront distribution uses those ranges, also add a custom origin header such as X-Origin-Verify with a random value from Secrets Manager; make the ALB listener's default action a fixed 403 response, with a rule forwarding only requests that carry the correct header. Better still, move the ALB to private subnets behind a CloudFront VPC origin. Retest: a direct request should time out, and one through another distribution should get 403.
Key takeaways
- A WAF filters layer 7 requests and stops common attack patterns, but can't see access control or business logic flaws: it's a compensating control, never a substitute for secure code.
- Start from a managed rule set such as the OWASP CRS; paranoia levels set strictness, and anomaly scoring blocks when the matched rules' points reach a threshold.
- Roll out in detection or Count mode, and fix false positives with the narrowest exclusion (one rule, one field, one path) and a recorded reason.
- Virtual patches block a known vulnerability while the fix ships; use allowlists, test them, and give them an owner and an expiry.
- Volumetric and protocol DDoS must be absorbed upstream by anycast networks bigger than the attack; L7 floods need rate rules, challenges, signatures and caching. Filter before you scale.
- Lock the origin to your edge: allow only the CDN's ranges and require a secret, certificate or provider ID that only your configuration sends.
Check yourself
Quiz
0/4 correct · 4 left
1.A WAF with the OWASP CRS is in blocking mode in front of an API. Which vulnerability is it least able to stop?
2.With CRS's default inbound anomaly threshold of 5, a request matches two rules of WARNING severity (3 points each). What happens in blocking mode?
3.Your origin's security group only allows CloudFront's origin-facing IP ranges. Why is that not sufficient origin protection on its own?
4.A 600 Gbps UDP reflection attack targets a service hosted on servers with a 10 Gbps uplink. Which defence actually helps?
Finished reading?
Mark it complete to fill in your roadmap.