Email Verification#
Overview#
Better Auth's email verification is configured under the emailVerification key in betterAuth({...}). It is decoupled from the core auth flow — verification only activates when sendVerificationEmail is provided. The main implementation lives in email-verification.ts with two endpoints: POST /send-verification-email and GET /verify-email.
Configuration Options#
All options are defined under emailVerification in init-options.ts:
| Option | Type | Default | Description |
|---|---|---|---|
sendVerificationEmail | async fn | — | Required to activate verification. Receives { user, url, token } and the Request object. |
sendOnSignUp | boolean | undefined | undefined | true = always send on sign-up; false = never; undefined = follows requireEmailVerification. |
sendOnSignIn | boolean | false | Re-sends verification on sign-in if email is unverified. |
autoSignInAfterVerification | boolean | — | Signs the user in automatically after successful verification. |
expiresIn | number | 3600 | Token expiration in seconds (1 hour default). |
beforeEmailVerification | async fn | — | Hook called before verification completes. |
afterEmailVerification | async fn | — | Hook called after verification completes. |
requireEmailVerification lives under emailAndPassword : when true, sign-in is blocked until the email is verified and sendVerificationEmail is called on every sign-in attempt.
Avoid awaiting sendEmail inside the callback to prevent timing attacks. On serverless platforms use waitUntil or equivalent to ensure delivery without blocking the response.
OWASP Enumeration Protection#
Better Auth follows OWASP authentication best practices in two ways:
Constant-time sign-up response#
When requireEmailVerification: true or autoSignIn: false, the POST /sign-up/email endpoint returns a 200 for both new and existing emails — no 422 leaks the existence of an account . The /change-email endpoint similarly always returns success regardless of whether the new email is already taken.
Timing attack mitigation on /send-verification-email#
When no active session is present, the endpoint enforces a 500 ms minimum response floor so that fast paths (email not found, already verified) are indistinguishable from slow paths (email found, send in progress) .
Synthetic user for plugin-extended schemas#
If plugins add fields to the user table (e.g., admin, two-factor), the synthetic response must include those fields to be structurally identical to a real sign-up. Use customSyntheticUser to assemble the full object in database schema order:
customSyntheticUser: ({ coreFields, additionalFields, id }) => ({
...coreFields,
role: "user", banned: false, banReason: null, banExpires: null,
...additionalFields,
id,
})
See admin plugin enumeration protection docs for a per-plugin field list.
Verification Lifecycle#
Triggering verification#
Verification emails are sent at three trigger points:
- Sign-up — automatically when
sendOnSignUp: trueor whenrequireEmailVerificationis enabled . - Sign-in — when
sendOnSignIn: trueand the email is still unverified . - Manual — client calls
authClient.sendVerificationEmail({ email, callbackURL }).
Token format#
Tokens are HS256 JWTs with configurable expiration. The GET /verify-email endpoint validates the JWT, then marks emailVerified: true and optionally auto-signs-in the user . An invalid or expired token redirects to callbackURL?error=invalid_token.
Lifecycle callbacks#
beforeEmailVerification and afterEmailVerification fire around the verification commit . Use these for audit logging, downstream syncs, or blocking verification under custom conditions.
Email change flow#
Email changes go through a two-step verification :
change-email-confirmation— user confirms intent; a token is sent to the new email.change-email-verification— user verifies the new email; the profile is updated withemailVerified: true.
A legacy single-step path is supported for backward compatibility .
Unverified sign-in error#
When requireEmailVerification is enabled and a user attempts sign-in without verifying, the API returns 403. Handle this client-side to prompt re-verification .
Managed Email Service (@better-auth/infra)#
Better Auth Infrastructure provides a managed transactional email service with pre-built templates so you can skip managing your own email provider .
Install & configure:
import { sendEmail } from "@better-auth/infra";
// BETTER_AUTH_API_KEY env var is read automatically
Use the verify-email template inside sendVerificationEmail :
emailVerification: {
sendOnSignUp: true,
async sendVerificationEmail({ user, url }) {
await sendEmail({
template: "verify-email",
to: user.email,
variables: { verificationUrl: url, userEmail: user.email, userName: user.name, appName: "Your App" },
});
},
}
The service supports multiple providers (AWS SES, SendGrid, Resend) and additional templates: reset-password, change-email, verify-email-otp, magic-link, two-factor, and more . Transactional email requires Pro plan or above .
Key Source References#
| Resource | Link |
|---|---|
| Email verification routes | email-verification.ts |
| Config type definitions | init-options.ts |
| Email & Password docs | better-auth.com/docs/authentication/email-password |
| Managed email service docs | better-auth.com/docs/infrastructure/services/email |
| Sign-up route (enumeration) | sign-up.ts |