Integration guide · Better Auth

How to block disposable emails in Better Auth

better-auth-vetto is a plugin that rejects throwaway sign-up emails before the account row exists. The list ships with the package, so there is no API call, no key and nothing to pay for.

By the Vetto team · Updated

Quick answer

Install better-auth-vetto and add vetto() to the plugins array. It registers a before hook on the sign-up endpoints and answers 400 for throwaway domains, so no user row is ever created. It is MIT, it costs nothing, and it works without a Vetto account.

1. Install

terminal
npm install better-auth-vetto

2. Add the plugin

auth.ts
// auth.ts
import { betterAuth } from 'better-auth';
import { vetto } from 'better-auth-vetto';

export const auth = betterAuth({
  emailAndPassword: { enabled: true },
  plugins: [
    vetto({
      checkMx: true,                     // also reject domains that can't receive mail
      allow: ['partner-company.com'],    // never block these
      block: ['some-new-throwaway.com'], // domains you saw before the list did
    }),
  ],
});

That is the whole integration. Every option is optional: with no arguments the plugin blocks the bundled list, allows privacy relays and leaves everything else alone.

What it catches, and what it lets through

  • Throwaway inboxes: mailinator.com, yopmail.com, temp-mail.org and ~9,900 more. Subdomains match too, so x.mailinator.com is caught.
  • Dead domains, with checkMx: true: no mail server, a null MX record or a domain that does not resolve. Your confirmation email could never arrive anyway. DNS errors and timeouts let the signup through.
  • Privacy relays are allowed: Apple Hide My Email, DuckDuckGo, Firefox Relay, SimpleLogin, Proton Pass, addy.io and 33mail. They forward to a real inbox, and four of the seven most popular blocklists get this wrong.

Use it without the plugin

The same helpers are exported on their own, which is handy in a signup form, a back-office screen or a one-off script.

anywhere.ts
// Without the plugin: the same verdict for one address.
import { checkEmail, canonicalEmail } from 'better-auth-vetto';

await checkEmail('jane@mailinator.com');        // 'disposable'
await checkEmail('jane@duck.com');              // 'ok'  (privacy relay)
await checkEmail('jane@nope.invalid', {         // 'dead' (no mail server)
  checkMx: true,
});

// One key for every alias of the same Gmail inbox:
canonicalEmail('j.a.n.e+trial7@googlemail.com'); // 'jane@gmail.com'

Stop one person opening many free trials

Gmail ignores dots and anything after a +, so j.a.n.e+trial7@gmail.com and jane@gmail.com are the same inbox. A blocklist cannot see that. Store a canonical key with a unique index instead:

schema.ts
// schema.ts — stop the 7th free trial from one inbox
import { canonicalEmail } from 'better-auth-vetto';

// Store it next to the real address, with a unique index.
await db.insert(users).values({
  email,                              // what you send mail to
  emailKey: canonicalEmail(email),    // unique index on this column
});

Keep the address the user typed for sending mail; the key is only for uniqueness. Dots are only stripped for Gmail and googlemail.com, because other providers treat them as significant.

Source and issues: github.com/yasaiiko/better-auth-vetto. Related: Auth.js / NextAuth · Supabase · Next.js · free trial abuse.

Frequently asked questions

How do I block disposable emails in Better Auth?

Add the better-auth-vetto plugin to the plugins array in betterAuth(). It runs as a before hook on sign-up, change-email, magic-link and email-OTP, and rejects the request with a 400 before the account row is created.

Does it call an API?

No. The blocklist of around 9,900 domains is bundled with the package, so the check is a lookup in memory with no network call and no API key. Only the optional checkMx setting does DNS, and it fails open.

Will it block Apple Hide My Email or Firefox Relay?

No. Privacy relays forward to an inbox the person actually reads, so they are allowed by default. Blocking them costs you real paying customers. Set allowRelays: false if you want them treated as disposable anyway.

Which endpoints does the plugin check?

By default /sign-up/email, /change-email, /sign-in/magic-link and /email-otp/send-verification-otp. Pass paths to change that list, for example to cover a custom endpoint.

What happens to the user when an address is rejected?

The request gets a 400 with a message you can show next to the email field. You can change the wording with the message and deadMessage options.

Does it work on Cloudflare Workers or Bun?

Yes. The blocklist check is pure JavaScript and runs anywhere. The optional checkMx setting needs node:dns, and where that is unavailable the dead-domain check is skipped rather than failing.

Give your signup form sharp eyes.

Free on one site. One line of HTML. Remove it any time and your form works exactly as before.

Founding offer: Pro for life for $69 once, first 100 buyers, until Nov 27.