How to Block Disposable Email Signups With Better Auth
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:
deliverablewithallowcontinues. A deliverable role or catch-all address can returnreviewand also continues to the next authentication step.riskywith a disposable signal returnsblock, so the hook throws before Better Auth writes the user.undeliverablereturnsblockafter a confirmed failure.unknownreturnsreview. 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.