emailverifier.dev
Posts

How to Block Disposable Email Signups With Better Auth

| 5 min read | Usama Ejaz
Better Auth before-hook cover with an email intercepted before the signup route completes.

Better Auth exposes the exact interception point needed for email-and-password signup:

hooks: {
  before: createAuthMiddleware(async (ctx) => {
    if (ctx.path !== '/sign-up/email') return

    // Check ctx.body.email here. Throw APIError to stop signup.
  }),
}

The before hook runs before the endpoint creates a user. This guide turns that hook into a runnable Node.js application with SQLite, one emailverifier.dev request, and a real Better Auth signup call.

The implementation covers email-and-password signup. It does not claim to cover OAuth, magic links, or administrator-created users.

Create a minimal Better Auth server

Use Node.js 22.5 or later so the example can use the built-in node:sqlite module documented by Better Auth. Create the project:

mkdir better-auth-email-check
cd better-auth-email-check
npm init -y
npm install better-auth express
npm install --save-dev typescript tsx @types/express
npm pkg set type=module
npm pkg set scripts.dev="node --env-file=.env --import=tsx server.ts"

Generate a high-entropy Better Auth secret:

node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"

Create an emailverifier.dev project and copy its API key. Create a .env file, exclude it from version control, and add:

BETTER_AUTH_SECRET=replace-with-the-generated-secret
BETTER_AUTH_URL=http://localhost:3000
EMAILVERIFIER_API_KEY=your-project-key

Better Auth's current installation guide defines the secret, base URL, database, migration, and email-and-password options used here.

Implement the before hook

Create auth.ts:

import { DatabaseSync } from 'node:sqlite'
import { betterAuth } from 'better-auth'
import { APIError, createAuthMiddleware } from 'better-auth/api'

type VerificationResult = {
  email: string
  status: 'deliverable' | 'risky' | 'undeliverable' | 'unknown'
  action: 'allow' | 'review' | 'block'
  flagged: boolean
  signals: string[]
  suggestion: string | null
}

function isVerificationResult(
  value: unknown,
): value is VerificationResult {
  if (!value || typeof value !== 'object') return false

  const result = value as Record<string, unknown>
  return (
    typeof result.email === 'string' &&
    ['deliverable', 'risky', 'undeliverable', 'unknown'].includes(
      String(result.status),
    ) &&
    ['allow', 'review', 'block'].includes(String(result.action)) &&
    typeof result.flagged === 'boolean' &&
    Array.isArray(result.signals) &&
    (result.suggestion === null ||
      typeof result.suggestion === 'string')
  )
}

async function checkSignupEmail(
  email: string,
): Promise<VerificationResult> {
  const apiKey = process.env.EMAILVERIFIER_API_KEY
  if (!apiKey) {
    throw new Error('EMAILVERIFIER_API_KEY is not set')
  }

  let response: Response
  try {
    response = await fetch(
      'https://emailverifier.dev/api/v1/verify',
      {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          'X-API-Key': apiKey,
        },
        body: JSON.stringify({ email }),
        signal: AbortSignal.timeout(4000),
      },
    )
  } catch {
    throw new Error('Email verification is temporarily unavailable')
  }

  if (!response.ok) {
    throw new Error('Email verification is temporarily unavailable')
  }

  const result: unknown = await response.json()
  if (!isVerificationResult(result)) {
    throw new Error('Email verification returned an invalid response')
  }

  return result
}

export const auth = betterAuth({
  database: new DatabaseSync('auth.sqlite'),
  emailAndPassword: {
    enabled: true,
  },
  hooks: {
    before: createAuthMiddleware(async (ctx) => {
      if (ctx.path !== '/sign-up/email') return

      const email = ctx.body?.email
      if (typeof email !== 'string' || !email.trim()) {
        throw new APIError('BAD_REQUEST', {
          message: 'Email is required.',
        })
      }

      const result = await checkSignupEmail(email.trim())
      if (result.action !== 'block') return

      if (result.signals.includes('disposable_address')) {
        throw new APIError('BAD_REQUEST', {
          message: 'Use a permanent email address.',
        })
      }

      throw new APIError('BAD_REQUEST', {
        message: result.suggestion
          ? `Check the address. Did you mean ${result.suggestion}?`
          : 'Check the email address and try again.',
      })
    }),
  },
})

The hook makes one external request and sends only { email }. Better Auth receives and hashes the password through its normal endpoint after the hook returns. A blocked result throws APIError before user creation.

The official Better Auth hooks guide uses the same /sign-up/email, createAuthMiddleware, and APIError pattern for pre-validation.

Mount Better Auth without consuming its request body

Create server.ts:

import express from 'express'
import { toNodeHandler } from 'better-auth/node'
import { auth } from './auth.js'

const app = express()

// Express 5 catch-all. Keep this before express.json() so Better Auth
// receives the original request body.
app.all('/api/auth/*splat', toNodeHandler(auth))

app.use(express.json())
app.get('/health', (_request, response) => {
  response.json({ status: 'ok' })
})

app.listen(3000, () => {
  console.log('Listening on http://localhost:3000')
})

Better Auth's Express integration guide warns against placing express.json() before the auth handler. It also documents the Express 5 *splat route used above.

Create the schema and call the real signup endpoint

Apply the built-in SQLite migration:

npx auth@latest migrate --yes

The migration command is supported by the built-in Kysely adapter and is documented in the current Better Auth CLI reference.

Start the server:

npm run dev

Submit an email-and-password signup:

curl http://localhost:3000/api/auth/sign-up/email \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Test User",
    "email": "person@example.com",
    "password": "correct-horse"
  }'

An accepted result continues into Better Auth and creates the user. A known disposable address returns the hook's 400 error and leaves no new user row.

How the four statuses reach Better Auth

The hook uses action for the decision and preserves the API's four-status model:

  • deliverable with allow continues. A deliverable role or catch-all address can return review and also continues to the next authentication step.
  • risky with a disposable signal returns block, so the hook throws before Better Auth writes the user.
  • undeliverable returns block after a confirmed failure.
  • unknown returns review. Keep email confirmation or restricted access because mailbox evidence was inconclusive.

The sample fails closed on a timeout, non-success response, or malformed payload by throwing an ordinary server error. For a low-cost account, you can return from those error paths instead, then require confirmation before granting anything expensive. Do not convert an upstream failure into “invalid email.”

The full response shape is in the emailverifier.dev REST API reference. Use the disposable-email policy guide to decide whether the benefit behind this Better Auth signup is valuable enough to fail closed.

Continue reading