Email verification
Closing the email+password sign-up loop with Better Auth email verification, where one click flips emailVerified and signs the user in.
Password sign-up queued a verification email and wrote three rows, then stopped with no session.
Password sign-in refused anyone whose emailVerified was still false, returning 'email-not-verified'.
Both lessons left the same gap: the email arrives, the link flips the flag, and the app opens.
This lesson closes that gap.
Clicking the link in the inbox flips emailVerified from false to true, deletes the pending row, and, with one config flag, drops the user straight into the app, signed in.
Behind a feature that looks like “send an email” sit four questions: what the link carries and where the matching secret lives, the exact sequence from click to signed-in, what stops a forwarded or stale link from being replayed, and whether the new verify endpoint and resend button leak whether an email exists.
One boundary: this lesson does not cover password reset, magic links, or changing your email from settings. Each reuses the machinery you build here, and each is named where it reappears.
Turning the email on
Section titled “Turning the email on”Sign-up named sendVerificationEmail as the seam the email rides on, then left it empty.
The smallest config that makes the email send is one block, plus a few knobs that shape what happens after the click.
You turn it on the way you turned on sign-up: one block dropped into betterAuth({ ... }) in lib/auth.ts.
Sign-up added emailAndPassword; verification adds an emailVerification block beside it, a sibling, not a nested option.
Most of the block is decisions, not logic.
Here’s the whole on-switch. Each knob is a library default, and your job is to decide where you agree.
emailVerification: { sendVerificationEmail: async ({ user, url }) => { await sendEmail({ to: user.email, subject: 'Verify your email', react: VerifyEmail({ url }), }); }, sendOnSignIn: true, autoSignInAfterVerification: true, expiresIn: 60 * 60,},The seam sign-up named is now filled. Better Auth calls this whenever a verification email needs to go out, handing you { user, url }: user.email is the recipient and url is the fully-formed verify link with the token already embedded. You don’t mint the token or build the URL. Your whole job is one line: pass the link to the sendEmail wrapper you built in the email unit. The library produces the secret; the callback delivers it.
emailVerification: { sendVerificationEmail: async ({ user, url }) => { await sendEmail({ to: user.email, subject: 'Verify your email', react: VerifyEmail({ url }), }); }, sendOnSignIn: true, autoSignInAfterVerification: true, expiresIn: 60 * 60,},This pays the last lesson’s debt. When an unverified user signs in, Better Auth re-fires sendVerificationEmail automatically, which is why the sign-in form’s 'email-not-verified' branch could say “we re-sent your link, check your inbox” without making the call itself. It only re-sends for users who are not yet verified; a verified user signing in triggers nothing. The default is false.
emailVerification: { sendVerificationEmail: async ({ user, url }) => { await sendEmail({ to: user.email, subject: 'Verify your email', react: VerifyEmail({ url }), }); }, sendOnSignIn: true, autoSignInAfterVerification: true, expiresIn: 60 * 60,},This shapes the moment after the click. On, the verify endpoint doesn’t just flip the flag, it issues a session, and the user lands signed in. Off, the library default, the click flips the flag and redirects, but the user signs in by hand. For a consumer app in 2026 the call is on: the click already proved the user controls that inbox, so a second sign-in is pure friction. The exception is high-stakes surfaces like banking and admin consoles, where you may want an explicit sign-in even right after verifying.
emailVerification: { sendVerificationEmail: async ({ user, url }) => { await sendEmail({ to: user.email, subject: 'Verify your email', react: VerifyEmail({ url }), }); }, sendOnSignIn: true, autoSignInAfterVerification: true, expiresIn: 60 * 60,},60 * 60 seconds is one hour, written out so the arithmetic is obvious. Here you’re agreeing with the default, and the reason is the point: an hour is long enough that someone checking email after lunch still has a working link, and short enough that a link leaked into a forwarded thread or a server log is mostly inert by the time anyone finds it. Hold the number loosely. Password reset, later in this chapter, uses a shorter ten minutes because the stakes are higher, and a magic link is shorter still because the link itself is the credential.
One delivery callback and three trust-and-UX decisions. Everything else, the token machinery, the endpoint that handles the click, and the email body, the library hands you, and the rest of this lesson opens each in turn.
The token, and why it never touches your database in the clear
Section titled “The token, and why it never touches your database in the clear”The user gets a link with a token in it. Where does the matching secret live, and what stops a leaked database backup from becoming a pile of working verification links?
When sendVerificationEmail fires, one row appears in the verification table that sign-up first wrote to.
Three columns matter here: identifier holds the email, value holds the token, and expiresAt holds now plus the hour you configured.
That word “token” hides the whole security lesson, because the token in the email and the token in the database are not the same string.
The raw token travels in the link; the library hashes it before it ever reaches value.
Two properties follow from that split.
The first: a database snapshot is inert.
Since only the hash is stored, an attacker who exfiltrates the entire verification table holds hashes, not the secrets that produce them, and cannot reconstruct a single working link.
This is the same principle as passwords: the secret the user holds is never the secret you store.
The second: guessing is intractable. The raw token is generated from a CSPRNG with enough entropy that producing a valid one by guessing never happens in practice.
The thing in the inbox and the thing in the database, side by side:
verification row
what you store The hash also makes the click fast and safe to check.
When the link arrives, the library hashes the incoming token and compares it against value in constant time , the same defense you saw for password verification.
A verification token is a bearer token : holding it is enough, which is why how the token’s life ends matters so much.
The row is deleted the instant verification succeeds, so one-time use needs no flag to check, a second click finds nothing.
The row also expires: the lookup ignores anything past expiresAt, and a periodic cleanup job sweeps the stragglers.
Deletion and expiry are what stand between a token leaked into a logfile and a working account takeover.
What happens when the user clicks
Section titled “What happens when the user clicks”The user clicks the link in their inbox. Which route handles it, and what’s the exact sequence from click to signed-in, or from click to “this link is dead”?
The link looks like this:
https://app.example.com/api/auth/verify-email?token=<token>&callbackURL=<dest>The path is the same /api/auth/* catch-all you mounted when you set Better Auth up, the [...all] route that handles every auth request.
No new route file: you already built the handler, you just never exercised this path through it.
The callbackURL is the post-verify destination, the same one sign-up passed to signUp.email({ callbackURL }), threaded through the link and back.
The library runs a fixed sequence for you.
On the happy path it hashes the incoming token, looks up the verification row by that hashed value, checks expiresAt, flips user.emailVerified to true, deletes the row, issues a session if autoSignInAfterVerification is on, then redirects to callbackURL.
Scrub through it one stage at a time:
+ check expiry
emailVerified
[...all] catch-all you mounted when you set up Better
Auth — no new route.
+ check expiry
emailVerified
+ check expiry
emailVerified
verification row by the hashed value and
checks expiresAt. Missing, expired, or already used →
the link is dead.
+ check expiry
emailVerified
user.emailVerified flips from false to true. This is the flag the sign-in lesson was waiting
on.
+ check expiry
emailVerified
verification row is deleted — one-time use,
enforced by deletion. The same link can never work twice.
+ check expiry
emailVerified
autoSignInAfterVerification is on, a session is
issued right here — no second trip to the sign-in form.
+ check expiry
emailVerified
callbackURL. The user is verified and
signed in. The circuit sign-up opened is closed.
Real inboxes are full of stale links, so there’s an unhappy path too.
If the token never existed, expired, or was already consumed, the library flips nothing and redirects with ?error=invalid_token on the query string.
Your landing page reads that param and renders something calm, like “this link is invalid or has expired, request a new one,” with a way to resend.
The detail that matters most: all three failure causes collapse into that one error string.
“Never existed,” “expired,” and “already used” are indistinguishable to the user, and to an attacker.
(You can point the library at a dedicated errorCallbackURL so errors get their own page; without it they land on callbackURL with the ?error= param, which is plenty here.)
So the only UI you write this lesson is that landing page, and it handles two outcomes: success, where the user is signed in and you show the app or a one-time “email verified” toast; and ?error=invalid_token, where you show the resend path.
One trap on that landing page is worth slowing down for, because it’s a real security hole, not a hypothetical.
The callbackURL is untrusted input.
Better Auth shipped a security advisory for an open redirect on this endpoint: a malformed callbackURL could bounce a user to an attacker’s origin.
The library now validates its own redirect targets against the trustedOrigins list, so keep that list correct.
But the library guarding its own redirects doesn’t cover yours.
Any callbackURL or ?next= that your landing-page code reflects into a redirect is still your job, and you have the tool: safeNext, the open-redirect guard you wired for ?next= on sign-in.
It returns the destination only when it’s a same-site /… path and falls back to a safe default for anything absolute or protocol-relative.
Never write redirect(searchParams.get('callbackURL')); always write redirect(safeNext(searchParams.get('callbackURL'))).
The verification email itself
Section titled “The verification email itself”What goes in a verification email, and what keeps it doing only its one job?
The template lives at emails/verify-email.tsx and exports VerifyEmail, the component your config callback rendered with react: VerifyEmail({ url }).
You learned React Email and the Resend send pipeline in the email unit; this is just the template this flow needs.
A transactional email like this one does exactly one job. A verification email exists to get the user to click “verify”: no cross-sells, no newsletter footer. Every extra element gives a spam filter one more reason to flag the message and the user one more reason to hesitate. Restraint here is a deliverability decision, not an aesthetic one.
So the anatomy is short: a single call-to-action button, a plain-text copy of the same link beneath it, and a line stating when the link expires. The plain-text link matters because some email clients strip buttons, leaving the raw URL as the only way to follow it. Here’s the whole file:
export const VerifyEmail = ({ url }: { url: string }) => ( <Html> <Body> <Text>Confirm your email to finish setting up your account.</Text> <Button href={url}>Verify email</Button> <Text>Or paste this link into your browser:</Text> <Text>{url}</Text> <Text>This link expires in 1 hour.</Text> </Body> </Html>);The { url } prop is the same url Better Auth handed your config callback.
The template mints no token, builds no URL, and makes no decisions; it renders one value the library already minted, which is exactly what a transactional template should do.
Re-sending: two doors, and only the newest link works
Section titled “Re-sending: two doors, and only the newest link works”When the email lands in spam or the link expires, the user needs a fresh one. Two paths produce it, and you’ve already half-built both.
The first is explicit: the “resend” button on the “check your inbox” page from sign-up.
It calls authClient.sendVerificationEmail({ email }), the other end of the button you wired in the sign-up lesson.
The second is implicit: sendOnSignIn: true, set two sections ago.
When an unverified user tries to sign in, the email re-fires automatically.
This completes the sign-in lesson’s 'email-not-verified' branch: the form shows “check your inbox” and a fresh link is already on its way, with no extra call.
Both doors share one rule: only the most recent link works. Each resend mints a new token that supersedes the previous one, so the old link stops working. That’s the safe default. If old links stayed valid, every resend would leave more live bearer tokens scattered across inboxes and logs, each a working key. Superseding holds it to one live link at a time, so never tell a user an old link still works after they’ve requested a new one.
Both doors also sit behind a rate limit: the resend endpoint is throttled, roughly one send per email per minute, so it can’t be used to flood an inbox. The full mechanics land in a dedicated rate-limiting chapter later; here it’s enough to know the limit is there and why.
Same answer at every door: enumeration on the verify surface
Section titled “Same answer at every door: enumeration on the verify surface”Sign-up and sign-in are already enumeration-safe. This lesson adds two public surfaces, the resend button and the verify endpoint, so each one has to close the same hole.
In case you landed here directly: user enumeration is when an endpoint answers “does this email exist?” with a tell, a different message, status, or redirect for a real account versus a fake one. The rule for the whole auth surface is one sentence: every entry point answers that question with the same shape.
The resend button.
authClient.sendVerificationEmail({ email }) must return the same response whether or not the email belongs to a real, unverified account.
It cannot reply “no account with that email” or “that email is already verified,” since either is a tell.
What mail the library actually sends behind that uniform response is a version-sensitive detail you shouldn’t lean on, so hold the discipline at the layer you control: the response the caller sees is identical, full stop.
Any emailVerified short-circuit you add must not change that response.
The verify endpoint.
Every failed click lands on one ?error=invalid_token, whether the token never existed, expired, or was already used.
Naming the cause, “already used” versus “expired,” would leak whether a token was ever valid.
This is the same move sign-up made when it collapsed USER_ALREADY_EXISTS into the success path: there a Result code, here a redirect param.
Sort these responses by whether they leak:
Sort each behavior of the verify and resend surfaces by whether it hands an attacker a way to tell a real email from a fake one. Drag each item into the bucket it belongs to, then press Check.
404 for unknown ones.What verification grants, and what it doesn’t
Section titled “What verification grants, and what it doesn’t”With emailVerified now true, what does the user actually gain, and where do people overreach?
A lot opens the moment the flag flips.
The sign-in action lets the user through, no more 'email-not-verified'.
The cookie gate in front of /dashboard lets them past.
Later flows can read the flag to decide what a verified user may set up.
But emailVerified: true is not authorization.
It proves only that this person controls this inbox, never that they may do X.
Every per-action check, whether the user belongs to this org, has the right role, or owns this record, still runs on top of verification.
A verified email is necessary but not sufficient: the floor a user clears before doing anything, never the ceiling that says what they may do.
Those are two questions, answered in two places, and “is this user allowed to?” gets answered at the action boundary, a topic still ahead of you.
When a user later changes their email from account settings, that flow reuses this exact machinery: the same verification table, hashed token, short expiry, delete-on-use, and click-to-confirm.
The only difference is that the row is scoped to the user rather than to a bare email.
External resources
Section titled “External resources”The Better Auth docs are the ground truth for the option surface and the verify endpoint’s behavior, and the OWASP cheat sheet is the canonical reference for the enumeration discipline applied to these two new surfaces.
The emailVerification surface: sendVerificationEmail, sendOnSignIn, autoSignInAfterVerification, and the verify-email behavior.
The full config surface, including expiresIn and the trustedOrigins list that guards the verify endpoint's redirect.
The one-CTA primitive behind the verify template, with the email-client compatibility table.
The canonical reference for generic, enumeration-safe responses across every auth entry point.