Skip to content
Chapter 53Lesson 2

Password sign-in

Build the Better Auth email-and-password sign-in as a Server Action that reads two channels. One library call can answer in several ways, wrong credentials, unverified email, rate limits, two-factor, or a real session, and the action turns each one into a typed Result the form can render.

In the last lesson you wrote the rows. Sign-up created a user, hashed a password into a credential account, and dropped a verification row keyed by the new email, then handed the visitor a “check your inbox” screen instead of a session. Now that same person has clicked the link in their inbox, their emailVerified flag has flipped true, and they’re back at your form typing the email and password they just chose. Sign-in is the twin of sign-up: where sign-up wrote, sign-in reads, and on success it issues the session sign-up withheld.

The action has the same shape you already know: a five-seam Server Action, one call into Better Auth wrapped in try/catch, a typed Result out. So the teaching here is not the mechanics, since you have the action skeleton cold. It’s the one thing sign-in adds that sign-up never had to face: a much wider set of answers.

Sign-up could really only say three things: here you go, your input is malformed, or something broke. Sign-in looks like it should be even simpler, one yes-or-no question: is this the right password? It isn’t. A correct sign-in answers several separate questions, and each one sends the user to a different screen:

  • Is the password actually right?
  • Is this email verified yet, or is the user still mid-onboarding?
  • Have there been too many attempts from this address, the sign of someone guessing?
  • Does this account have a second factor turned on?

By the end of this lesson you’ll have a sign-in Server Action that issues a session on success, and on every other path returns a typed Result the form turns into exactly the right copy: wrong email or password, check your inbox, too many attempts, try again in a moment, or enter your authentication code.

One boundary up front, the same way the last lesson drew its boundary. This lesson stops at issuing the session. It does not build the resend-verification screen (that’s the next lesson, on email verification), it does not build the two-factor challenge UI (that lands later in this chapter), and it does not wire up production rate limiting (that’s a dedicated chapter in the rate-limiting unit). Each of those is named here at the exact point where this action hands off to it: a seam already cut, waiting for the lesson that fills it.

The last lesson left you with a finding worth restating: the same Better Auth instance exposes two faces, and they fail in opposite ways. The browser-side authClient returns failure as a value you read off error.code. The server-side auth.api throws on failure. The action you write lives entirely on the server side, so it’s the throwing face you wrap.

Sign-in’s two faces look almost identical to sign-up’s, with one new input worth flagging now and explaining properly later.

const { data, error } = await authClient.signIn.email({
email,
password,
rememberMe,
callbackURL: '/dashboard',
});

This runs in the browser. There’s no throw: a wrong password comes back as a value on error.code, and you branch on it. rememberMe and callbackURL ride along here; we’ll get to rememberMe below.

That last input, rememberMe, is the one new field on the form. It defaults to true, and it surfaces as a checkbox. Don’t reason about it from its name right now: what “remember me” actually controls is subtler than it looks, and it gets its own section later in this lesson. For the next two sections, treat it as a value that rides along into the call and move on.

What the library checks, and the five answers it can give

Section titled “What the library checks, and the five answers it can give”

The core of the lesson lands here, before you write a line of the action, the same way the last lesson taught the sign-up outcomes before wiring sign-up. Once you hold the full set of answers signInEmail can give, the action is just plumbing them through.

Picture the request running a gauntlet of gates. When it arrives, Better Auth walks it through those gates in order, and where the request falls out decides which answer comes back:

  1. The per-IP rate limiter checks this address hasn’t sent too many requests too fast.
  2. It resolves the email to a user and finds the credential account that holds the password hash.
  3. It verifies the submitted password against the stored hash, in constant time .
  4. It checks emailVerified, the flag the last lesson wrote false and email verification flips true.
  5. If the account has two-factor enabled, it stops and signals that a second factor is needed.
  6. Otherwise it issues the session: a fresh row, a fresh token, the cookie attached.

Five places to fall out, five answers. One mechanism here trips up almost everyone the first time, because it contradicts the obvious mental model.

You’d expect every failure to come back the same way: the call throws, you catch, you read the reason. Four of the five do exactly that. Rate-limited, wrong credentials, and unverified email all arrive as a thrown APIError, caught in your action’s catch. But the two-factor answer does not throw. When credentials are valid and 2FA is on, signInEmail resolves successfully: it returns normally, with no error at all, except the value it resolves to isn’t a session. It’s a small object that says “first factor passed, now I need the second.”

So “did the call succeed?” and “is the user signed in?” are not the same question. A resolved call can still be a continuation that hasn’t issued a session yet. Your action therefore has to read two channels: the resolved value, to catch the two-factor continuation, and the thrown error, for everything else. That dual-channel read is the structural difference that shapes the whole action.

Now the answers themselves. This is the catalog the rest of the lesson hangs on, so read it not as a list of error codes but as a set of destinations. For each one, note what tripped it, which channel it arrives on, what the user sees, and, most important, whether it’s actually an error, a continuation, or success.

  • 'too-many-attempts' Per-IP rate limiter tripped thrown 429 · retry-after Too many attempts, try again in a moment Error
  • 'invalid-credentials' Wrong password OR unknown email thrown INVALID_EMAIL_OR_PASSWORD · 401 Wrong email or password Error
  • 'email-not-verified' Correct password, emailVerified still false thrown EMAIL_NOT_VERIFIED · 403 The “check your inbox” view Continuation
  • 'requires-second-factor' Credentials valid, 2FA enabled resolved the odd one out · { twoFactorRedirect: true } The 2FA code prompt Continuation
  • 'ok' Every gate cleared, no 2FA resolved session row · fresh token · cookie set The dashboard Success
Five outcomes, one row each — and the column that matters most is the last one: two of these *look* like failures but are **continuations**, and `'requires-second-factor'` is the lone outcome that arrives on the *resolved* (success) channel, not a thrown error.

Two of these need a beat more, because they’re where the senior reframe matters most.

'invalid-credentials' collapses two different facts into one answer: wrong password and no such account come back identically. That’s not Better Auth being vague. It’s the user enumeration discipline you met in the last lesson, now at the sign-in surface. If a wrong password and an unknown email gave different responses, an attacker could feed your form a list of emails and read off which ones are real, rebuilding the exact harvesting oracle sign-up was careful to close. So the answer is deliberately the same shape for both, and your copy says “Wrong email or password,” never “no account with that email.” The full threat model lives in the last lesson; the one thing to carry forward is that this collapse is load-bearing, not lazy.

'email-not-verified' is the first answer that isn’t a failure. The account exists and the password is right, so the user did nothing wrong. They’re simply mid-onboarding: they signed up but haven’t clicked the link yet. Painting that red, as if they got something wrong, is a small but real misread. The right move is to swap the form for the same “check your inbox” view from the last lesson and let them finish. (When you configure verification to resend on sign-in, with sendOnSignIn: true, the library even re-sends the mail here. That’s the next lesson’s concern; this action just returns the answer.)

And then the one that breaks the obvious model:

'requires-second-factor' is the most misread outcome in the whole flow. The credentials checked out and 2FA is on, but signInEmail does not throw for this. It resolves normally, with { twoFactorRedirect: true, twoFactorMethods: ['totp'] } and no session yet. The twoFactorMethods array tells you which factors the account has available, so the form knows which prompt to show. The beginner instinct is to treat “not signed in” as “failed” and catch this as an error with a red message. It is not an error. It’s a successful first factor: a continuation that hands the form a different screen, the one that collects the authentication code. The factor itself gets verified in a separate call, later in this chapter. Your action’s only job is to notice this on the resolved value and pass the available methods along.

The figure below draws all five gates and shows why the dual-channel read matters: 2FA forks off the success exit, not a failure exit.

rate limit
throws 'too-many-attempts' Error
verify hash
throws 'invalid-credentials' Error
check verified
throws 'email-not-verified' Continuation
check 2FA
2FA on?
yes — first factor passed 'requires-second-factor' Continuation
session
returns 'ok' Success
Where the request falls out names the `Result` — and `'requires-second-factor'` forks off the *success* exit, not a failure.

Before you wire any of this, prove to yourself you can sort the five answers into what they really are, because the action’s logic is exactly this classification turned into code.

Sort each sign-in outcome by what it really is. Two of them feel like errors but aren't. Drag each item into the bucket it belongs to, then press Check.

Error The user got something wrong, or is being blocked.
Continuation Not a failure — hand the form a different screen.
Success A session was issued.
The password didn’t match the stored hash.
The eleventh request in ten seconds from this IP address.
Correct password, but this email was never verified.
Correct password, and the account has two-factor turned on.
Everything checked out and a session row was written.

The two that belong in Continuation are the whole point. 'email-not-verified' and 'requires-second-factor' look like failures but are not: they’re successful steps that route the user onward. That distinction is about to become the shape of the action.

You’ve written this action’s skeleton once already. The five seams are the same: parse, then a deliberately empty authorize seam, then the single mutating library call, then the returns. So this section moves fast on the parts you know and spends its budget on the one part that’s genuinely new: the success path, which forks instead of just returning ok.

Here’s the whole action. Read it once top to bottom; the walkthrough below takes it seam by seam.

'use server';
const signInSchema = z.object({
email: z.string().trim().toLowerCase().pipe(z.email()),
password: z.string().min(1),
rememberMe: z.preprocess((v) => v === 'on' || v === true, z.boolean()).default(true),
});
export async function signIn(
prevState: Result<SignInOk> | null,
formData: FormData,
): Promise<Result<SignInOk>> {
const parsed = signInSchema.safeParse(Object.fromEntries(formData));
if (!parsed.success) {
return err('validation', 'Check the highlighted fields.', z.flattenError(parsed.error).fieldErrors);
}
const { email, password, rememberMe } = parsed.data;
let result;
try {
result = await auth.api.signInEmail({
body: { email, password, rememberMe },
headers: await headers(),
});
} catch (error) {
return mapSignInError(error);
}
if ('twoFactorRedirect' in result) {
return ok({ status: 'second-factor', methods: result.twoFactorMethods });
}
return ok({ status: 'signed-in', redirectTo: safeNext(formData.get('next')) });
}

Same opening as sign-up: Object.fromEntries turns the FormData into a plain object, and safeParse validates it. One difference is worth a pause: password: z.string().min(1), not min(12). At sign-up you enforce a strength floor because you’re setting a password. At sign-in you’re checking one, and an account created back when your floor was lower must still be able to sign in. Copying min(12) here is a real way to lock existing users out of their own accounts.

'use server';
const signInSchema = z.object({
email: z.string().trim().toLowerCase().pipe(z.email()),
password: z.string().min(1),
rememberMe: z.preprocess((v) => v === 'on' || v === true, z.boolean()).default(true),
});
export async function signIn(
prevState: Result<SignInOk> | null,
formData: FormData,
): Promise<Result<SignInOk>> {
const parsed = signInSchema.safeParse(Object.fromEntries(formData));
if (!parsed.success) {
return err('validation', 'Check the highlighted fields.', z.flattenError(parsed.error).fieldErrors);
}
const { email, password, rememberMe } = parsed.data;
let result;
try {
result = await auth.api.signInEmail({
body: { email, password, rememberMe },
headers: await headers(),
});
} catch (error) {
return mapSignInError(error);
}
if ('twoFactorRedirect' in result) {
return ok({ status: 'second-factor', methods: result.twoFactorMethods });
}
return ok({ status: 'signed-in', redirectTo: safeNext(formData.get('next')) });
}

HTML checkboxes are awkward to validate: a checked box submits the string "on", and an unchecked one submits nothing at all, never a real boolean. Don’t reach for z.coerce.boolean() here, because coercion makes even the string "false" truthy, the classic FormData-boundary trap. Preprocess the raw value into an actual boolean, and default to true so a form that omits the field still gets a persistent session.

'use server';
const signInSchema = z.object({
email: z.string().trim().toLowerCase().pipe(z.email()),
password: z.string().min(1),
rememberMe: z.preprocess((v) => v === 'on' || v === true, z.boolean()).default(true),
});
export async function signIn(
prevState: Result<SignInOk> | null,
formData: FormData,
): Promise<Result<SignInOk>> {
const parsed = signInSchema.safeParse(Object.fromEntries(formData));
if (!parsed.success) {
return err('validation', 'Check the highlighted fields.', z.flattenError(parsed.error).fieldErrors);
}
const { email, password, rememberMe } = parsed.data;
let result;
try {
result = await auth.api.signInEmail({
body: { email, password, rememberMe },
headers: await headers(),
});
} catch (error) {
return mapSignInError(error);
}
if ('twoFactorRedirect' in result) {
return ok({ status: 'second-factor', methods: result.twoFactorMethods });
}
return ok({ status: 'signed-in', redirectTo: safeNext(formData.get('next')) });
}

Identical to sign-up: on a parse failure, return err('validation', ...) carrying the flattened fieldErrors so the form can mark each bad field. Nothing new here.

'use server';
const signInSchema = z.object({
email: z.string().trim().toLowerCase().pipe(z.email()),
password: z.string().min(1),
rememberMe: z.preprocess((v) => v === 'on' || v === true, z.boolean()).default(true),
});
export async function signIn(
prevState: Result<SignInOk> | null,
formData: FormData,
): Promise<Result<SignInOk>> {
const parsed = signInSchema.safeParse(Object.fromEntries(formData));
if (!parsed.success) {
return err('validation', 'Check the highlighted fields.', z.flattenError(parsed.error).fieldErrors);
}
const { email, password, rememberMe } = parsed.data;
let result;
try {
result = await auth.api.signInEmail({
body: { email, password, rememberMe },
headers: await headers(),
});
} catch (error) {
return mapSignInError(error);
}
if ('twoFactorRedirect' in result) {
return ok({ status: 'second-factor', methods: result.twoFactorMethods });
}
return ok({ status: 'signed-in', redirectTo: safeNext(formData.get('next')) });
}

There’s no authorize seam to fill. Sign-in is a public endpoint, so there’s no prior caller whose permission you’d check: the credential check is the authorization. As with sign-up, the seam is deliberately, visibly empty: considered, not forgotten.

'use server';
const signInSchema = z.object({
email: z.string().trim().toLowerCase().pipe(z.email()),
password: z.string().min(1),
rememberMe: z.preprocess((v) => v === 'on' || v === true, z.boolean()).default(true),
});
export async function signIn(
prevState: Result<SignInOk> | null,
formData: FormData,
): Promise<Result<SignInOk>> {
const parsed = signInSchema.safeParse(Object.fromEntries(formData));
if (!parsed.success) {
return err('validation', 'Check the highlighted fields.', z.flattenError(parsed.error).fieldErrors);
}
const { email, password, rememberMe } = parsed.data;
let result;
try {
result = await auth.api.signInEmail({
body: { email, password, rememberMe },
headers: await headers(),
});
} catch (error) {
return mapSignInError(error);
}
if ('twoFactorRedirect' in result) {
return ok({ status: 'second-factor', methods: result.twoFactorMethods });
}
return ok({ status: 'signed-in', redirectTo: safeNext(formData.get('next')) });
}

The single library call, the only thing in this action that touches the database. It’s assigned to result, not awaited-and-discarded the way sign-up’s call was, because on success there’s a value we need to read. It’s wrapped in try/catch because this face throws on failure, and the catch hands the error straight to mapSignInError. The headers: await headers() argument (headers() is async in this version of Next) hands Better Auth the request so that on success it can attach the fresh session cookie. The library owns the session write; there’s no db.transaction of yours here.

'use server';
const signInSchema = z.object({
email: z.string().trim().toLowerCase().pipe(z.email()),
password: z.string().min(1),
rememberMe: z.preprocess((v) => v === 'on' || v === true, z.boolean()).default(true),
});
export async function signIn(
prevState: Result<SignInOk> | null,
formData: FormData,
): Promise<Result<SignInOk>> {
const parsed = signInSchema.safeParse(Object.fromEntries(formData));
if (!parsed.success) {
return err('validation', 'Check the highlighted fields.', z.flattenError(parsed.error).fieldErrors);
}
const { email, password, rememberMe } = parsed.data;
let result;
try {
result = await auth.api.signInEmail({
body: { email, password, rememberMe },
headers: await headers(),
});
} catch (error) {
return mapSignInError(error);
}
if ('twoFactorRedirect' in result) {
return ok({ status: 'second-factor', methods: result.twoFactorMethods });
}
return ok({ status: 'signed-in', redirectTo: safeNext(formData.get('next')) });
}

This is the heart of the action, the part sign-up never had. The call resolved, but a resolved call isn’t necessarily a finished sign-in. if ('twoFactorRedirect' in result) is the documented way to detect the two-factor continuation; TypeScript can’t infer that field, so you check for it by name. When it’s there, you return a success-shaped Result that tells the form to render the 2FA prompt for the available methods. No session has been issued yet; the factor-verification call lives later in this chapter. This is the “continuation, not error” idea from the catalog, now written as code.

'use server';
const signInSchema = z.object({
email: z.string().trim().toLowerCase().pipe(z.email()),
password: z.string().min(1),
rememberMe: z.preprocess((v) => v === 'on' || v === true, z.boolean()).default(true),
});
export async function signIn(
prevState: Result<SignInOk> | null,
formData: FormData,
): Promise<Result<SignInOk>> {
const parsed = signInSchema.safeParse(Object.fromEntries(formData));
if (!parsed.success) {
return err('validation', 'Check the highlighted fields.', z.flattenError(parsed.error).fieldErrors);
}
const { email, password, rememberMe } = parsed.data;
let result;
try {
result = await auth.api.signInEmail({
body: { email, password, rememberMe },
headers: await headers(),
});
} catch (error) {
return mapSignInError(error);
}
if ('twoFactorRedirect' in result) {
return ok({ status: 'second-factor', methods: result.twoFactorMethods });
}
return ok({ status: 'signed-in', redirectTo: safeNext(formData.get('next')) });
}

Fall through to here and there was no second factor: the real session exists and its cookie is already attached. Return ok({ status: 'signed-in', redirectTo }), where redirectTo is a validated destination. safeNext is the open-redirect guard we’ll meet in a moment. After this the form’s only job is to navigate. (And the revalidate seam: nothing you’ve cached changed here, so, as with sign-up, name it and skip it.)

1 / 1

One detail in that signature is easy to skim past and worth pausing on. The success channel carries its own type, SignInOk:

type SignInOk =
| { status: 'signed-in'; redirectTo: string }
| { status: 'second-factor'; methods: string[] };

This is the domain outcome the form switches on, and it’s a small discriminated union rather than a single value, precisely because “success” here means one of two genuinely different things: a finished session, or a 2FA continuation. Keep this distinct in your head from the generic Result error codes ('validation', 'unauthorized', and friends) that come out of lib/result.ts. The error codes ride the failure channel; SignInOk rides the success channel. This separation is exactly why trying to model 'requires-second-factor' as an error code gets you stuck: it was never a failure, so it doesn’t belong in the failure union.

As for why you go through a Server Action at all rather than calling the client straight from the form, that’s the same three reasons from the last lesson: the action parses on the server where input can’t be bypassed, it turns the library’s throw into a typed Result, and it keeps any library-specific wording from ever leaking into your UI.

That leaves the translator the catch hands off to. mapSignInError is the sign-in twin of the last lesson’s mapSignUpError, and it handles only the thrown failures: two-factor never reaches it, because two-factor is the success branch you already returned above.

function mapSignInError(error: unknown): Result<never> {
if (error instanceof APIError) {
const code = error.body?.code;
// Unknown email and wrong password collapse to one shape — enumeration stays closed.
if (code === 'INVALID_EMAIL_OR_PASSWORD') {
return err('unauthorized', 'Wrong email or password.');
}
if (code === 'EMAIL_NOT_VERIFIED') {
return err('forbidden', 'Verify your email, then try again.');
}
if (error.status === 429) {
return err('rate_limited', 'Too many attempts. Try again in a moment.');
}
}
return err('internal', 'Something went wrong. Try again.');
}

Three things in that translator are deliberate, and each is a small senior tell.

The comment is the kind production keeps: not a restatement of the code, but the reason two cases return the same shape. A reader who deletes the comment and “tidies up” by giving unknown-email its own friendly message has just reopened the enumeration hole, and the comment is what stops them.

The rate-limit case checks error.status === 429 rather than a code string, because the numeric HTTP status is the most version-stable thing to key off here. The two code strings it does match, INVALID_EMAIL_OR_PASSWORD and EMAIL_NOT_VERIFIED, are the volatile part. Better Auth ships these as $ERROR_CODES on the client, and library versions occasionally rename them, so read them off $ERROR_CODES rather than trusting them from memory. (The last lesson hit the same risk with its sign-up codes.)

And the Result codes coming out, 'unauthorized', 'forbidden', 'rate_limited', and 'internal', are your application’s fixed Result union from lib/result.ts, the discriminants your form branches on. The second argument to err is your copy, written by you, never the library’s wording. Don’t read these Result codes as HTTP statuses: 'unauthorized' here is a discriminant the UI matches on, not an HTTP 401 you’re sending to a client.

What the library defends, and the defense it leaves you to add

Section titled “What the library defends, and the defense it leaves you to add”

A sign-in endpoint is the most attacked surface in your entire app. It accepts secrets, it tells the caller when they got one right, and it sits at a public URL. So the senior question writes itself: what actually stops an attacker from simply trying passwords until one works?

The honest answer comes in two layers, one the library hands you and one you add yourself, and getting this distinction right is the point of this section. A developer who assumes the library does more than it does ships a real hole.

Layer one: the per-IP rate limit. Better Auth ships this. Better Auth rate-limits all of its auth endpoints, with tighter caps on the riskier ones, and /sign-in/email is about as strict as it gets: roughly three requests every ten seconds, per IP address. One surprise is worth naming before it surprises you: this limit is on in production and off in development. Local testing never gets throttled, so the first time you see a 429 will be in production, from an endpoint your dev machine let you hammer freely. Cross the cap and you get the 429 that surfaces as the 'too-many-attempts' outcome from the catalog. This layer stops a single address pounding one endpoint, the shape of a credential stuffing run that replays a leaked password list from one machine. Where it leaks: an attacker who rotates IP addresses, through a botnet or a proxy pool, slips under a per-IP cap completely, and the library does nothing per-account to notice.

Layer two: the per-account limit. You add this; core Better Auth does not. State this plainly, because it’s the fact people get wrong: core Better Auth ships no failed-attempt lockout counter. Some platforms (Auth0, Clerk) include one; Better Auth’s built-in brute-force story is the per-IP limiter and nothing more. So the senior move is to add a second key: a limit that counts attempts per email address, independent of the IP they come from. The course wires this up properly later, in the rate-limiting chapter, as the dual-key (per-IP and per-email) limiter the code conventions mandate for every auth endpoint. This second layer stops IP-rotation aimed at one known-good account, exactly the attack layer one misses.

Per-IP rate limit keyed by IP address
Who provides it Better Auth built-in — on in prod, off in dev
Stops One IP hammering the endpoint — a credential-stuffing run replayed from a single machine.
Hole An attacker rotating IPs (a botnet, a proxy pool) sails under it.
Per-email limit keyed by email address
Who provides it You added in the rate-limiting chapter
Stops IP-rotation aimed at one known-good account — the guesses pile up wherever they come from.
Hole One attempt each spread across many accounts stays under it.
Each layer has a hole the other plugs — which is the whole argument for running both. The decisive column is *who provides it*: the per-IP limit is Better Auth's, the per-email limit is yours to add.

The payoff is in how the two layers cover each other. Beat the per-IP cap by rotating addresses, and the per-email key catches you: the guesses against that one account pile up no matter where they come from. Try to slip under the per-email key by spreading one attempt each across thousands of accounts, and the per-IP cap catches that: too many requests, too fast, from your address. Neither layer is sufficient alone; together they leave no easy path. That’s why the library’s default by itself, IP only, is not yet enough for a product that handles money or anyone’s private data.

One rule about the per-email limit, once you’ve added it, is non-negotiable.

Now to pay off the field we deferred, and to correct a misconception nearly everyone holds. “Remember me” is almost universally assumed to mean “keep me logged in longer.” It does not. It controls one thing only: whether the session cookie survives the browser closing.

The two cases are simple:

  • Checked (true). The session cookie is written with a Max-Age equal to the session’s configured lifetime, so it persists on disk. Close the browser, reopen it days later, and the cookie is still there, so the user is still signed in.
  • Unchecked (false). A session-only cookie, with no Max-Age at all. The browser holds it in memory and discards it the moment the browser closes. Reopen, and the user lands back at sign-in.

Here’s the correction, and it’s the whole point of this section: the session.expiresAt row in your database is set the same either way. “Remember me” governs the cookie’s lifetime on the user’s machine; it has nothing to do with how long the server keeps the session alive. The server would honor that session for its full configured lifetime in both cases. A user who unchecks the box and reopens isn’t signed out because the session expired on the server. They’re signed out because the browser threw away the cookie that pointed at it. The session is still sitting there, valid; there’s just no longer a cookie to present it.

remember me — checked
Max-Age: 30 days persists on disk — survives the browser closing
remember me — unchecked
Session kept in memory — cleared the moment the browser closes
same on both sides session { expiresAt: 2026-07-07 } one server-side row, one identical expiresAt
Both back a `session` row with the **same** `expiresAt`. The checkbox changes the *cookie* on the user's machine, not the server-side session.

The last thing worth naming on the success path costs you zero configuration and closes a real attack, which makes it easy to take for granted and worth making concrete.

Recall the session fixation threat from the chapter on the auth mental model (“Sessions versus JWTs, and the cookie that carries them”). An attacker who can plant a session identifier they already know into a victim’s browser before the victim signs in would, in a naive system, still hold a valid handle to that session after the victim authenticates, riding in on a session ID they chose. The defense is blunt and complete: mint a brand-new session.token on every successful sign-in, and never reuse any value that existed before authentication.

Better Auth does exactly this. The token that comes out of a successful signInEmail is fresh, so any identifier an attacker pre-planted is dead the instant the real user signs in: it now points at nothing. The posture here is the same one from the last lesson’s “the library owns the hash”: you don’t write rotation logic, you don’t manage the token’s lifecycle, and you don’t even see it happen. The library encodes the fixation defense, and you get it for free by calling signInEmail. This is the abstract model from the auth-mental-model chapter, made concrete at the call site.

Sign-out is the inverse operation, and it’s almost trivial, except for one rule that is not optional.

Both faces look the way you’d expect by now: the browser one takes nothing, and the server one takes the request headers like every other server call.

await authClient.signOut();

Runs in the browser. Takes nothing, because the cookie travels with the request, so Better Auth already knows which session to kill.

Either one deletes the session row and clears the cookie, and the app sends the user back to /sign-in.

One rule governs how you trigger it.

One hardening beat remains, and it’s about where the user goes after a successful sign-in: the redirectTo your action already returned.

The setup is a convenience you’ll build all the time. A protected page catches a signed-out visitor and sends them to /sign-in?next=/dashboard/settings, so that once they sign in you can bounce them back to exactly where they were headed. That’s handy, but it becomes an open redirect the moment you do the obvious thing and redirect(searchParams.get('next')) with whatever’s in the query string.

An attacker crafts /sign-in?next=https://phish.example.com, or the sneakier /sign-in?next=//phish.example.com. That leading // is a protocol-relative URL, which browsers happily treat as an absolute address to another origin. They get a victim to sign in through your trusted domain, the real one, and then your own app obediently hands them off to the attacker’s lookalike login page, now wearing all the credibility of having come straight from you.

The rule is to validate ?next= against an allowlist before you redirect to it. Accept only a same-site path: it must start with a single /, it must not start with //, and it must not be an absolute http(s):// URL. The course centralizes this check in safeNext(url) (from lib/redirects.ts, per the security baseline in the code conventions), which is the function your action’s ok({ redirectTo: safeNext(formData.get('next')) }) was already routing through. This is the line finally earning its explanation.

redirect(formData.get('next') as string);

Hands the user to whatever the query string says, including //phish.example.com, which lands them on an attacker’s origin while they think they’re still on your site.

One note so you don’t over-trust the library here. Better Auth validates its own internal redirects against the trustedOrigins you configure, but that covers the library’s redirects, not yours. Any next value your surrounding form and redirect code handles is on you, and safeNext is how you handle it.

You’ve now seen every gate, every answer, and every hardening beat in isolation. Watch the happy path run as one continuous motion: the successful sign-in, start to finish, with nothing branching off. (The branches already live in the gauntlet figure earlier; this is the clean run.)

1 Submit email · password · remember
2 Parse Zod · lowercase · coerce
3 Verify hash scrypt · constant time
4 Checks pass rate · verified · 2FA
5 Rotate session fresh token · cookie set
6 Redirect ok({ redirectTo })
no session yet token: not minted yet

Submit. The user submits their email, password, and the remember-me choice. Nothing has happened server-side yet; this step belongs to the browser.

1 Submit email · password · remember
2 Parse Zod · lowercase · coerce
3 Verify hash scrypt · constant time
4 Checks pass rate · verified · 2FA
5 Rotate session fresh token · cookie set
6 Redirect ok({ redirectTo })
no session yet token: not minted yet

Parse. The action parses with Zod, normalizes the email, and coerces the remember-me checkbox into a real boolean before anything touches the database.

1 Submit email · password · remember
2 Parse Zod · lowercase · coerce
3 Verify hash scrypt · constant time
4 Checks pass rate · verified · 2FA
5 Rotate session fresh token · cookie set
6 Redirect ok({ redirectTo })
no session yet token: not minted yet

Verify hash. auth.api.signInEmail finds the 'credential' account and verifies the password against the stored hash in constant time, so no timing tell leaks.

1 Submit email · password · remember
2 Parse Zod · lowercase · coerce
3 Verify hash scrypt · constant time
4 Checks pass rate · verified · 2FA
5 Rotate session fresh token · cookie set
6 Redirect ok({ redirectTo })
no session yet token: not minted yet

Checks pass. The request is under the rate cap, the email is verified, and no second factor is required, so every gate clears.

1 Submit email · password · remember
2 Parse Zod · lowercase · coerce
3 Verify hash scrypt · constant time
4 Checks pass rate · verified · 2FA
5 Rotate session fresh token · cookie set
6 Redirect ok({ redirectTo })
fresh session issued fresh token — pre-auth value is dead

Rotate session. A fresh session row and token are issued, never reusing any pre-auth value, and the cookie is attached via nextCookies().

1 Submit email · password · remember
2 Parse Zod · lowercase · coerce
3 Verify hash scrypt · constant time
4 Checks pass rate · verified · 2FA
5 Rotate session fresh token · cookie set
6 Redirect ok({ redirectTo })
fresh session issued fresh token — pre-auth value is dead

Redirect. The action returns ok({ redirectTo }) and the user lands on the allowlisted destination, signed in.

And now rebuild that order from memory.

Order the steps a successful password sign-in takes, from the button press to the dashboard. Drag the items into the correct order, then press Check.

The user submits their email, password, and the remember-me choice
The action parses and normalizes the input
auth.api.signInEmail verifies the password against the stored hash
The rate-limit, email-verified, and two-factor gates all pass
A fresh session row and token are issued and the cookie is set
The action returns ok and the user is redirected to their destination

That’s the sign-in surface: a single library call whose answer fans into a small, closed set. Success is either a real session or a 2FA continuation, and alongside it sits a handful of thrown failures your translator collapses into safe, typed Results. A sign-in was never a boolean. It reads two channels and classifies the answer, and you’ve just built one.