A SaaS login endpoint gets hit by 8000 requests per minute for ninety minutes, every six hours, from rotating IPs distributed across 14 ASNs. The application-level rate limiter rejects most of them with 429, but the auth path still runs the bcrypt comparison on every request that hits it, the RDS connections fill up, and customer logins start timing out at the edge. The credential-stuffing campaign is running through valid-looking traffic patterns that the rate limiter at the application cannot identify as malicious without burning database connections to find out. The fix is AWS WAF on the ALB, with the Bot Control managed rule group and a rate-based rule on the login path. The discipline that makes it work is the COUNT-then-BLOCK promotion pattern.
What’s happening
AWS WAF is a request-level filter that sits in front of ALB, CloudFront, API Gateway, or AppSync. Rules evaluate every request and emit one of three actions: ALLOW, BLOCK, or COUNT. The COUNT mode is the critical one — it logs the request as if the rule had matched but lets it through. This is the safety net that prevents a new rule from black-holing legitimate traffic on day one.
WAF rules come in three categories:
-
Managed rule groups — AWS-provided and AWS Marketplace rule groups. The core baseline:
AWSManagedRulesCommonRuleSet(CRS) — OWASP-derived rules against SQLi, XSS, RFI, generic bad inputs.AWSManagedRulesKnownBadInputsRuleSet— known exploit patterns (Log4Shell, Java deserialization, common CVEs).AWSManagedRulesAmazonIpReputationList— IPs AWS has observed in active malicious activity.AWSManagedRulesBotControlRuleSet— CAPTCHA + JS challenge for non-browser clients; the bot-vs-human discrimination layer.AWSManagedRulesATPRuleSet(Account Takeover Prevention) — credential-stuffing-specific rules, integrates with the login endpoint.
-
Rate-based rules — block IPs that exceed N requests in a sliding 5-minute window. The rule scope can be the whole web ACL or a specific URI (the login path specifically).
-
Custom rules — application-specific filters (block requests with no User-Agent, require a custom header on the admin path, geo-block specific countries).
The architecture pattern for credential-stuffing defense:
- CRS + Known Bad Inputs in BLOCK mode as the floor — these have very low false-positive rates and stop the loud attacks.
- Bot Control in CHALLENGE mode on auth endpoints — issues a CAPTCHA challenge to anything that does not look like a real browser.
- ATP in BLOCK mode on the login endpoint — catches credential-stuffing patterns specifically.
- Rate-based rule scoped to
POST /loginat a threshold of 100 requests per 5 minutes per IP — backstop against anything the previous layers miss.
The reason WAF on the ALB works where the application rate limiter does not: the WAF decision is made before the request hits the ALB target, so the bcrypt comparison never runs, the database connection is never opened, and the cost-per-blocked-request is a fraction of the cost of a request that reaches the app and is rejected there.
How to verify
The state check on an existing web ACL:
aws wafv2 list-web-acls --scope REGIONAL --region us-east-1 \
--query 'WebACLs[].[Name,Id,ARN]' --output table
aws wafv2 get-web-acl --scope REGIONAL --region us-east-1 \
--name prod-acl --id <id> \
--query 'WebACL.Rules[].[Name,Action,OverrideAction,Statement.RateBasedStatement.Limit]' \
--output table
Every rule should have either an Action (custom rule) or an OverrideAction (managed rule group). For managed groups, OverrideAction: NONE means the group’s per-rule actions take effect; OverrideAction: COUNT overrides everything in the group to COUNT mode.
To check what is actually being blocked vs counted, query the sampled requests:
aws wafv2 get-sampled-requests --scope REGIONAL --region us-east-1 \
--web-acl-arn arn:aws:wafv2:us-east-1:123:regional/webacl/prod-acl/xxx \
--rule-metric-name AWSManagedRulesBotControlRuleSet \
--time-window StartTime=$(date -u -v-1H +%s),EndTime=$(date -u +%s) \
--max-items 100 \
--query 'SampledRequests[].[Timestamp,Action,Request.URI,Request.Headers[?Name==`User-Agent`].Value|[0]]' \
--output table
The Action column shows what each request triggered. A new rule in COUNT mode should accumulate counted requests proportional to the volume the production traffic would hit; if the rule is in COUNT and nothing is matching, the rule is misconfigured. Conversely, if BLOCK mode is showing 30% of all requests, the rule is producing false positives at a rate that breaks customers.
The fix
The COUNT-then-BLOCK promotion pattern, applied to every new rule:
-
Deploy in COUNT mode for one full business week. Set the rule’s
OverrideAction: COUNT(for managed groups) orAction: { Count: {} }(for custom rules). The rule logs every match without affecting traffic. -
Sample the logs and classify the matches. Use Athena over the WAF logs (S3 destination) to get the match distribution:
SELECT terminatingRuleId, httpRequest.uri, httpRequest.country, COUNT(*) AS hits FROM "waf_logs"."prod_acl" WHERE day = '2026/05/20' AND ruleGroupList[1].ruleId LIKE 'AWS%' AND action = 'COUNT' GROUP BY 1, 2, 3 ORDER BY hits DESC LIMIT 100;The top URIs reveal whether the rule is hitting an actual attack pattern or a routine path (a third-party webhook, a health-check endpoint, a search endpoint with unusual characters). False positives are the rows where the URI is one your app legitimately serves and the request looks normal in the rest of the log.
-
Add exclusions for false-positive rules. A managed rule group lets you set per-rule actions to override the default. If
SizeRestrictions_BODYis matching legitimate file uploads:{ "ManagedRuleGroupStatement": { "VendorName": "AWS", "Name": "AWSManagedRulesCommonRuleSet", "RuleActionOverrides": [ { "Name": "SizeRestrictions_BODY", "ActionToUse": {"Count": {}} } ] } }Or restrict the scope of the entire rule group to specific URIs with
ScopeDownStatement, so the rule only evaluates on paths where the application is vulnerable. -
Promote to BLOCK after the COUNT week is clean. Flip
OverrideAction: NONEand ship. Watch the WAF metrics for the first 48 hours after promotion —BlockedRequestsshould match the rate thatCountedRequestswas showing in COUNT mode, within a normal variance. -
Keep the rule list short and the order intentional. WAF charges per request inspection, and a web ACL with 30 rules has 30 evaluations per request. The order matters: rate-based rules first (cheap), IP reputation list second (cheap lookup), managed groups in increasing specificity, custom rules last. AWS evaluates rules in priority order and stops at the first terminating action.
For credential-stuffing specifically, the rule layout we deploy:
Priority 10: IP reputation list - BLOCK
Priority 20: Rate-based - POST /login, 100 req / 5 min per IP - BLOCK
Priority 30: ATP rule group on /login - BLOCK
Priority 40: Bot Control - CHALLENGE on /login, NONE elsewhere
Priority 50: CRS - BLOCK
Priority 60: Known Bad Inputs - BLOCK
Priority 70: Custom rules (admin path geo-block, etc.)
The rate-based rule at priority 20 is the backstop — even if Bot Control and ATP both miss a pattern, the per-IP rate limit stops the volume. The threshold of 100 requests per 5 minutes per IP is generous for any legitimate use of the login endpoint (a power user might do 10) and tight against credential-stuffing campaigns (rotating-IP attacks struggle to maintain volume below the threshold per IP).
The false-positive cleanup workflow over the first month:
- Daily review of
CountedRequestsfor any rule still in COUNT mode. - Weekly review of
BlockedRequestsfor spikes — a sudden 10x increase in blocks on one rule usually means a legitimate change in traffic pattern (a new feature, a new partner integration) caught by an old rule. - Monthly review of the rule list — drop rules that have zero matches in 30 days (the rule is either covering a pattern that has stopped or scoped to a URI that no longer exists).
Three rules across every WAF deployment we run:
Never deploy in BLOCK on day one. The COUNT week is non-negotiable. A new rule that goes to BLOCK and takes down a partner integration on the same day is the canonical WAF rollout failure.
Scope down managed groups when needed. Bot Control on every URI breaks API clients that legitimately use cURL. Bot Control on /login and /signup only stops the credential-stuffing campaigns without blocking the API.
WAF protects, the app still rate-limits. WAF stops the loud attacks. The application’s own rate limiter is the second layer for the patterns that look like traffic — abusive customers, runaway scripts from legitimate users. Both layers exist for different threat models.
The initial deployment on a SaaS workload is a focused day for the web ACL, a week of COUNT-mode observation, a day of false-positive cleanup. The ongoing cost is one hour per month for the metric review. The campaign that triggered the question stops the same day the rate-based rule promotes to BLOCK.
Stack Harbor runs IAM cleanup, CloudWatch baseline, AWS Backup, and the cost-control rhythm as part of Managed AWS and ongoing Managed Cloud Ops. If credential-stuffing traffic is filling your bcrypt queue, start with an infrastructure consult.