I build SignupBouncer, the signup check in this guide. The original is on signupbouncer.com.
Vercel's Next.js SaaS starter lets anyone sign up with any email address, including burner addresses from disposable email services. The same person can also sign up again and again by moving a dot or adding a +tag to a Gmail address. This guide adds a SignupBouncer check to signUp, the server action that creates the user, so your form refuses burner addresses and repeat signups.
- Get the starter running on your machine. You'll run Postgres in Docker and write
.envby hand. - Create a SignupBouncer API key and copy our helper file into
lib/signupbouncer.ts. - Call
checkSignupinsignUp, before it hashes the password. - In production, set
SIGNUPBOUNCER_IP_HEADERto the header that holds the client's IP. - Try a few signups in shadow mode and see how we marked them on your dashboard. Then switch your app to Enforce.
- Lowercase the email in the starter's schemas, so an address typed with capitals matches the same account.
The starter sends no confirmation email, so someone can even sign up with an address at a domain that can't receive mail. Every new account comes with a team, and every team can start the 14-day trial the starter sets up at Stripe Checkout.
A refused person sees an error in the signup form you already have. Someone who keeps signing up with the same Gmail inbox gets through the first few times. After that, your form refuses them.
The finished code is in signupbouncer/nextjs-saas-starter-signup-protection. We built it from the starter at commit 6e33e58 (11 Dec 2025) and moved it to Next.js 16.4.2. The finished code keeps the starter's MIT license.
Get the starter running
If the starter already runs on your machine, do the Next.js upgrade below and then skip ahead to getting a SignupBouncer key. If it doesn't, clone the starter and install its packages:
git clone https://github.com/nextjs/saas-starter
cd saas-starter
pnpm install
The starter pins a Next.js 15.6 canary, which you'll see under next in your package.json. That canary came out before the fix for CVE-2026-96889, a critical remote code execution bug in Next.js. Move the starter to Next.js 16.4.2:
pnpm add next@16.4.2
Next.js 16 doesn't accept the starter's experimental ppr flag. In next.config.ts, delete the whole experimental block, which holds ppr and clientSegmentCache. That leaves nextConfig as an empty object.
The starter's pnpm db:setup script writes .env for you, but it needs Docker and the Stripe CLI. The SignupBouncer check doesn't need Stripe, so you can write .env by hand. Start Postgres in Docker on port 54322, the port pnpm db:setup uses:
docker run -d --name saas-starter-db -p 127.0.0.1:54322:5432 -e POSTGRES_PASSWORD=postgres postgres:16.4-alpine
The 127.0.0.1: in the docker run command makes the database reachable from your own machine only. Without 127.0.0.1:, Docker opens port 54322 on every network interface. Anyone on your network could then log in with the password postgres.
Then create .env:
POSTGRES_URL=postgres://postgres:postgres@127.0.0.1:54322/postgres
STRIPE_SECRET_KEY=sk_test_placeholder
STRIPE_WEBHOOK_SECRET=whsec_placeholder
BASE_URL=http://localhost:3000
AUTH_SECRET=replace-with-a-long-random-string
For AUTH_SECRET, any long random string works, and openssl rand -base64 32 makes one. Signup works with the placeholder Stripe values.
You'll need your Stripe test keys for the pricing page, Checkout and pnpm build, because they call Stripe. The starter's README covers the Stripe setup.
Create the tables, then start the app:
pnpm db:migrate
pnpm dev
Don't run pnpm db:seed. It creates products in your Stripe account.
The first time you run pnpm dev, Next.js rewrites a few lines of tsconfig.json, so git status shows that file as changed. pnpm dev also warns that middleware is deprecated. The warning comes from the upstream starter, and signup works with it.
Get a SignupBouncer key
Sign up for SignupBouncer. On the Free plan, you get 1,000 checks a month and you don't need a card.
On your app's Integration tab, choose "Create API key". We show the key only once, so copy it right away. Keys start with sb_live_.
Add the helper file
The helper is the file lib/signupbouncer.ts, and it has no dependencies. Download it to the same path in your app:
curl -o lib/signupbouncer.ts https://raw.githubusercontent.com/signupbouncer/nextjs-saas-starter-signup-protection/master/lib/signupbouncer.ts
Add these lines to .env.example and to .env. In .env, replace sb_live_*** with the key you copied:
# signupbouncer: your sb_live_... key. Server only, never NEXT_PUBLIC_.
SIGNUPBOUNCER_API_KEY=sb_live_***
# The header your host overwrites with the client IP. On Vercel: x-real-ip.
# Leave it unset and the email is checked alone.
# SIGNUPBOUNCER_IP_HEADER=x-real-ip
# Only for a local signupbouncer API. Leave unset in production.
# SIGNUPBOUNCER_BASE_URL=http://127.0.0.1:8080
SIGNUPBOUNCER_API_KEY has no NEXT_PUBLIC_ prefix, so the key stays on the server. Leave SIGNUPBOUNCER_BASE_URL unset, because it's only for our own tests against a local copy of SignupBouncer.
Call the check in signUp
Open app/(login)/actions.ts. Replace the line that imports cookies from next/headers with these lines:
import { cookies, headers } from 'next/headers';
import {
blockMessage,
checkSignup,
clientIp,
shouldBlock
} from '@/lib/signupbouncer';
In signUp, find the existingUser check and the hashPassword line after it. Add the SignupBouncer check between them. In the code below, the existingUser check at the top and the hashPassword line at the bottom are already in your file:
if (existingUser.length > 0) {
return {
error: 'Failed to create user. Please try again.',
email,
password
};
}
// Refuse burner, unreachable and repeat sign-ups before the user exists.
// If signupbouncer is slow or down, the sign-up goes through.
const h = await headers();
const check = await checkSignup(
{
apiKey: process.env.SIGNUPBOUNCER_API_KEY ?? '',
baseUrl: process.env.SIGNUPBOUNCER_BASE_URL || undefined,
onError: (e) => console.warn('signupbouncer:', e.reason, e.status ?? '')
},
{
email,
ip: clientIp(h, process.env.SIGNUPBOUNCER_IP_HEADER),
userAgent: h.get('user-agent') ?? undefined
}
);
if (shouldBlock(check)) {
return { error: blockMessage(check), email, password };
}
const passwordHash = await hashPassword(password);
checkSignup sends us the email and the user agent. It sends the client IP too once you set SIGNUPBOUNCER_IP_HEADER. shouldBlock(check) returns true when our answer's action is "block".
When shouldBlock returns true, signUp returns the block message in the starter's own { error } state. The signup form already displays error, so you don't need to edit the signup page.
The check runs before hashPassword. So for a refused signup, the starter skips the password hashing and creates no user and no team.
blockMessage(check) returns the block message you set on your app's Rules tab in the dashboard. If you keep the default message, the wording depends on the reason for the refusal. Someone signing up with a burner domain sees "Please sign up with a permanent email address." Someone who mistypes a big provider's domain sees "Did you mean ann@gmail.com?"
If our answer has no message, blockMessage falls back to DEFAULT_BLOCK_MESSAGE, which is the burner-domain wording. To use your own words, change the message on the Rules tab, or return your own string instead of blockMessage(check).
checkSignup never throws. It returns action: "allow" on a timeout after 1.5 seconds, a network error, an error status or an incomplete answer. It also calls onError, which logs a fixed reason such as signupbouncer: timeout.
Your app refuses a signup only when it gets a complete answer from us. If SignupBouncer is slow or down, the signup goes through. An outage on our side delays a signup by 1.5 seconds at most.
In our test run with the SignupBouncer API stopped, a new user still reached the starter's dashboard in about a tenth of a second.
The client IP
On your machine, the check runs without the client's IP. In production, set SIGNUPBOUNCER_IP_HEADER to the header your host overwrites with the client's IP.
On Vercel the header is x-real-ip. Add SIGNUPBOUNCER_IP_HEADER=x-real-ip to your Vercel project's environment variables, next to SIGNUPBOUNCER_API_KEY. For Cloudflare and Fly.io, the comment above clientIp() in lib/signupbouncer.ts names the header to use.
clientIp() reads only the header you name. Clients can send X-Forwarded-For or X-Real-IP themselves. If your host doesn't replace the header you name, the header holds whatever the client sent.
On the Starter plan and above, we add points for datacenter, VPN and Tor addresses. A VPN or Tor address on its own never scores enough for a block.
On the Free plan, we still run the IP checks, but their points don't change the score. The answer's shadow field lists the points a paid plan would add.
Shadow mode first, then enforce
Every SignupBouncer app starts in shadow mode. We check and log each signup, and action is always "allow". So shouldBlock returns false and every signup gets an account.
Sign up a few times through your form, once with an address at mailinator.com. Test signups count toward your monthly checks.
Open the Checks tab on your dashboard. The mailinator.com signup shows as block, and its account exists all the same.
When the results look right, open your app's Rules tab. Choose Enforce next to Mode, then Turn on blocking in the window that opens. From then on, your form refuses any signup marked block.
Lowercase the email
The upstream starter stores an email exactly as it was typed, so Test@test.com and test@test.com become two accounts (nextjs/saas-starter#184). To fix it, add .trim().toLowerCase() after z.string() in every schema in app/(login)/actions.ts that reads an email.
Start with signInSchema:
const signInSchema = z.object({
email: z.string().trim().toLowerCase().email().min(3).max(255),
password: z.string().min(8).max(100)
});
Add .trim().toLowerCase() to signUpSchema too:
const signUpSchema = z.object({
// One account per address, whatever its case (nextjs/saas-starter#184).
email: z.string().trim().toLowerCase().email(),
password: z.string().min(8),
inviteId: z.string().optional()
});
Change updateAccountSchema, which the account settings form uses:
const updateAccountSchema = z.object({
name: z.string().min(1, 'Name is required').max(100),
email: z.string().trim().toLowerCase().email('Invalid email address')
});
Change inviteTeamMemberSchema, which the team invite form uses:
const inviteTeamMemberSchema = z.object({
email: z.string().trim().toLowerCase().email('Invalid email address'),
role: z.enum(['member', 'owner'])
});
Invitations sent before this change can still have capitals in their stored address. In signUp, find the query that matches an invite link to its invitation, and change it to compare the stored address in lower case.
The changed query uses sql, which the file already imports from drizzle-orm:
const [invitation] = await db
.select()
.from(invitations)
.where(
and(
eq(invitations.id, parseInt(inviteId)),
// Invitations sent before the lowercase change can hold capitals.
eq(sql`lower(${invitations.email})`, email),
eq(invitations.status, 'pending')
)
)
.limit(1);
After the change, an invitation sent to Ann@gmail.com matches a signup as ann@gmail.com. Upstream's signUp creates the user before it looks up the invitation. So a signup through an expired invite link still leaves an account with no team.
The schema change only lowercases new input. If your database already has emails with capitals, lowercase them once.
The email column of users is unique, so first find accounts that differ only by case, and merge or rename them. Then lowercase the emails in users and invitations together:
select lower(email), count(*) from users group by 1 having count(*) > 1;
update users set email = lower(email) where email <> lower(email);
update invitations set email = lower(email) where email <> lower(email);
Repeat signups from the same inbox
Gmail ignores dots in an address and anything after a +. So a.b@gmail.com, ab+1@gmail.com and a.b+2@gmail.com all reach the same inbox.
On every plan, we treat those Gmail spellings as the same inbox. We count how many times the inbox signed up over the last 7 days, across every app on your SignupBouncer account.
Each repeat adds points to the signup: 25 for the second, 40 for the third, 55 for the fourth, 70 for the fifth and 75 from the sixth on. The second signup stays below the challenge cutoff, because people do sign up again after they lose a password.
With the default cutoffs, a score of 40 marks a signup challenge and a score of 70 marks it block. So we mark the third and fourth signups challenge. They still get accounts, because shouldBlock(check) returns false for a challenge.
Your form refuses the fifth signup. In our test run, the same inbox got four accounts, and the form refused its fifth spelling.
To refuse from the third signup on, change the condition to shouldBlock(check, 'block'). A challenge is any score between the two cutoffs. So shouldBlock(check, 'block') also refuses other signups marked challenge, such as an address on a free subdomain like ann@foo.duckdns.org (45 points).
On the Pro plan, velocity rules add points when many signups arrive in a short time from the same network, or from the same inbox in several spellings. Velocity points can raise a signup to challenge but never to block. So your form refuses a signup over velocity points only when you refuse challenges.
What the check doesn't stop
A person with several real inboxes at different providers, say a Gmail and an Outlook address, gets an account with each. We can't link two unrelated inboxes to the same person, so each address starts its own repeat count.
We still score each of those signups on its email address and its network. On the Starter plan and above, we add points to a signup from a datacenter, VPN or Tor address, whichever inbox the person uses.
The check doesn't prove that the person owns the address. For that, add email verification to the starter.
On the Free plan, once you've used 1,000 checks in a month, signups go through unchecked until the month ends.
Run the finished code
git clone https://github.com/signupbouncer/nextjs-saas-starter-signup-protection
cd nextjs-saas-starter-signup-protection
pnpm install
cp .env.example .env
Fill in .env the same way as in the first section, and set SIGNUPBOUNCER_API_KEY to your key. Then create the tables and start the app:
pnpm db:migrate
pnpm dev
Open http://localhost:3000/sign-up and sign up with an address at mailinator.com. With your app set to Enforce, the form shows "Please sign up with a permanent email address." and the starter creates no account.
The repo keeps the starter's own README as README.upstream.md, with the Stripe setup and the steps for going to production.
Where to go next
- signupbouncer/nextjs-saas-starter-signup-protection has the finished code and its tests.
- The REST API reference lists every field in our answer, and the errors.
- The reason codes table shows what each reason means and how the points add up.
- Shadow and enforce shows what each mode does with a check's result.

Top comments (0)