Turning on AWS WAF in front of a web application is one CDK construct and a checkbox's worth of managed rule groups. It's also, in our experience, guaranteed to block something real within the first week: a form submission, a webhook, an image upload, a rich-text field. The rules aren't wrong. They're generic, and your application isn't.
This is the WAF configuration we run in front of a public Next.js application on App Runner, the five managed rules that blocked legitimate traffic, the reason each one fired, and the override that fixed it without turning the rule off for everything.
The setup
CloudFront in front, App Runner behind, WAF attached to the CloudFront distribution so it sees every request before the origin does. Three managed rule groups plus a rate limit:
const acl = new wafv2.CfnWebACL(this, 'WebAcl', {
scope: 'CLOUDFRONT',
defaultAction: { allow: {} },
visibilityConfig: { sampledRequestsEnabled: true, cloudWatchMetricsEnabled: true, metricName: 'web-acl' },
rules: [
managedGroup('AWSManagedRulesAmazonIpReputationList', 10),
managedGroup('AWSManagedRulesKnownBadInputsRuleSet', 20),
managedGroup('AWSManagedRulesCommonRuleSet', 30, /* overrides below */),
{
name: 'rate-limit', priority: 40,
statement: { rateBasedStatement: { limit: 2000, aggregateKeyType: 'IP' } },
action: { block: {} },
visibilityConfig: { sampledRequestsEnabled: true, cloudWatchMetricsEnabled: true, metricName: 'rate-limit' },
},
],
});
Cost: $5 for the web ACL, $1 per rule group, $0.60 per million requests. About $16 a month for us, which is the cheapest security control on the bill after the data protection policy.
Count first, block later
We didn't start in block mode. Every managed group ran with its action overridden to COUNT for two weeks, with the sampled-request log going to S3 and a CloudWatch dashboard showing counts per rule. That period is what produced the list below. Without it, we'd have found each of these by a user reporting a broken form, which is how most teams find them.
The dashboard query is simple: count of matched requests per terminatingRuleId and ruleGroupList[].ruleId, filtered to the ones with action COUNT. Anything with real volume that isn't obviously an attack gets examined by hand: what path, what body, what client.
The five rules that blocked real traffic
All five are in AWSManagedRulesCommonRuleSet, which is the group everyone enables and the one with the most opinions about what a request should look like.
1. SizeRestrictions_BODY: any request body over 8 KB. The rule exists because oversized bodies are a common attack vector and because WAF only inspects the first 8 KB (16 KB on CloudFront in newer configurations) anyway. Our profile-edit form posts a JSON payload with an avatar as a base64 data URL. That's 40 KB. Blocked. Every profile save from a user with a photo, gone, for the two weeks it would have taken someone to report it.
Fix: the rule stays in block mode globally and is overridden to COUNT for the two routes that legitimately accept large bodies, using a scope-down statement on the URI path. Better fix, which we also did: uploads go to S3 through a presigned URL and the form posts a key, not the bytes, so the body is 2 KB again.
2. CrossSiteScripting_BODY: HTML in a request body. A rich-text editor for product descriptions submits sanitised HTML. To the XSS rule, <p> and <a href> in a body are an attack. Blocked on save.
Fix: override to COUNT for the two admin routes that accept HTML, and keep it blocking everywhere else. The application already sanitises with an allow-list on the server; the WAF rule was duplicating that check with a blunter instrument.
3. GenericRFI_BODY: a URL in a request body. Remote-file-inclusion detection fires on http:// or https:// strings in the body. A webhook from our payment provider includes the URL of the receipt. A user pasting a link into a support form includes a URL. Both blocked.
Fix: COUNT on /api/webhooks/* and the support form route. This is the rule with the highest false-positive rate on any app that accepts free text, and the first one to look at if forms are failing mysteriously.
4. NoUserAgent_HEADER: request without a User-Agent. Browsers always send one. Some webhook senders don't, and one of ours didn't. Every payment confirmation from that provider was blocked, which we found within a day because it broke the checkout, not within two weeks.
Fix: COUNT scoped to /api/webhooks/*. Webhook routes are authenticated by signature anyway; the User-Agent check adds nothing there.
5. EC2MetaDataSSRF_BODY: the string 169.254.169.254 in a body. A field where an operator pastes log excerpts for a support ticket contained an instance metadata URL from a debugging session. Blocked. Once. We include it because it's a good example of a rule being right about what the string is and wrong about whether it matters.
Fix: none. One block in six months on an internal route is fine. Not every false positive needs an override; the override is a permanent hole and the block was a one-off.
How an override is written
The important property: the rule is not disabled. It's changed to COUNT only when a scope-down statement matches, and it blocks everywhere else. In CDK, on the managed rule group statement:
managedRuleGroupStatement: {
vendorName: 'AWS', name: 'AWSManagedRulesCommonRuleSet',
ruleActionOverrides: [
{ name: 'GenericRFI_BODY', actionToUse: { count: {} } },
{ name: 'NoUserAgent_HEADER', actionToUse: { count: {} } },
],
scopeDownStatement: {
byteMatchStatement: {
fieldToMatch: { uriPath: {} }, positionalConstraint: 'STARTS_WITH',
searchString: '/api/webhooks/', textTransformations: [{ priority: 0, type: 'LOWERCASE' }],
},
},
},
That's one instance of the group for the webhook paths with two overrides, and a second instance of the same group, lower priority, with no overrides, for everything else. Two copies of the group, $2 a month, and the rules apply in full to 99 % of the traffic.
The rate limit is the rule that earns its keep
The managed groups have blocked a steady few hundred requests a day of scanner noise, which the application would have handled fine anyway. The rate-based rule, 2,000 requests per five minutes per IP, has blocked three credential-stuffing attempts against the login route and one scraper that was pulling every product page in a loop. Those are the incidents that would have cost something. If you enable one rule, enable that one, and put a tighter one (100 per five minutes) on /api/auth/* specifically.
What we'd tell a team enabling WAF tomorrow
- Two weeks in
COUNT, with logging on, before a single rule blocks. Read the log by rule and by path. - Expect the five rules above, in roughly that order of likelihood, on any app with forms, uploads or webhooks.
- Override by path, never globally. If a rule needs to be off everywhere, the application has a bigger problem than the rule.
- Move large payloads out of request bodies (presigned uploads) rather than widening the body limit. It fixes the WAF issue and the bandwidth bill at once.
- Put a tight rate limit on authentication routes. It's the one control here that has stopped an actual attack.
- Re-read the count metrics monthly. Managed groups update their rules without telling you, and a new false positive shows up as a new spike.
Sixteen dollars a month and one afternoon of reading logs. If you'd rather skip the two weeks and start from a configuration that already knows about the five rules, talk to us.