Day 63: How we handle the same person signing up 5 times in an hour
There's a moment when you're building a SaaS where you have to decide what to do about the same IP signing up over and over.
Some products go nuclear. Block the IP. Rate-limit by network. Captcha three times. The problem with all of that: real, well-meaning users hit those walls constantly. Someone signs up, forgets they signed up, signs up again. An agency creates accounts for two clients from the same office. A family runs two small businesses from the same router.
If you treat the first 100 of those people like criminals, you'll never find the actual abusers — because the actual abusers will route around it in a week.
So we picked the boring path. Log silently, warn softly, review later.
This is Day 63, where that path closes end-to-end.
What was already live going into today
Day 62 shipped Part 2 — silent backend logging. Every signup attempt writes a row to a signup_attempts table: IP, email, user agent, timestamp. Two API routes do it: /api/signup-precheck fires before Supabase signUp is called (inserts a pending row), and /api/signup-precheck/complete updates it to completed after signUp returns successfully. Both are fire-and-forget — if either one fails, the actual signup still works. We never block a real user because our logging table is having a bad day.
Day 62 also shipped a duplicate-email UX fix: Supabase's anti-enumeration default returns success with an empty identities array instead of an error when someone signs up with an already-registered email. We now detect that and show "This email is already registered" with a "Sign in instead" link.
But none of that did anything with the logged data. The table was filling up. Nothing was watching it.
Today, three pieces shipped to close the loop:
- A flag check in the precheck route
- A soft warning modal for flagged signups
- An admin tab to review flagged IPs
The flag check
The precheck route now runs one extra query before inserting the new row:
const since = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString()
const { count } = await supabase
.from('signup_attempts')
.select('id', { count: 'exact', head: true })
.eq('ip_address', ip)
.eq('outcome', 'completed')
.gte('attempted_at', since)
const flagged = (count ?? 0) >= 5
Five completed signups from the same IP in the last 24 hours. That's the threshold. If you're under it, signup proceeds normally — the response just has flagged: false and the frontend never knows the check happened. If you're over it, the response says flagged: true and the modal appears.
I picked 5 because it's low enough to catch the obvious patterns and high enough that I'd be shocked if a real user hit it. Most people sign up once. Some sign up twice (forgot their first account). A handful might do three or four for legitimate reasons. Five is where my charitable assumptions run out — but even then, the response isn't to block them. It's to ask once.
The modal
Here's what a flagged signup sees, before anything is sent to Supabase:
Multiple signups detected
We noticed several recent signups from your network. Each business needs its own account — are you sure you want to create a new one?
That's the whole defense. Two buttons. No captcha re-loop, no email verification gauntlet, no IP block.
If they click Continue anyway, we log flag_dismissed to that row and proceed with the signup normally. They get an account.
If they click Cancel, we log flag_shown_abandoned. They stay on the page. Captcha resets. They can try a different email or close the tab.
Both outcomes are data. A determined abuser will click Continue every time and the modal does nothing to stop them — but now I have a list of every IP that ignored the warning, when, with which emails. A real user who hit the modal because their agency office signed up four legitimate customers will see the message, recognize "yeah that's us," click Continue, and move on. Either way the data is captured for review later.
The signup logic was refactored into a helper called runSignup() that gets called from both the normal path and the modal's Continue button. Same code, two entry points. For a non-flagged signup, the behavior is byte-for-byte identical to Day 62.
The admin tab
That data is no use unless someone looks at it. The admin panel got a 10th tab today: Suspicious Signups.
The query is straightforward — group by IP, last 24 hours, only rows where the modal was actually shown (so we're looking at flagged events, not all signups). Then group by IP application-side. Each entry shows:
- IP address
- Total flagged events from that IP
- How many were abandoned vs continued
- The list of emails (first 5, then "+N more")
- First and last seen timestamps
- A Mark Reviewed button
The migration added three columns to signup_attempts: reviewed boolean default false, reviewed_at timestamptz, and reviewed_by uuid referencing auth.users. Mark Reviewed updates all flagged rows from that IP at once, stamps who reviewed it and when, and the row dims to half-opacity in the UI.
The point of "reviewed" is not enforcement. There's no automatic block. It's so when I open the tab on Day 73 and see 14 flagged IPs, I'm not staring at the same three from last week — I'm looking at the new ones.
What this isn't
This isn't a fraud detection system. It's a tripwire.
If someone wants to abuse Ominvo signups badly enough to rotate IPs through a VPN, the 24-hour window with a count of 5 won't see them. If they're running headless browsers through residential proxies, none of this catches them. That's fine. That's not the threat model.
The threat model is: someone clicks Sign Up four times in 20 minutes, gets confused, clicks four more times. Or someone tries to abuse the free tier by registering five businesses on the same machine. Or — most likely — a customer's office has a few legitimate accounts and we want to know that before assuming it's bad.
For real determined abuse, the actual defense is the per-business rate limits on /api/draft-reply, the graceful degradation work from Day 62, and the per-tier cost caps. The signup abuse layer is the front door — not the lock.
What broke during dogfooding
Nothing this time, which is unusual. Day 62 surfaced three real bugs during the post-deploy dogfood pass — the Manage Billing button locking permanently, the misleading "payment unavailable" message for accounts without a Stripe customer ID, and the duplicate-email silent success. Day 63 shipped clean on first attempt.
Worth saying out loud: that's not because the code was better written. It's because the surface area was smaller and more isolated. The signup precheck route doesn't share state with anything. The modal is a leaf component that gets mounted and unmounted with no side effects elsewhere. The admin tab is read-only against the database except for one UPDATE on its own dedicated columns.
Small, isolated, additive — that's the cheap stuff to ship. The hard stuff is the cross-cutting work like Day 62's graceful degradation, where a contract change in one API route had to be matched by three different UI consumers without breaking any existing path.
What's still on the list
We're 46 days out from launch. Big remaining items:
- Stripe live-mode cutover around August 5 — cancel test subs, audit hardcoded price IDs, re-verify webhook signing in live mode
- PRE_LAUNCH flag flip on July 23 — the two boolean flags on /lifetime and /pricing currently route to /waitlist; they need to point at Stripe Checkout
- Google Business Profile API approval — application window opens July 9, we're targeting July 23
- DKIM deliverability re-verification before launch traffic ramps
But the signup abuse layer — silent logging, soft warning, manual review — is now end-to-end and live. Three days of work across three commits, one logical story, closed.
Onto the next thing.
Building Ominvo in public — review management for small businesses. See the full changelog or open a dashboard account.
Written by
The founder of Ominvo
Building review management for single-location small businesses. Join the waitlist →