Production Security And Server Hardening

Rate Limiting And Abuse Controls

The Layers

At the web server, nginx counts cheaply, before PHP spends anything:

limit_req_zone $binary_remote_addr zone=login:10m rate=5r/m;

location /login {
    limit_req zone=login burst=5;
}

This throttles by client address — effective against a single noisy source, blind to an attacker rotating addresses, and dependent on the proxy lesson's header discipline: a forged X-Forwarded-For that reaches the counter makes limits evadable.

The application layer sees identity, so it can count what actually matters: attempts per account, per API key, per session — dimensions an address-rotating attacker cannot escape. Frameworks ship this as middleware backed by a shared store (Redis), which is essential the moment there are two servers: per-server counters multiply every limit by the fleet size. Alongside, fail2ban reads auth logs and bans repeat offenders at the firewall, closing the loop from application evidence back to network enforcement.

Answering Honestly

A rejected request gets 429 Too Many Requests with a Retry-After header, so legitimate clients back off correctly. Two refinements matter for auth endpoints. First, respond in constant time and shape whether an account exists or not, or the limiter becomes an account-enumeration oracle. Second, prefer slowing to blocking where lockouts are themselves an attack: an attacker who can lock any account by failing five logins has turned your control into their weapon — per-address limits, escalating delays, and CAPTCHAs degrade attackers without handing them a denial-of-service button against users.

What To Limit

The worklist, in rough priority: authentication (login, password reset, OTP verification — small keyspaces fall to patient guessing), messaging endpoints (anything that sends email or SMS spends your money and reputation), expensive queries (search, exports, report generation), and account creation (the seed of most downstream abuse). For each, set limits from measured legitimate behavior — an order of magnitude above the honest 99th percentile is a defensible start — and log rejections with enough context to tell attack from mishap.

Abuse controls extend past counting: velocity checks (one card, many accounts), disposable-email policies, and the asymmetric-cost principle — make the caller spend more than you do, via proof-of-work, CAPTCHA, or simply making the unauthenticated path cheap and cacheable. The long-running work lesson pairs with this: queuing expensive work converts a spike into backlog instead of an outage.

What To Check

Before moving on, make sure you can:

  • place limits at edge, web server, and application, and say what each layer can see
  • explain why identity-keyed limits need a shared store on multi-server fleets
  • respond to limited requests with 429 and Retry-After without leaking account existence
  • explain when blocking becomes the attacker's tool and slowing beats it
  • rank a new application's endpoints for limiting priority

What You Should Be Able To Do

After this lesson, you should be able to design layered rate limits for a PHP application, pick the counting key each endpoint needs, set thresholds from evidence, and verify that rejection behavior is honest to clients and useless to attackers.

Practice

Practice: Design Rate Limits

Design the rate-limiting scheme for a SaaS application with login, password reset, a public search page, and a JSON API with per-customer keys, running on three app servers.

Show solution

Password reset and any email-sending endpoint: strict per-address and per-target-account limits, because each request spends deliverability reputation; rejected sends are logged with address and target for fail2ban's log-based bans.

Search: per-address request rate at nginx sized from measured traffic (10× the honest p99), with the expensive path behind a short cache so most load never reaches PHP.

API: per-key quotas in the application (per-minute and per-day), 429 with Retry-After and rate-limit headers so well-behaved clients self-regulate; keys, not addresses, are the dimension because customers share NATs and attackers rotate IPs.

Everything returns 429 honestly, every rejection is logged with its key and rule, and a dashboard of rejections per rule exists from day one — limits without visibility cannot be tuned, only guessed at.

Practice: Diagnose An Abused Limiter

Support reports customers randomly locked out of accounts they did not touch. Logs show a botnet failing logins against thousands of accounts at one attempt per address per hour — under every per-address limit — while the per-account five-failure lockout does the locking.

Task

Explain the design error and redesign the login protection.

Show solution

The design armed the attacker. Per-address limits are transparent to a botnet that spreads attempts thin, while the per-account hard lockout let five cheap failures deny service to any chosen user — the attacker is not trying to get in; they are weaponizing the control. This is the canonical lockout-as-attack failure.

Redesign around asymmetry:

  • Replace hard lockout with escalating friction on the account dimension: increasing delays, then CAPTCHA — costs that hurt automation without denying the real owner, who can still pass a challenge.
  • Add the dimension the attacker cannot cheaply fake: device/session reputation. Known devices (a signed cookie from past successful login) bypass friction entirely, so the real owner rarely feels any of it.
  • Keep global anomaly detection: thousands of distinct addresses failing at exactly one per hour is a glaring fleet-wide signature even though every individual address is compliant. A global failure-rate alarm catches distributed attacks that no per-key counter can.
  • Feed confirmed-hostile addresses to the firewall layer via fail2ban, and notify account owners after sustained targeting, since password-stuffing implies their credential is circulating.

The test for any new control: ask what happens when an attacker deliberately triggers it against someone else. A control that can be aimed is a weapon you built.

Practice: Define Abuse Control Checks
Show solution
  • A synthetic probe exercises each limited endpoint past its threshold monthly and asserts the contract: 429, Retry-After present, and — for login — byte-identical responses for real and fake accounts.
  • Redis counter store health is monitored, and the failure mode is asserted by test: if the store is down, limits fail closed for auth endpoints and open for read endpoints, per documented decision rather than accident.
  • Rejections per rule are dashboarded with alerting on both extremes: a spike (attack or broken client) and a flatline (a rule silently disabled or bypassed — the header-trust regression from the proxy lesson shows up here).
  • The forged X-Forwarded-For probe from the proxy lesson runs against limited endpoints specifically, since the limiter is the control most damaged by that regression.

Human, quarterly:

  • Re-derive thresholds from current legitimate p99s; growth turns yesterday's generous limit into today's support tickets.
  • Walk the endpoint inventory for new unlimited endpoints — every feature shipping email, SMS, or expensive queries joins the scheme before launch, and the review is what catches the ones that did not.
  • Re-run the "can this control be aimed at a victim?" question against every blocking rule added since last review.