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

Example · bash
# 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.

Example · javascript
// 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.

Example · bash
# 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.

Example · javascript
// 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

  • Be the first to comment on this lesson.