Skip to content
Chapter 53Lesson 3

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.

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.

1 / 1

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:

In the email what the user holds
verify link
the raw token, sent over email
one-way hash
the library does this
can't reverse
In the verification row what you store
verification
identifier ada@acme.com
value 7f1c…e84
expiresAt in 1 hour
a hash of the token, never the token
The string in the inbox and the string in the database are never the same. A stolen database yields no working links.

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.

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:

1 Click link
2 Hash token
3 Look up
+ check expiry
4 Flip
emailVerified
5 Delete row
6 Issue session
7 Land in app
The user clicks the link in their inbox. The request hits the [...all] catch-all you mounted when you set up Better Auth — no new route.
1 Click link
2 Hash token
3 Look up
+ check expiry
4 Flip
emailVerified
5 Delete row
6 Issue session
7 Land in app
The library hashes the token from the URL. The raw token is never looked up directly — only its hash.
1 Click link
2 Hash token
3 Look up
+ check expiry
4 Flip
emailVerified
5 Delete row
6 Issue session
7 Land in app
It finds the verification row by the hashed value and checks expiresAt. Missing, expired, or already used → the link is dead.
1 Click link
2 Hash token
3 Look up
+ check expiry
4 Flip
emailVerified
5 Delete row
6 Issue session
7 Land in app
user.emailVerified flips from false to true. This is the flag the sign-in lesson was waiting on.
1 Click link
2 Hash token
3 Look up
+ check expiry
4 Flip
emailVerified
5 Delete row
6 Issue session
7 Land in app
The verification row is deleted — one-time use, enforced by deletion. The same link can never work twice.
1 Click link
2 Hash token
3 Look up
+ check expiry
4 Flip
emailVerified
5 Delete row
6 Issue session
7 Land in app
Because autoSignInAfterVerification is on, a session is issued right here — no second trip to the sign-in form.
1 Click link
2 Hash token
3 Look up
+ check expiry
4 Flip
emailVerified
5 Delete row
6 Issue session
7 Land in app
A redirect to 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'))).

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:

emails/verify-email.tsx
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.

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.

Leaks Hands the attacker a tell
Safe Same shape either way
Resend replies “no account found” when the email is unknown.
Verify shows “this link was already used” for one token but “this link has expired” for another.
Resend returns success for real emails but a 404 for unknown ones.
Resend returns the same acknowledgement for every email submitted.
Every failed verify click lands on one “invalid or expired link” page.
A non-existent email simply results in no mail, with an identical response to the caller.

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.

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.