Better Auth
Check signups in Better Auth
Install the plugin and add it to your betterAuth config. It checks each signup before Better Auth creates the user.
Install
The @signupbouncer/better-auth plugin, from npm or JSR. It checks each email and password signup (the /sign-up/email endpoint) before Better Auth creates the user.
- Install the plugin. It needs better-auth 1.7 or later.
- Create an API key for the app on its Integration tab in your dashboard, then set SIGNUPBOUNCER_API_KEY in your server's environment to that key (sb_live_...).
- Add signupBouncer to the plugins in your betterAuth config, as in lib/auth.ts.
npm install @signupbouncer/better-auth@^0.2.0
# or, on Deno:
deno add jsr:@signupbouncer/better-auth@^0.2.0lib/auth.ts
// SignupBouncer signup check. Docs: https://signupbouncer.com/docs/code/better-auth
// 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 ipHeader, we still check the email. The plugin's README lists the header for Cloudflare, Vercel, Fly.io and your own proxies: https://github.com/signupbouncer/signupbouncer-better-auth
- With checkOtherSignups: true, the plugin also checks OAuth, magic-link and email OTP signups. It leaves out anonymous users, users created with the admin plugin and users your server code creates. Check those signups in your own code before you grant free credits.
- When Google, GitHub or another provider built into Better Auth has verified the email, the plugin says so, and SignupBouncer scores the IP and signup bursts only. Otherwise the email gets the full check, as it does for generic OAuth and SSO providers.
- A refused email and password signup gets a 400 answer with code SIGNUP_BLOCKED and the message from your Rules tab. On the client, authClient.signUp.email() returns it as error, so show error.message. The plugin lets a signup marked challenge through unless you set challenge: "block".
- When you turn on the "Why was this refused?" link in the Rules tab, the error body also holds why_url. The Better Auth client returns why_url on the sign-up error, next to message. Show why_url as a link after the message.
- A refused OAuth signup that goes through the provider's sign-in page, or a refused magic-link signup, ends in a redirect to your error callback URL, with error=SIGNUP_BLOCKED and the message in error_description. Show that message on the page. Better Auth drops the other fields, so the redirect has no why_url. Sign-in with a Google ID token (authClient.signIn.social with idToken) has no redirect. It gets the same 400 answer as email and password signup, why_url included.
What the code does when a check fails
In every snippet, the call has a 1.5-second limit, and the code lets the signup through on any failure: a timeout, a network error, a non-2xx answer or an unreadable or incomplete body. It refuses a signup only after a complete answer with "action" and "verdict" both "block", "mode" set to "enforce", and no quota or error flag. The exception is an address that can't be real, which the code refuses without a call: one over 320 characters, or, in the Better Auth plugin, one that isn't an address at all. New apps start in shadow mode, where "action" is always "allow". So the code refuses no other signup until you switch the app to enforce in your dashboard. Each snippet checks only the signups that pass through it. So if your app also creates users somewhere else, such as an OAuth callback or an admin tool, call the check there too.