← All posts

Someone used my login form to bury a fraud alert

7 min read

I run a small rank tracker. It has no password — you type your email, we send a one-time sign-in link. Under a hundred lines of auth, nothing exotic.

Last week I built an admin panel for it, mostly out of curiosity about who was signing up. The first thing it showed me was a list titled “requested a link, never signed in.” Twenty addresses.

That number is the whole story, and it took me a minute to see why.

The volume was the tell

My marketing at that point consisted of two comments: one on a Reddit thread about rank trackers, and one on an Indie Hackers post that had nothing to do with my product. Plus a few directory listings that had sent me nothing measurable. No launch, no ads, no announcement.

There is no funnel on earth that turns that into twenty sign-ups in fourteen hours.

So I looked at the timestamps instead of the count:

05:46  ┐ same minute
05:46  ┘
04:58  ┐ one minute apart
04:57  ┘
20:52  ┐
20:51  ┘
19:52  ┐
19:52  ┘

Pairs. About a minute apart, roughly once an hour, across fourteen hours. Human traffic does not arrive on a metronome.

But the addresses were the part that made it click. They were not the random strings you get from a scraper — xk8vq2@…, that sort of thing. They were plausible: real consumer ISP domains, the kind of address a person has had since 2004. One of them was at a county government domain.

Real people. Real addresses. Zero of them ever clicked the link.

What it actually was

This is list-bombing, sometimes called subscription bombing. I'd read about it; I had not expected to be a participant.

The mechanics: an attacker has just charged a stolen card, moved money, or changed a password. The bank sends the victim an alert. If the victim reads it within the hour, they call someone and it gets reversed. The attacker's entire problem is those few hours.

So they feed the victim's address to a script that submits it to every sign-up form it can find. The inbox fills with thousands of “confirm your account” messages, and the one email that mattered is in there somewhere, unread, on page nine.

The addresses in my database were the victims', not the attacker's. They never signed in because they never asked for anything.

My product was one line of noise in someone else's fraud.

Why magic-link auth is a perfect target

An attacker doing this needs forms that will email an arbitrary address with no verification step. That requirement is the entire description of passwordless login: type an email, receive mail. No signup flow to complete, no password to invent, no confirmation to click before the mail goes out. The mail is the first step.

It's not personal, either. Scanners crawl for this pattern continuously. I wasn't targeted; I matched.

Why my rate limits saw nothing

I had rate limiting. Two layers, both reasonable-looking:

Neither one can see this attack, and the reason is worth internalising: the attack's shape is one request per victim, from a rotating source.

The per-email counter sees one request for that address. Under the limit. The per-IP counter sees one request from that IP. Under the limit. Both counters are keyed on a resource the attacker has in abundance, so neither ever fires, while the aggregate behaviour — my domain emailing twenty strangers in a day — goes entirely unmeasured.

The limits weren't too loose. They were counting the wrong thing.

The bug I found on the way, which is probably your bug too

While digging into the IPs, I noticed every request in my logs came from 10.0.1.3.

The app runs in a container behind Traefik. request.client.host gives you the peer on the other end of the TCP connection — which, behind a reverse proxy, is the proxy. Not the visitor.

I assumed a useless IP column was a cosmetic problem. It isn't — my rate limiter was keyed on that value:

hit(f"login:ip:{ip}", limit=8, window=900)

Every visitor on the internet was sharing one bucket. Eight sign-in attempts per fifteen minutes, globally. One person retrying could lock out everyone else, and an attacker was indistinguishable from real traffic because they were all the same key.

The fix is X-Forwarded-For, with one detail that matters:

xff = request.headers.get("x-forwarded-for") or ""
parts = [p.strip() for p in xff.split(",") if p.strip()]
if parts:
    return parts[-min(hops, len(parts))]   # Nth from the RIGHT

X-Forwarded-For is a client-supplied header that each proxy appends to. If a caller sends X-Forwarded-For: 1.2.3.4 and your proxy appends the real address, the header reads 1.2.3.4, <real>. Read it left-to-right and you trust whatever the caller typed — you've built a rate limiter that anyone can bypass with a header, and an audit log anyone can forge.

You can only trust the entries your own proxies added, which are on the right. With one trusted proxy, take the last entry. With Cloudflare in front of Traefik, take the second from last. Make it configurable, because getting the count wrong is silent in both directions.

If your platform sets CF-Connecting-IP, prefer that — Cloudflare writes it itself and strips any inbound copy.

What fixed it

Two things, in this order.

A site-wide ceiling. Not per IP, not per email — a cap on how many sign-in emails the whole application sends in an hour, regardless of who asks. Mine is 20/hour, tunable by environment variable. It's a blunt instrument that can't tell humans from scripts, and that's the point: it bounds the damage however the traffic is spread. This is the layer I should have had from day one.

Then Cloudflare Turnstile. Free, doesn't require moving your DNS, and in managed mode most real visitors see nothing at all.

I already had a honeypot field and a “submitted in under a second” timing check. Worth being honest: neither caught this. Those catch scripts that spray every form on the internet without looking at any of them. This one was written against my form — it left the hidden field alone and it wasn't in a hurry. Layers are not interchangeable.

Two implementation notes if you add a challenge to a login form:

Why you should care before it happens to you

The cost isn't bandwidth or your email quota. It's that every one of those messages goes to someone who didn't ask, and some of them press the spam button.

Your sending reputation is what makes your sign-in link land in an inbox instead of a junk folder. Damage it badly enough and your own login stops working — not with an error, just silently, for everyone. Your email provider may also suspend you for abuse, which is the same outcome arriving sooner.

If your product emails an address someone typed into a public form, you're a candidate. Go count how many people requested something and never came back. That number is the check.

Written while building RankJot — Google rank tracking that emails you when your positions move. There's a free checker with no signup if you just want a one-off look.