The model is agreeable. That is what makes it useful, and it is the entire attack surface.
It wants to complete the task. Told a rule, it follows it — right up until someone constructs a conversation in which breaking the rule looks like following a better one. It has no concept of your threat model, no memory of the last caller, and no way to tell a customer from someone probing you.
So the fourth principle of this book, stated plainly: anything you enforce by asking the model nicely is not enforced. This chapter is the set of things that must sit behind the tools instead.
Count Attempts Against the Identity, Not the Guess
A six-digit code has a million possibilities, so it needs an attempt limit. Where you keep that counter is the entire security of it:
// Attempts are counted per email address, never per submitted code —
// keying on the code would hand every new guess its own fresh budget.
$attemptsKey = "verify-attempts:{$email}";
Key on the submitted code and each wrong guess is its own first attempt, forever. The limit exists, the code enforces it correctly, and it stops nothing.
Key on the thing being protected — the email address — and three wrong guesses is three wrong guesses no matter how they arrive.
Count against the resource, not against the request. The same error appears with per-request rate limits keyed on a value the caller controls.
Two Windows, or You Have Paced Abuse Rather Than Stopped It
private const PER_MINUTE_LIMIT = 3;
/**
* A per-minute ceiling alone only paces abuse, it never stops it: three
* codes a minute, forever, is thousands of mails a day to an address
* nobody has proven they own. The daily cap is what bounds the spend and
* keeps the sending domain off spam lists.
*/
private const PER_DAY_LIMIT = 10;
A per-minute limit feels like protection because it stops the obvious burst. Do the arithmetic and it permits thousands of emails a day to an address nobody has proven they own — which is a mail-bombing tool with your domain on it, and a reputation problem that outlives the incident.
This is Chapter 4's rate limit versus budget distinction in a security setting. The short window shapes traffic; the long window bounds total harm. You need both, and the long one is the one that matters.
And both are incremented the same careful way:
// add() then increment(), rather than read-modify-write, so two concurrent
// sends cannot both read the same count and write it back — which would let
// a cap be overrun a little at a time. Both windows need this: N simultaneous
// requests all reading 0 defeats the per-minute ceiling just as readily as
// the daily one.
private static function bump(string $key, DateTimeInterface|int $ttl): void
{
Cache::add($key, 0, $ttl);
Cache::increment($key);
}
Same lesson as the spending ceiling: check-then-write fails open under concurrency, and a security limit must never fail open.