Better Auth plugin
Refuse disposable emails and repeat signups in Better Auth
Add @signupbouncer/better-auth to your Better Auth config. It checks each email and password signup with SignupBouncer before Better Auth creates the user, and refuses the ones marked block. One option adds OAuth, magic link and email OTP signups.
- Create a free account, then create an API key on your app's Integration tab.
- Install the plugin from npm or JSR.
- Add
signupBouncerto the plugins in yourbetterAuthconfig. - Watch your signups on the Checks tab in shadow mode, then switch the app to enforce.
What it refuses
The plugin checks every email and password signup (POST /sign-up/email) before Better Auth creates the user. It also checks the user Better Auth is about to save, through a user.create.before database hook. When that address is the one it already checked, it makes no second request. Set checkOtherSignups: true to check OAuth, magic link and email OTP signups too (below).
It refuses a signup when we mark it block. On every plan, that covers:
- a disposable email domain, or a domain whose mail servers serve only disposable domains
- a domain that cannot receive email
- the same inbox signing up again under Gmail dots or a
+tag. Each repeat adds points. At the default cutoffs, the fifth spelling of one inbox in 7 days is refused.
A signup marked challenge goes through, because Better Auth has no captcha step to send it to. Set challenge: "block" to refuse those too.
The plugin also refuses an email that it can't check, without asking us: one longer than 320 characters, or one with a space or without text on both sides of an @. This happens in shadow mode too.
Install
The plugin has no dependencies of its own. It needs better-auth 1.7 or later, and on Node it needs Node 22 or later.
npm install @signupbouncer/better-auth@^0.2.0
# or, on Deno:
deno add jsr:@signupbouncer/better-auth@^0.2.0Create an API key on your app's Integration tab in the dashboard. Set SIGNUPBOUNCER_API_KEY in your server's environment to that key (sb_live_...). Then add the plugin to your config:
lib/auth.ts
// lib/auth.ts: your Better Auth config with the SignupBouncer plugin, which checks each email and password signup
// before Better Auth creates the user.
import { betterAuth } from "better-auth";
import { signupBouncer } from "@signupbouncer/better-auth";
export const auth = betterAuth({
// ...your database and the rest of your Better Auth config
emailAndPassword: { enabled: true },
plugins: [
signupBouncer({
apiKey: process.env.SIGNUPBOUNCER_API_KEY!,
// The header your host sets to the client IP (Vercel: "x-real-ip", Cloudflare: "cf-connecting-ip"). Without it,
// only the email is checked.
// ipHeader: "x-real-ip",
// Also check OAuth, magic link and email OTP signups:
// checkOtherSignups: true,
}),
],
});
Set ipHeader only to a header your host overwrites with the client's address. Without it, we check the email alone. The plugin's README lists the header for Cloudflare, Vercel, Fly.io and your own proxies.
This config checks email and password signups. If people also sign up with Google, GitHub, a magic link or an email code, uncomment checkOtherSignups: true.
Shadow mode first
New SignupBouncer apps start in shadow mode. We check and log each signup the plugin sends, and refuse none of them. The plugin still refuses an email that it can't check, as above. When the log looks right, switch the app to enforce in the dashboard. The plugin needs no change, because it acts on the answer's action, which is always "allow" in shadow mode.
What a refused user sees
A refused signup gets HTTP 400 with this body:
{
"code": "SIGNUP_BLOCKED",
"message": "Please sign up with a permanent email address.",
"why_url": "https://signupbouncer.com/why/mailinator.com"
}The message is the one you set on the Rules tab of your dashboard. While you keep the default there, the words fit the reason:
- a disposable domain: "Please sign up with a permanent email address."
- a typo of a big mail provider: "Did you mean ann@gmail.com? Please check your email address and try again."
- any other reason: "We couldn't create your account. Please contact us if you need help."
why_url is there when you switch on "Add a 'Why was this refused?' link" under Advanced on the Rules tab. It goes to our page about the email's domain, which says how to sign up another way. The link carries the domain only, never the full address.
On the client, authClient.signUp.email() returns the body as error. Show the message, and the link when there is one:
const { error } = await authClient.signUp.email({ email, password, name });
// why_url is not in Better Auth's error type, but the client returns every field of the body.
const whyUrl = (error as { why_url?: string } | null)?.why_url;
// Where your form shows the error (JSX):
<p role="alert">
{error?.message} {whyUrl && <a href={whyUrl}>Why was this refused?</a>}
</p>;OAuth, magic link and email OTP signups
With checkOtherSignups: true, the plugin also checks users created by OAuth, ID-token sign-in, Google One Tap, magic link, email OTP and other plugins.
- For a signup through one of your
socialProviders, such as Google or GitHub, the plugin sends the provider and whether it verified the address. That is Better Auth's ownemailVerifiedflag, never something the user typed. When it is verified, we score the IP and signup bursts only, so a rule about the email cannot refuse it. - Generic OAuth and SSO providers get the full email check, since your app or your users pick their names.
- A refused OAuth or magic-link signup ends in a redirect to your error callback URL, with
error=SIGNUP_BLOCKEDand the message inerror_description. Better Auth drops the other fields, so the redirect has nowhy_url. ID-token sign-in gets the 400 body above.
When SignupBouncer is slow or down
The signup goes through. By default your users wait at most 1.5 seconds on the check (timeoutMs, up to 10 seconds). A timeout, a network error, an error status such as 401 for a wrong key, or an incomplete answer each let the signup through. Apart from an email that the plugin can't check, only a complete answer can refuse a signup.
Each failure calls onError with a fixed reason and the status code, or writes one console.warn line without it. Neither holds the API key, the email or the IP, so you can log them whole.
What it doesn't check
- OAuth, magic link and email OTP signups, unless you set
checkOtherSignups: true. - Sign-ins, password resets, sessions and anything else that doesn't create a user.
- Users your admins create through
/admin/*, anonymous sign-in, and users your server code creates without a request. Check those in your own code before you grant free credits. - An address that your own
databaseHooks.user.create.beforechanges. Your hook runs after the plugin's, so the plugin checked the earlier address. - On the Free plan, signups past 1,000 checks in a month go through unchecked until the month ends.
More
- Better Auth in our docs, next to the REST API and the reason codes.
- The plugin's README on GitHub, with every option and the client IP setup.
- The package on npm and JSR. MIT licence.
Start on the Free plan, in shadow mode
1 app and 1,000 checks a month. No card. Shadow mode logs what we would refuse until you switch the app to enforce.