I built a football prediction game for the 2026 World Cup. A month long, 104 matches,
one winner at the end. It worked, people played, nothing caug...
For further actions, you may consider blocking this person and/or reporting abuse
"Every
24 hours, everysetMaxResults" is the part I'd underline. Those constants are facts with a shelf life, and nothing about them looks like an assumption when you read the line.I ran into the same class from a different side. My scanner scores my own projects and its penalty weights are hardcoded numbers I picked once, against a codebase that has since roughly tripled. Nothing failed. The numbers just quietly stopped describing the thing they were calibrated on, and every report kept looking authoritative.
What helped wasn't better constants — it was giving each one a birth certificate: the date I set it, what the distribution looked like on that date, and how far it's allowed to drift before something complains. The comparison is mechanical now, so the day it stops fitting, I get told instead of noticing in a retro.
Your four bugs share a property I'd never named: they're all decisions that were correct for a cadence nobody wrote down. A test suite can't catch them because there's nothing to assert against — the assumption was never expressed as a value anywhere.
The birth certificate is better than what I do today. My constants carry a comment explaining the reasoning, but nothing that records when the reasoning was formed, and that is the part that rots. A "set 2026-06, when the only event in the database was a 32-team tournament" would have made the 24-hour reminder window read as suspicious the day a league season landed, instead of reading as a sensible default forever.
The cadence framing took me the longest to see, and I only saw it in hindsight. Each of the four was a correct decision for a rhythm nobody had written down: matches every four days, a bracket that only moves forward, one competition at a time. A test suite encodes the rhythm you had when you wrote it, so it keeps agreeing with you right up to the moment the rhythm changes, and then it agrees with you just as confidently while the product is wrong.
Since publishing I hit a fifth one from the same family: a signup rate limit of five accounts per hour per IP, written when users arrived one at a time from search. The day the link went into a WhatsApp group, a whole office behind one NAT would have been locked out after five. Nothing failed there either. It was simply calibrated for a different arrival pattern.
Your fifth one is a different species from the other four, and the distinction is worth keeping because the remedy differs.
The first four were calibrated to a rhythm that later changed. Drift is the failure there, and a birth certificate catches it: record what was true when you set the number, and the day reality diverges you have something to compare against.
The rate limit isn't that. Five per hour per IP wasn't correct-then and stale-now — it encoded an assumption about identity, that one IP means roughly one person, and that was never true. It just hadn't been exercised. Nothing drifted. A birth certificate on that constant would have read "set when arrivals were one at a time from search," and no measurement would ever have contradicted it, right up to the WhatsApp link.
So: some constants rot, and some were wrong at birth and merely unexercised. The certificate catches the first kind. Only something adversarial catches the second — and the tell for that family is a key that stands in for identity rather than measuring it.
One upgrade on the certificate itself, from mine. The date is necessary but the comparison is what fires. Mine records when it was set, the baseline distribution at that moment, and the drift I'll tolerate — so a machine computes the divergence instead of me noticing it. "Calibrated when the largest event was 32; largest event is now 306" is a line a script can print on every run. "Set 2026-06, during a tournament" is a line I have to reread and be alarmed by, and I have already demonstrated that I won't.
You are right, and the split is sharper than mine. I had one family: constants calibrated for a rhythm. It is two. The ones that stopped being true, and the ones that were never true and simply never got exercised. The remedies do not overlap.
So I ran your tell over my own code, looking for keys that stand in for identity instead of measuring it. It found one, sitting three lines below the one I had already fixed.
The signup limiter is mine. I wrote it, and after the WhatsApp incident I raised it from 5 to 30 per hour per IP. Login throttling is not mine: it is one line of framework config,
max_attempts: 5,interval: 15 minutes. What that expands into is two limiters, not one. A local one keyed on username plus IP, at 5. And a global one keyed on IP alone, at five times the number you wrote, so 25 failed logins per 15 minutes for everyone behind that address. Same key, same assumption, still live.It hides better than the first one, for two reasons. It only fires on failures, so it needs a bad Monday rather than a bad script. And it hits people who already have accounts, so it can never show up in a signup funnel: the office that gets locked out is the office that was already playing.
The blast radius is also wider than the login form, which I only found by pulling the thread. Our magic link authenticator, the one-click return link we put in reminder emails, sits on the same firewall and goes through the same throttling listener. It consumes nothing, it only peeks at the counter, but it is refused once the counter is spent. So 25 bad passwords from anyone behind that NAT are enough to break the return link of a colleague who typed nothing wrong at all. The proxy key does not just block the front door. It blocks the path we built for the people who cannot get through the front door.
What I did not expect is that fixing the first one is what made the second invisible. I patched the constant that broke, the incident closed, and the whole class felt handled.
A corollary to your heuristic, then: proxy keys cluster in the layer someone else wrote for you. My bad constant was a number I chose and can defend badly. The one your tell just found is a number I never chose, expanded from a default, with a multiplier I did not know existed until I opened the factory class. Those constants have no birth certificate to write, because there was no birth.
Which is why I think the second family gets something much smaller than a drift monitor, and not the same tool. There is no baseline for a key that was wrong at the start, so nothing diverges and no script fires. What I am keeping instead is an inventory: one hand-maintained list of every place in the product where a key stands in for a person, and what breaks downstream when that key is wrong. It is short, and short is the point. A machine can compute drift. It cannot notice that an IP was never a person.
I ran your tell and found the same defect, and the first thing it did was break your
corollary.
One of my smaller internal tools has a brute force guard. Five failures in five minutes
locks. The key is the IP and nothing else — no username. That is your global limiter
exactly, except it is not a framework default and there is no factory class and no
multiplier I did not know about. I wrote it. I chose the five. I named the function. So
proxy keys are not clustering in the layer someone else wrote for me; mine is in the layer
I wrote for myself, and I could defend the number badly in my own voice.
What the two do share is that neither key was ever differentiated in practice. Yours came
from a default nobody exercised. Mine came from me, and nobody exercised it either. If the
corollary is going to survive, I think it has to be about exercise rather than authorship —
someone else's defaults are one common way a key goes untested, not the mechanism.
I did not reason my way to this. I read the table. The limiter stores its attempts, so I
asked it how many distinct keys it has ever recorded, and the answer is one: a Docker
bridge address. Every request that has ever hit that login form, from inside the building
and outside it, arrived with the same value. The key does not stand in for a person. It
stands in for everyone.
Which suggests something for your inventory, and it is a friendly disagreement with your
last line. A machine cannot notice that an IP was never a person, but it can count. If a
limiter has been storing its key for months, the distinct values of that key against the
number of accounts is an executable question. Mine is one distinct value against two users.
Yours, run on whatever backs the global counter, would answer in one query whether that key
has ever separated two customers. That does not replace the hand-maintained list — you still
have to decide the key was supposed to be a person — but it turns "is this key a proxy" from
a judgement into a measurement, and it would have found mine years earlier than I did.
Your two reasons it hides better, and I have a third that is worse. The evidence erases
itself. The check deletes rows older than the window before it counts, and it only runs when
somebody attempts a login. So the record of a lockout survives exactly until the next failed
attempt. The rows I found are from the tenth of June, still sitting there — not because
anything preserved them, but because nobody has failed a login since. Five rows, which is
precisely the lock threshold. Someone mistyped five times in one minute and locked out the
entire user base, and the only reason I can see it today is that the tool is quiet. On a
busy system I would have opened an empty table and concluded the limiter was fine. It looks
like a log and behaves like a countdown.
On fixing the first making the second invisible: mine is worse, because I had already written
the finding down. There is a note in my own reference material saying that traffic arriving
through our tunnel cannot be used to identify a machine. I wrote that about network topology,
filed it as a networking fact, and never once read it as a statement about every IP-keyed
thing I own. The knowledge was not missing. It was shelved under the wrong heading, which
means no amount of remembering harder would have retrieved it.
And the inversion, since you named the two families: my main system, the one four hundred
and fifty people use, has no login limiter at all. No table, no throttle, nothing. The tool
with two users has the broken guard and the tool with four hundred and fifty has no guard,
because the small one was built from a checklist and the large one grew. Your second family
is keys that were never true. There is a third sitting next to it that I did not see until
this morning: guards that were never written, in the place where the checklist was never
applied. Same silence, and the inventory catches it for the same reason — you have to list
where a key should be, not where one is.
I ran your count, and it answered. Then I looked at what it answered, and I do not think the count is the measurement.
The limiter itself could not answer it at all, so everything below is from the access log instead. Fifteen days, one product.
Signups: 21 POSTs, 17 distinct addresses. Logins: 91 POSTs, 17 distinct addresses. Against your one distinct value for two users, seventeen looks healthy. Seventeen is the number that let the attack through.
On 13 August at 11:41:52 UTC, three addresses started posting to the login form. Twenty-one attempts each, then a fourth at seven. Sixty-three attempts in four minutes. The global limiter, the IP-keyed one I described last time, sits at twenty-five per fifteen minutes. It did not fire once, because the attempts were spread across three keys and each key stayed under its own budget. Nobody defeated the limiter. The attacker was simply not one key.
Now the same key on the other form. Of those 21 signups, five arrived on an address shared with another signup. Two accounts created 26 seconds apart from one address on 21 August. Two more, 48 seconds apart, on 12 August. That is the group arrival my original incident was about, and it is not hypothetical in my logs, it is in there twice.
So: one key, one fortnight, both failures at once. It bundles people who are not one, and it splits an actor who is not three. I had been carrying the proxy key as a thing that groups. It also divides, and the division is the one that costs you the guard rather than the customer.
Which is why I would not ship the count as the check. A low distinct count proves a key is a proxy: one value against 450 users is proof, and it needs no interpretation. A high count proves nothing. It is equally consistent with a key that separates people properly and with a key an attacker can mint at will, and those are opposite problems with opposite remedies.
Two numbers I would put in its place. Both come from the log rather than from the limiter, and both are directly comparable to the threshold, which is what the count never was.
Bundling: the most events ever sharing one key value inside the limiter's own window, over the budget. Mine is 3 signups in 38 minutes on one address. Against the five per hour I used to have, that is one household spending sixty percent of a stranger's budget. Against the thirty I have now, it is nothing. The number moves when the threshold moves, so it is an answer and not just a fact.
Splitting: the most events in one window across all key values, over the per-key budget. Mine is 63 in four minutes against 25 per fifteen. A limiter whose observed total runs at two and a half times its own per-key budget is not limiting anything, and no count of distinct keys will ever say so.
On your third reason it hides better, mine is worse, and it is a property of the default rather than of my code. Your rows from the tenth of June are still sitting there. Symfony's limiter keeps its state in a cache pool with a TTL, so there are no rows. I opened the store while writing this: it holds exactly one entry, a sliding window for one signup address, with 2156 seconds left to live. When that expires there is nothing, and nothing will have failed. Yours survives until the next attempt. Mine survives until the clock says so, whether anything happens or not.
That has a consequence I like even less. All 88 of those login POSTs returned a 302, because a form login redirects on success and on failure alike. So the log records that 63 attempts were made and cannot tell me whether any of them worked. The one component that knew was the counter, and it forgot fifteen minutes later. I am nine days downstream of a credential attack on my own product, and the honest sentence is that I do not know how it went.
On authorship, you are right, and I would push past exercise to the reason it goes unexercised. Mine came from a framework default. Yours came from you. Both went untested, and I think the mechanism is the same: neither of us had a group. A key that stands in for identity is only wrong when two things that should differ arrive with the same value, and that needs two people, or one person willing to look like three. Solo development produces neither. You had two users and a Docker bridge. I had search traffic arriving one at a time. Neither of us was withholding a test. Neither of us had the input.
Which makes your inversion the part I am keeping. The tool with two users has the broken guard and the tool with 450 has none, because the small one came off a checklist and the large one grew. My inventory lists where a key stands in for a person. Yours has to list where a guard should be and is not, and that list cannot be built by reading code, because every entry is an absence. I do not have a method for it. I suspect the honest version is every route that accepts a credential or creates something, ticked by hand, and that all of the value is in the ticking.