Email verification
Closing the email+password sign-up loop with Better Auth email verification, the hashed bearer token, one-time link, and click-to-signed-in flow that flips emailVerified and lets a user into the app.
Two lessons ago, Password sign-up did everything except the one thing the user actually waits for. It queued a verification email, wrote three rows, and handed the visitor a calm “check your inbox” page, then deliberately stopped with no session. One lesson ago, Password sign-in refused to let anyone through while emailVerified was still false, returning 'email-not-verified' and stopping there too. Both lessons left a gap in the same place: the email that arrives, the link that flips the flag, and the moment the app finally opens.
This lesson is that bridge. By the end, a click on a link in someone’s inbox will flip emailVerified from false to true, delete the pending row that was waiting, and, with one config flag, drop the user straight into the app, signed in. The sign-up, verify, and in-the-app circuit closes here.
Like the two lessons before it, this one hides a handful of senior questions behind a feature that looks like it’s mostly “send an email”:
- What’s in the link, and what stores the token? The link carries a secret. Where does the matching half of that secret live?
- What happens, step by step, when the user clicks it? Which route handles the click, and what’s the exact sequence from click to signed-in?
- How long does the link live, and what makes it un-replayable? A link in an inbox can be forwarded, logged, or leaked. What keeps a stale or stolen one from working?
- How does enumeration discipline survive a brand-new public surface? Sign-up and sign-in answer “does this email exist?” the same way no matter what. The verify endpoint and the resend button are new doors, so do they hold the line, or did we just open a fresh oracle?
One boundary up front, the same way the last two lessons drew theirs. This lesson does not cover password reset, magic links, or the change-your-email-from-settings flow. Each of those reuses the exact machinery you’re about to build, and each is named at the seam where it does, but none is built here.
Turning the email on
Section titled “Turning the email on”The senior question: sign-up named sendVerificationEmail as the seam the email rides, then left it empty. What’s the smallest config that makes the email actually send, and which knobs reshape what happens after the click?
You turn the whole thing on the same way you turned on sign-up: with one block dropped into betterAuth({ ... }) in lib/auth.ts. Where sign-up added emailAndPassword, verification adds an emailVerification block right next to it, a sibling rather than a nested option. It’s the same “drop a block in” move, and most of the block is decisions, not logic.
Here’s the entire on-switch. Each knob is a default the library already has an opinion about, and your job is to decide where you agree and where you don’t.
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 invokes this whenever a verification email needs to go out, and it hands you everything: { user, url }, where user.email is the recipient and url is the fully-formed verify link with the token already embedded. You do not mint the token, and you do not build the URL. Your entire job is one line: hand the link to the sendEmail wrapper you built in the email unit. The library produces the secret, and the callback only 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 is the knob that pays the last lesson’s debt. When an unverified user signs in, Better Auth re-fires sendVerificationEmail automatically, which is exactly why the sign-in form’s 'email-not-verified' branch could say “we re-sent your link, check your inbox” without making a second call itself. Note one subtlety now rather than later: this 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 reshapes the moment after the click. With it on, the verify endpoint doesn’t just flip the flag, it issues a session, and the user lands signed in with no second trip to the sign-in form. With it off, which is the library default, the click flips the flag and redirects, but the user then has to sign in by hand. For a consumer SaaS in 2026 the call is on: the click already proved the user controls that inbox, so making them sign in again 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 at a glance rather than as a bare 3600. This one is worth a note precisely because it’s the library default: here you’re agreeing, and the reason is the point. An hour is long enough that someone who checks their 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 stumbles on it. Hold that number loosely. Password reset, later in this chapter, uses a shorter ten minutes because a reset is higher-stakes, and a magic link is shorter still because the link itself is the credential.
That’s the whole on-switch: one delivery callback and three trust-and-UX decisions. Everything else, including the token machinery, the endpoint that handles the click, and the email body, is something the library hands you. The rest of this lesson opens each of those boxes in turn.
A quick check that the four knobs landed before we go deeper. Fill in the blanks:
Complete the `emailVerification` block. Pick the right option from each dropdown, then press Check.
emailVerification: { sendVerificationEmail: async ({ user, url }) => { await ___({ to: user.email, subject: 'Verify your email', react: VerifyEmail({ url }), }); }, sendOnSignIn: ___, autoSignInAfterVerification: ___, expiresIn: 60 * ___,},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 senior question: 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, the table that was generated back when you set up the Better Auth schema and the one sign-up first wrote to. This lesson is its first real consumer: sign-up wrote the row, and this lesson reads it and deletes it. The row has three columns that matter to us: identifier holds the email, value holds the token, and expiresAt holds now plus the hour you configured.
That one-word description of value, “holds the token,” is doing a lot of quiet work, and unpacking it is the whole security lesson. The token in the email and the token in the database are not the same string. What travels in the link is the raw token. What sits in value is a one-way transform of it: the library hashes it before it ever touches the column. Two properties fall out of that split, and both are worth naming.
The first is that a database snapshot is inert. Because only the transformed value is stored, an attacker who exfiltrates the entire verification table cannot reconstruct a single working link. They hold the hashes, not the secrets that produce them. This is the exact same principle you already met with passwords two lessons ago: the secret the user holds is never the secret you store. There it was the password on the account row; here it’s the verification token. Same move, second kind of secret. You already own this idea.
The second is that guessing is intractable. The raw token isn’t a small number you could brute-force. It’s generated from a CSPRNG with enough entropy that producing a valid token by guessing is simply not a thing that happens in practice. As with the password hash, you don’t choose the byte count; the library encodes the safe default, and that’s the whole value of reaching for it.
So picture the two halves side by side. The thing in the inbox and the thing in the database are never the same string:
verification row
what you store That hashed value is also what makes the click fast and safe to check. When the verification link arrives, the library hashes the incoming token and compares it against value, and it does that comparison in constant time , the same reflex you met for password verification last lesson. Same defense, different token.
One more thing to nail down, because it’s where the link gets its whole character. A verification token is a bearer token : holding it is enough. That’s exactly why the two properties that close out the token’s life matter so much. The row is deleted the instant verification succeeds. One-time use isn’t a flag the library checks; it’s enforced by the row simply no longer existing, so a second click on the same link finds nothing. The row also expires: the lookup ignores anything past expiresAt, and a periodic cleanup job sweeps the stragglers so dead rows don’t pile up forever. Expiry and deletion are the two things standing between “a bearer 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 senior question: 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”?
Start with the link itself. It looks like this:
https://app.example.com/api/auth/verify-email?token=<token>&callbackURL=<dest>Look at the path. That’s the same /api/auth/* catch-all you mounted when you first set Better Auth up, the [...all] route that handles every auth request. You already built the thing that handles this click; you just never had a reason to exercise this particular path through it. There’s no new route file to write here. And that callbackURL on the end is the post-verify destination, and it has been riding along the whole time: it’s the same callbackURL sign-up passed to signUp.email({ callbackURL }), threaded through the link and now coming home.
When the click lands, the library runs a fixed sequence, and it runs all of it for you. On the happy path it hashes the incoming token, looks up the verification row by that hashed value, checks that expiresAt hasn’t passed, flips user.emailVerified to true, deletes the verification 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.
Now the unhappy path, because real inboxes are full of stale links. If the token never existed, has expired, or was already consumed, the library doesn’t flip anything; it redirects with ?error=invalid_token appended to the query string. Your landing page reads that param and renders something calm, such as “this link is invalid or has expired, request a new one,” with a way to resend. Here is the detail that matters most, the one we’ll develop fully two sections from now: all three failure causes collapse into that one error string. “Never existed,” “expired,” and “already used” are indistinguishable in what the user, or an attacker, sees. (For production you can point the library at a dedicated errorCallbackURL so errors route to their own page; without it, errors simply land on callbackURL carrying the ?error= param. The single-page version is plenty for now.)
So the only real UI you write in this whole lesson is that landing page at callbackURL, and it’s small. It handles two outcomes: success, where the user is signed in, so you show the app or pop a one-time “email verified” toast; and the ?error=invalid_token branch, where you show the resend path. That’s it.
There’s one trap on this landing page worth slowing down for, because it’s a real, recent security hole rather than a hypothetical. That callbackURL is untrusted input. Better Auth itself shipped a security advisory for an open redirect on this very 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 you configure, so keep that list correct, or legitimate links break and crafted ones might slip through.
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 responsibility, and you already have the tool for it. Route it through safeNext, the exact same 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'))). Reflecting a raw query param into a redirect is the dangerous part, and safeNext is what neutralizes it.
The verification email itself
Section titled “The verification email itself”The senior question: what’s actually in the email, and what’s the discipline that keeps a transactional verify mail from turning into a marketing surface?
The template lives at emails/verify-email.tsx and exports VerifyEmail, the very component your config callback rendered with react: VerifyEmail({ url }). You already learned React Email’s anatomy and the whole Resend send pipeline back in the email unit, so none of that gets re-taught here. This is just the one template this flow needs.
The governing principle for a transactional email like this one is that it does exactly one job. A verification email exists to get the user to click “verify.” No cross-sells, no “while you’re here,” no newsletter footer. Every extra element is one more reason for a spam filter to flag the message and one more half-second for the user to hesitate before clicking. Restraint here is a deliverability decision, not an aesthetic one.
So the anatomy is short: a single primary call-to-action button, a plain-text version 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, so the raw URL has to be there to copy. 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>);Notice the { url } prop. It’s the same url Better Auth handed your config callback, and the template does nothing but render the link the library minted. It mints no token, builds no URL, and makes no decisions. It’s a pure render of one value, and that’s exactly what a transactional template should be.
Re-sending: two doors, and only the newest link works
Section titled “Re-sending: two doors, and only the newest link works”The senior question: the email landed in spam, or the link expired before the user got to it. How do they get a fresh one, and what stops the old link, or the resend button itself, from becoming a problem?
There are two ways to a fresh link, and you’ve already half-built both.
The first is explicit: the “resend” button on the “check your inbox” page that sign-up put there. It calls authClient.sendVerificationEmail({ email }). That’s the other end of the button you wired in the sign-up lesson, and this is where it leads.
The second is implicit: sendOnSignIn: true, which you set two sections ago. When an unverified user tries to sign in, the email re-fires automatically. This is the sign-in lesson’s 'email-not-verified' branch made whole: the form shows “check your inbox” and a fresh link is already on its way, with no extra call.
Both doors share one rule worth understanding rather than memorizing: only the most recent link works. Each resend mints a new token and supersedes the previous one, so the old link stops working. That’s not an inconvenience, it’s the safe default, and here’s why. If old links stayed valid, every resend would widen the attack surface: more live bearer tokens floating around more inboxes and more logs, each one a working key. Superseding keeps it to one live link at a time. Whether the library literally replaces the row or just lets the newest win is an internal detail. What you can rely on is the guarantee, so don’t promise a user that an old link still works after they’ve asked for a new one.
Both doors also sit behind a rate limit. The resend endpoint is throttled by design, roughly one send per email per minute, so the button can’t be turned into a tool for flooding someone’s inbox. This is the same posture sign-up took at the same call site: the protection exists from day one. The full mechanics, the dual-key per-IP-plus-per-email limiter and the safeLimit wrapper, 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”The senior question: sign-up and sign-in are enumeration-safe. The verify endpoint and the resend button are brand-new public surfaces, so does the discipline hold, or did we just open a fresh oracle?
Recall the threat, since you may have landed on this lesson directly. User enumeration is what happens when an endpoint answers “does this email exist?” with a tell: a different message, a different status, or a different redirect for a real account versus a fake one. Sign-up closed that hole, and sign-in closed it. The rule for the whole auth surface is one sentence: every entry point answers “does this email exist?” with the same shape. This lesson adds two entry points, so this lesson has to close two.
The resend button is the first. authClient.sendVerificationEmail({ email }) must return the same response to the caller whether or not that email belongs to a real, unverified account. It must not reply “no account with that email,” and it must not reply “that email is already verified,” because either one is a tell. Exactly what mail the library sends behind that uniform response for an already-verified or non-existent address 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. If you ever add an emailVerified short-circuit inside the callback, it must not change the response shape. The uniformity is yours to guarantee, not the library’s to guess.
The verify endpoint is the second, and you already saw its defense in action. Every failed click lands on one ?error=invalid_token: a token that never existed, a token that expired, and a token that was already used all end up there. Naming the specific cause, “this link was already used” versus “this link has expired,” would leak whether a token was ever valid in the first place, so you collapse them into one. If that move feels familiar, it should: it’s the very same thing sign-up did when it collapsed USER_ALREADY_EXISTS into the success path. There it was a Result code; here it’s a redirect param. Same discipline, different surface.
Sort these responses into the ones that leak and the ones that don’t, which is the distinction the whole section turns on:
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.And the rule that ties both new surfaces back to everything before them:
What the flipped flag unlocks (and what it doesn’t)
Section titled “What the flipped flag unlocks (and what it doesn’t)”The senior question: emailVerified is now true. What does that actually grant the user, and what’s the trap in treating it as more than it is?
Plenty opens the instant that flag flips. The sign-in lesson’s action now lets the user through, with no more 'email-not-verified'. The cookie gate in front of /dashboard lets them past. And later flows can read the flag to decide what a verified user is allowed to set up. The flag is real, and it’s load-bearing.
But here’s the trap, and it’s the kind of conflation that separates someone who has shipped auth from someone who’s about to. emailVerified: true is not authorization. All it proves is that this person controls this inbox, nothing more. It does not mean “this user may do X.” Every per-action check, such as whether this user belongs to this org, has the right role, or owns this record, still runs on top of verification, every time. A verified email is necessary but not sufficient: it’s the floor a user has to clear before they can do anything, never the ceiling that says what they may do. Those are two different questions, answered in two different places, and the place where “is this user allowed to?” gets answered is the action boundary, which is a whole topic still ahead of you. For now, just don’t let the flag pretend to be more than a floor.
One last thread to tie off. When a user later changes their email from account settings, that flow reuses this exact machinery: the same verification table, the same hashed token, the same short expiry, the same one-time-use-by-deletion, and the same click-to-confirm. The only real difference is the entry point: the row is scoped to the user rather than to a bare email. You won’t build that flow here, but when you reach it, you’ll recognize it immediately. It’s this lesson’s primitive wearing a different hat.
To close, rebuild the click-to-signed-in sequence from memory. Putting the steps back in order yourself is what makes the temporal model stick:
Order what happens from the moment the user clicks the verification link to the moment they're signed in. Drag the items into the correct order, then press Check.
verification row and checks it hasn’t expired emailVerified flips from false to true verification row is deleted — the link can’t be reused 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.