WAF, Gateway and Where to Enforce
What a WAF genuinely buys you, what it cannot see, and why it is a layer rather than a solution.
A web application firewall inspects traffic and blocks what matches its rules. It is genuinely useful and routinely oversold, and knowing the difference matters when someone proposes it as a substitute for fixing the code.
What a WAF is good at
- Buying time. A virtual patch for a newly disclosed vulnerability, while the real fix ships.
- Blocking commodity scanning. Automated tools hitting known paths and payloads.
- Volumetric defence. DDoS absorption at the edge.
- Uniform enforcement. One place for IP reputation, geo rules and bot signals.
What it cannot do
A WAF cannot see authorization. GET /api/invoices/1044 with a valid token is indistinguishable from a legitimate request — only your application knows that invoice belongs to someone else. Since broken authorization is the dominant cause of API breaches, the most important category is the one a WAF is blind to.
It also cannot understand your business logic, cannot see inside encrypted request bodies it does not decrypt, and is bypassable by encoding for a determined attacker.
Gateway versus application
The dividing line: the gateway authorizes the route; the service authorizes the object. Token verification, route-level scope, rate limiting and header stripping belong at the edge. Ownership, tenancy, field-level permissions and business rules stay in the service, because they require data.
Positive security models
Far stronger than blocking known-bad: validate every request against your OpenAPI schema at the gateway, and reject anything that does not match. Unknown paths, unknown fields, wrong types and out-of-range values are refused before reaching your application at all.
Deploy it in monitor mode first
A WAF turned straight to blocking will break a legitimate client within a day. Run it in detection mode, tune against real traffic, then enforce.
Example
# What a WAF sees, and what it cannot
# ✅ Blocks — a recognisable payload
GET /api/users?id=1' OR '1'='1
GET /api/../../etc/passwd
POST /api/comments {"body": "<script>alert(1)</script>"}
# ❌ Cannot block — indistinguishable from legitimate traffic
GET /api/invoices/1044
Authorization: Bearer <a perfectly valid token>
# The WAF sees: a valid token, a normal path, a normal method, no payload.
# Only your application knows invoice 1044 belongs to somebody else.
# That is the most common API vulnerability, and it is invisible at the edge.When to use it
- A WAF rule provides a virtual patch for a newly disclosed dependency vulnerability while the real upgrade is tested and deployed.
- Schema validation at the gateway rejects unknown fields and wrong types before they reach any application code.
- A security review rejects the proposal to rely on a WAF instead of fixing an IDOR, because the WAF cannot see the difference.
More examples
Positive security: validate against your own schema
Using the OpenAPI document as the validator removes the usual drift between documentation and enforcement — they become the same artefact.
// Blocking known-bad is a losing game. Allowing only known-good is not.
// Your OpenAPI document already describes known-good.
import OpenApiValidator from 'express-openapi-validator';
app.use(OpenApiValidator.middleware({
apiSpec: './openapi.yaml',
validateRequests: {
allowUnknownQueryParameters: false, // unknown query params → 400
removeAdditional: false, // do not strip — REJECT
coerceTypes: false, // "1" is not 1; be strict
},
// In development this catches responses that leak fields the spec forbids.
validateResponses: process.env.NODE_ENV !== 'production' ? {
removeAdditional: false,
onError: (err, body, req) => {
logger.error({ err, path: req.path }, 'response violates the schema');
throw err; // fail the test suite
},
} : false,
// Unknown paths do not reach application code at all.
validateSecurity: true,
ignorePaths: /^\/(health|metrics)$/,
}));
// What this rejects, before any handler runs:
// - a path not in the spec → 404
// - a method not defined for that path → 405
// - an unknown body field → 400 (mass assignment)
// - a wrong type → 400 (NoSQL injection)
// - a value out of range → 400 (resource exhaustion)
// - a missing required field → 400
// - an unknown query parameter → 400
//
// One configuration line replaces a large amount of hand-written validation,
// and it cannot drift from the documentation because it IS the documentation.
// ── The same idea at the gateway ─────────────────────────────────────
// AWS API Gateway: request models and validators
// Kong: the request-validator plugin, configured from the OpenAPI spec
// Envoy: the json_to_metadata and ext_authz filters
//
// Enforcing at the gateway means malformed traffic never reaches your
// application, which matters under load.
// ⚠️ And keep the spec honest: generate it from code, or test the code
// against it. A spec that has drifted is worse than none, because it
// silently permits things it describes as forbidden.WAF rules that earn their place
Narrowing an exclusion to a single path rather than disabling a rule globally is the discipline that keeps a WAF useful after the first few false positives.
# Deploy in MONITOR mode, tune against real traffic, then enforce. A WAF turned
# straight to blocking will break a legitimate client within a day.
# ── Cloudflare: managed rules first, custom rules for what they miss ──
# 1. Managed rulesets: OWASP core + Cloudflare managed
# Start at "log" sensitivity. Review a week of matches. Then escalate.
# 2. Rate limiting at the edge — cheaper than in your application
# (http.request.uri.path matches "^/api/auth/(login|register|forgot)$")
# → 10 requests per minute per IP, block for 10 minutes
# 3. Protect the expensive endpoints specifically
# (http.request.uri.path matches "^/api/(reports|exports)")
# → 5 requests per hour per authenticated user
# 4. Block known-bad reconnaissance paths
# (http.request.uri.path in {"/.env" "/.git/config" "/wp-admin"
# "/actuator/env" "/debug/pprof/"})
# → Block. These are never legitimate, so there are no false positives.
# 5. Geo and ASN rules, ONLY with a business justification
# (ip.geoip.country in {"XX"} and http.request.uri.path contains "/admin")
# → Managed Challenge
# ⚠️ Geo blocking is trivially bypassed with a VPN and it blocks real
# customers. Use it for the admin surface, not the product.
# 6. Virtual patching — the WAF's best genuine use
# A CVE is announced Friday; the fix ships Monday.
# (http.request.body contains "__proto__" or
# http.request.uri.query contains "constructor[prototype]")
# → Block
# This is a TEMPORARY measure with a ticket and a removal date attached.
# ── ModSecurity, self-hosted ─────────────────────────────────────────
# SecRuleEngine DetectionOnly # ← start here, for weeks
# SecRuleEngine On # only after tuning
#
# Include /etc/modsecurity/crs/crs-setup.conf
# Include /etc/modsecurity/crs/rules/*.conf
#
# # Paranoia level 1 is the sane default; 2+ produces heavy false positives.
# SecAction "id:900000,phase:1,pass,setvar:tx.paranoia_level=1"
#
# # Exclusions for YOUR API — these are unavoidable and must be reviewed.
# SecRule REQUEST_URI "@beginsWith /api/documents" \
# "id:1001,phase:2,pass,nolog,ctl:ruleRemoveById=942100"
# # rule 942100 = SQL injection detection; our document bodies contain
# # legitimate SQL examples. Narrow the exclusion to the ONE path.
# ── Measure whether it is doing anything ─────────────────────────────
# blocks by rule id, weekly → which rules ever fire?
# false positives reported, weekly → which rules cost you customers?
# time from CVE to virtual patch → the metric that justifies the spend
#
# A rule that has never fired and a rule that only blocks customers are both
# candidates for removal.The division of responsibility, written down
The bypass test is the one that matters: it verifies the application's controls hold when the edge is removed, which is exactly the scenario a misconfiguration creates.
// Ambiguity about where a control lives is how controls end up nowhere.
// Write the table, and review it when adding a control.
export const ENFORCEMENT_LAYERS = {
cdn_waf: [
'DDoS absorption',
'IP reputation and known-bad bot blocking',
'Reconnaissance path blocking (/.env, /.git)',
'Coarse per-IP rate limiting',
'Virtual patching (temporary, with a ticket)',
'TLS termination and protocol enforcement',
],
api_gateway: [
'Token signature, issuer, audience and expiry',
'Route-level scope requirements',
'Schema validation against the OpenAPI spec',
'Per-principal rate limiting and quotas',
'Stripping client-supplied internal headers',
'Request id generation and propagation',
],
application: [
'Re-verify the credential (never trust the edge alone)',
'OBJECT ownership — the check no edge layer can make',
'TENANT isolation',
'Field-level read and write permissions',
'Business rules and workflow state',
'Explicit response shaping',
'Audit logging',
],
database: [
'Row-level security',
'Least-privilege roles',
'Column encryption for sensitive fields',
'Statement timeouts',
],
};
// The questions this table answers:
//
// "Can the WAF stop IDOR?"
// No. It is in the `application` list because it needs data the edge
// does not have. This is not a tuning problem.
//
// "Do services still need to verify the token if the gateway does?"
// Yes. Otherwise anything reaching a service directly — a misconfigured
// ingress, an SSRF, another pod — is unauthenticated.
//
// "Where does rate limiting go?"
// Both. Coarse per-IP at the edge (cheap, absorbs volume), precise
// per-principal at the gateway (meaningful identity).
//
// "We are adding a new control. Where?"
// Does it need data? → application.
// Is it about the request shape? → gateway.
// Is it about volume or reputation? → edge.
// And the test that proves the layers are independent:
it('the application enforces authorization without the gateway', async () => {
// Call the service DIRECTLY, bypassing the gateway entirely.
const res = await request(serviceApp)
.get(`/api/invoices/${bobInvoice.id}`)
.set('X-Internal-Token', mintInternalToken(alice));
expect(res.status).toBe(404);
});
Discussion