Your login page is a signup form
Scripted signups filled our access queue, and the fix we reached for first would have been theatre. A captcha on the signup form guards one door out of five — because in most auth libraries, magic links, email OTP, One Tap and OAuth all create accounts by default, and none of them go anywhere near that form.
The short version
Bot signups started landing in our access queue. The obvious fix — put a captcha on the signup form — would have done almost nothing, because four other endpoints create accounts without ever rendering that form. Magic links, email OTP, Google One Tap and OAuth sign-in all default to "create the user if they don't exist". Anti-abuse controls belong on endpoints, not on forms. Two things about the fix surprised us, and both are the kind you only find by reading the library's source.
The signups
They arrived a few a day, well spaced out rather than in a burst. The addresses were deliberately plausible — real-looking domains, ordinary-looking local parts. We looked at the addresses first and got it wrong: some we judged organic, and they weren't.
The tell wasn't the email at all. It was a different column entirely, one we weren't even displaying in the review screen at the time — and once we sorted on it, the rest of the signature fell out immediately. Every one unverified, every one created with a password rather than OAuth, and zero session rows. Nobody ever came back to see whether they'd been approved.
That last detail is the one that reframes everything. An attacker interested in your data comes back. These didn't.
The clue that explained it
The same addresses had signed up for a different product of ours. Both products are listed on our company site.
That's the whole story: something crawled the company site, followed the outbound product links, found a signup form behind each, and filled them in. We were never targeted. A list-walker walked a list, and we happened to be on it.
It also means the low-and-slow spacing wasn't restraint — it was throttling to stay under naive rate limits. A bot that wants to avoid attention behaves in exactly the opposite way to one trying to cause damage.
So before doing anything, we wrote down what this wasn't: not an intrusion attempt, not a denial-of-service, not a data leak. Unverified accounts can't sign in at all; unapproved ones never get past the waiting screen; approved ones are isolated to their own graph and can't see anyone else's. The actual costs were mundane and real — our email sending reputation, and the queue a human being has to review by hand.
That second cost is the one to take seriously, and not for the reason you'd guess. The risk isn't that a reviewer waves a bot through at 11pm. It's that somewhere in the noise a genuine early user gets rejected or lost, and nothing will ever tell you that happened.
The fix that would have been theatre
We reached for a captcha on the signup form. Then we went and read what our auth library actually does by default.
| Path | Option | Default |
|---|---|---|
| Magic link sign-in | disableSignUp | false — creates the account |
| Email OTP sign-in | disableSignUp | false — creates the account |
| Google One Tap | disableSignup | false — creates the account |
| OAuth sign-in | disableImplicitSignUp | false — creates the account |
None of these are bugs. They're sensible defaults for the common case, where "sign in" and "sign up" are the same intent and you want one button for both.
But it means the signup form is one door out of five. A script that POSTs straight to the magic-link endpoint gets a user row, a welcome email and a queue entry, having never loaded the page our captcha was going to live on. We'd have shipped a widget, watched the graph stay flat, and concluded the captcha worked.
Two lessons, and the second is the one we keep relearning:
- Put the control on the endpoint, not the form. A form is a suggestion about how the endpoint gets called.
- Read the defaults. Every one of those four is documented. We'd configured all four plugins and never looked at the options we hadn't set.
Our captcha plugin's own default endpoint list made the same point from the other side: it covers three endpoints, and we needed nine.
Two things we only found by reading the source
disableSignUp is enforced when the link is clicked, not when it's sent.
We assumed turning it on would also reduce the amount of mail we'd send to strangers. It doesn't. The send endpoint never looks the address up — it stores a token and mails it. The refusal happens later, when the link is opened and the user turns out not to exist.
So an unknown address still receives exactly one email. If your reason for disabling passwordless signup is "stop us mailing people who didn't ask", this setting does not do that, and a dashboard graph won't tell you. Only the captcha in front of the send endpoint does.
It also creates a UX cliff we had to handle: the failure surfaces as a redirect with an error code, after the person has clicked a link in their inbox. Left alone it dumps them on the app with a query parameter and no explanation. It now lands on a page that says "you don't have an account yet" and offers to make one.
Turnstile tokens are single-use.
This one bit us in about four minutes of testing. The token is consumed by the server the moment it's verified. So: type the wrong password, get "wrong password", fix it, submit again — and now you fail a captcha check, while looking at a captcha widget sitting there with a green tick.
Every form has to reset its widget after each attempt, successful or not. It's two lines and it's completely invisible until you try the second attempt, which is exactly the kind of thing that ships broken.
The trade we made on purpose
Magic-link signup is gone.
Our library offers no per-call override for magic links the way it does for OAuth, where the signup page can pass an explicit "yes, create this one" flag and the login page simply doesn't. Magic link is a single global switch, so turning it off for the login page turned it off everywhere, and the signup form's "email me a sign-in link" option went with it.
That's a real cost, and it's worth being honest that it cuts against the grain: a magic-link signup proves the person controls the inbox before the account exists, which is a better filter than a password signup, and it would have caught every bot in this wave for free.
We took the trade anyway, for a reason that has nothing to do with bots. An account created from a login page skips the signup page, and the signup page is where we ask people to accept the terms. We'd rather have one signup path where consent is unambiguously given than two where one of them quietly isn't.
Signing up is email and password, or OAuth. Magic links get you back into an account that already exists.
What we'd tell you to check
If you're running a small product with an open signup:
- List every endpoint that can create an account. It's more than you think.
- List every endpoint that will send an email to an address a caller chose. That's the one that can damage something you can't quickly repair — your sending reputation is slow to rebuild, and when it goes, password resets and verification links stop arriving for the users who are real.
- Don't judge a signup by its email address. Look at the fields a script has to invent rather than the one it can copy from a list.
- If you have more than one product on one company site, assume the crawler found all of them, and fix it in a layer you can lift into the next one.
None of this was sophisticated. It was a form-filler working a list, and the interesting part wasn't the attack — it was discovering that the defence we reached for first guarded one door out of five.
Discussion
Don't build sharing with row-level security
Tenant isolation is one indexable equality on every table, and that uniformity is the whole value. Widening it with an OR so colleagues can share records gives away every column on the row, costs you the index on your hottest predicate, and ships a change no feature flag can hold back. Publish a projection instead.
By profession
Your network is your livelihood, and it currently lives in business cards, half-remembered conversations, and a phone full of names with no context. Pick the guide that matches your work.