keyed by IP address Password sign-in
Build the Better Auth email-and-password sign-in as a Server Action that classifies each outcome, wrong credentials, unverified email, rate limit, two-factor, or a real session, into a typed Result the form renders.
In the last lesson, sign-up wrote the rows: a user, a hashed-password credential account, and a verification row keyed by the new email, ending on a “check your inbox” screen instead of a session.
That visitor has now clicked the link, flipped emailVerified true, and returned to type the email and password they chose.
Sign-in is the twin: where sign-up wrote, sign-in reads, and on success it issues the session sign-up withheld.
The action keeps the shape you know: a five-seam Server Action wrapping one Better Auth call in try/catch, a typed Result out.
What’s new is the range of answers.
Sign-up could say only three things: here you go, your input is malformed, or something broke.
Sign-in looks simpler, one yes-or-no question, is the password right?
It isn’t.
A correct sign-in answers several separate questions, each routing the user to a different screen:
- Is the password right?
- Is this email verified yet, or is the user still mid-onboarding?
- Have there been too many attempts from this address?
- Does this account have a second factor turned on?
You’ll build a Server Action that issues a session on success and on every other path returns a typed Result the form renders as the right copy: wrong email or password, check your inbox, too many attempts, try again in a moment, or enter your authentication code.
The action stops at issuing the session; the resend-verification screen, the two-factor challenge, and production rate limiting come later, and the action cuts a seam where it hands off to each.
The call: client returns, server throws
Section titled “The call: client returns, server throws”The same Better Auth instance exposes two sides that fail in opposite ways.
The browser-side authClient returns failure as a value on error.code; the server-side auth.api throws.
Your action runs on the server, so you wrap the throwing side.
const { data, error } = await authClient.signIn.email({ email, password, rememberMe, callbackURL: '/dashboard',});In the browser. A wrong password comes back as a value on error.code, never a throw, so you branch on it.
const result = await auth.api.signInEmail({ body: { email, password, rememberMe }, headers: await headers(),});The side your action uses. On failure it throws an APIError, with the reason in error.body.code and error.status, which your catch translates. On success it resolves a value worth inspecting, so capture it in result, new for sign-in.
rememberMe is the one new field on the form, a checkbox defaulting to true.
Its name is misleading and it earns its own section later; for now, treat it as a value that rides along into the call.
What the library checks, and the five answers it can give
Section titled “What the library checks, and the five answers it can give”Once you hold the full set of answers signInEmail can give, the action is just plumbing them through, so the answers come first.
Picture the request running a gauntlet of gates. Better Auth walks it through them in order, and where the request falls out decides which answer comes back:
- The per-IP rate limiter checks this address hasn’t sent too many requests too fast.
- It resolves the email to a
userand finds thecredentialaccount that holds the password hash. - It verifies the submitted password against the stored hash, in constant time .
- It checks
emailVerified, the flag the last lesson wrotefalseand email verification flipstrue. - If the account has two-factor enabled, it stops and signals that a second factor is needed.
- Otherwise it issues the session: a fresh row, a fresh token, the cookie attached.
Five answers, and one 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.
Rate-limited, wrong credentials, and unverified email all arrive as a thrown APIError, caught in your action’s catch.
The two-factor answer does not throw.
When credentials are valid and 2FA is on, signInEmail resolves successfully, but 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 different questions: a resolved call can still be a continuation that has issued no session yet. Your action therefore reads two channels, the resolved value for the two-factor continuation and the thrown error for everything else. That dual-channel read shapes the whole action.
For each answer below, note what tripped it, which channel it arrives on, what the user sees, and whether it’s 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
'invalid-credentials' collapses two facts into one answer: wrong password and no such account come back identically.
That is the user enumeration discipline from 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 harvesting oracle sign-up closed.
So both get the same answer, and your copy says “Wrong email or password,” never “no account with that email.”
'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 signed up but haven’t clicked the link yet.
Painting that red is a real misread.
Swap the form for the same “check your inbox” view from the last lesson and let them finish.
(With sendOnSignIn: true the library even re-sends the mail here, but that is the next lesson’s concern; this action just returns the answer.)
'requires-second-factor' is the most misread outcome.
The credentials checked out and 2FA is on, but signInEmail does not throw.
It resolves normally, with { twoFactorRedirect: true, twoFactorMethods: ['totp'] } and no session yet.
The twoFactorMethods array tells the form which prompt to show.
Catching it as a red error is the common mistake.
It is not an error, but a successful first factor, a continuation that hands the form the screen collecting the authentication code, which 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: 2FA forks off the success exit, not a failure exit.
Before wiring any of this, sort the five answers into what they really are, because the action’s logic is 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.
The two in Continuation are the point: 'email-not-verified' and 'requires-second-factor' look like failures but are successful steps that route the user onward.
That distinction is about to become the shape of the action.
Wiring the sign-in action
Section titled “Wiring the sign-in action”The five seams match the sign-up action: parse, an empty authorize seam, the single mutating library call, the returns.
The new part is the success path, which forks instead of just returning ok.
'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 matters: password: z.string().min(1), not min(12). Sign-up enforces a strength floor because it sets a password; sign-in only checks one, and an account created when your floor was lower must still be able to sign in. Copying min(12) here locks 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", an unchecked one submits nothing at all, never a real boolean. Don’t reach for z.coerce.boolean(), 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.
'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 here that touches the database. It’s assigned to result rather than awaited-and-discarded like sign-up’s call, because on success there’s a value to read. The try/catch wraps it because this face throws on failure, and the catch hands the error to mapSignInError. The headers: await headers() argument (headers() is async in this version of Next) hands Better Auth the request so it can attach the fresh session cookie on success. 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')) });}The fork, 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 telling the form to render the 2FA prompt for the available methods. No session has been issued yet; the factor-verification call comes later in this chapter. This is the “continuation, not error” idea from the catalog, now in 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, covered shortly. After this the form’s only job is to navigate. (And the revalidate seam: nothing cached changed here, so name it and skip it, as with sign-up.)
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, a discriminated union rather than a single value because “success” here means one of two different things: a finished session, or a 2FA continuation.
Keep it distinct from the generic Result error codes ('validation', 'unauthorized', and friends) from lib/result.ts.
The error codes ride the failure channel; SignInOk rides the success channel.
That separation is why modeling 'requires-second-factor' as an error code gets you stuck: it was never a failure, so it doesn’t belong in the failure union.
Why route through a Server Action at all, rather than calling the client from the form?
The same three reasons as 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 library-specific wording out of your UI.
mapSignInError is the sign-in twin of mapSignUpError, and it handles only the thrown failures: two-factor never reaches it, because two-factor is the success branch you already returned.
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 this translator are deliberate.
The comment gives the reason two cases return the same shape, not a restatement of the code. A reader who deletes it and “tidies up” by giving unknown-email its own friendly message reopens the enumeration hole; 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.
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 occasionally renames them across versions, so read them off $ERROR_CODES rather than from memory.
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, never the library’s wording.
Don’t read these 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 app: it accepts secrets, tells the caller when one is right, and sits at a public URL. So what stops an attacker from trying passwords until one works? The answer comes in two layers, one the library hands you and one you add yourself. Assume the library does more than it does and you ship a real hole.
Layer one: the per-IP rate limit. Better Auth ships this.
Better Auth rate-limits all 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.
The limit is on in production and off in development, so the first 429 you ever see will be in production, from an endpoint your dev machine let you hammer freely.
Crossing the cap produces the 429 that surfaces as the 'too-many-attempts' outcome from the catalog.
This stops a single address pounding one endpoint, the shape of a credential stuffing run that replays a leaked password list from one machine.
It leaks against an attacker who rotates IP addresses, through a botnet or a proxy pool: each address slips under the per-IP cap, and nothing watches per-account.
Layer two: the per-account limit. You add this; core Better Auth does not.
Core Better Auth ships no failed-attempt lockout counter; Auth0 and Clerk include one, but its built-in defense is the per-IP limiter and nothing more.
So you add a second key: a limit that counts attempts per email address, independent of the IP they come from.
The course wires this up later, in the rate-limiting chapter, as the dual-key (per-IP and per-email) limiter every auth endpoint uses.
This second layer stops IP-rotation aimed at one known-good account, exactly the attack layer one misses.
keyed by email address The two layers cover each other. Rotate addresses to beat the per-IP cap, and the per-email key catches the guesses piling up against that one account. Spread one attempt each across thousands of accounts to dodge the per-email key, and the per-IP cap catches the flood from your address. Neither is enough alone; together they leave no easy path. That’s why IP-only, the library’s default, isn’t enough for a product that handles money or private data.
What “remember me” actually controls
Section titled “What “remember me” actually controls”Almost everyone reads “remember me” as “keep me logged in longer.” It does no such thing. It controls one thing: whether the session cookie survives the browser closing.
- Checked (
true). The cookie gets aMax-Ageand persists on disk, so closing the browser and reopening it days later leaves the user signed in. - Unchecked (
false). A session-only cookie with noMax-Age, held in memory and discarded the moment the browser closes, so reopening lands the user back at sign-in.
The correction worth holding onto: the session.expiresAt row in your database is identical either way.
The checkbox sets the cookie’s lifetime on the user’s machine, not how long the server keeps the session alive.
A user who unchecks the box and reopens isn’t signed out because the session expired; the session is still there, valid.
The browser just threw away the cookie that pointed at it.
Max-Age: 30 days persists on disk — survives the browser closing Session kept in memory — cleared the moment the browser closes session { expiresAt: 2026-07-07 } one server-side row, one identical expiresAt Sign-in rotates the session token
Section titled “Sign-in rotates the session token”One more thing happens on the success path, for free, that closes a real attack.
Recall the session fixation threat from the chapter on the auth mental model (“Sessions vs JWTs”).
An attacker who plants a session ID they already know into a victim’s browser before sign-in would, in a naive system, still hold a valid handle to that session afterward.
The defense is complete: mint a brand-new session.token on every successful sign-in, and never reuse a value that existed before authentication.
Better Auth does exactly this.
The token from a successful signInEmail is fresh, so any pre-planted identifier points at nothing the instant the real user signs in.
As with “the library owns the hash” from the last lesson, you write no rotation logic and never see it happen; calling signInEmail gives you the fixation defense for free.
Signing out
Section titled “Signing out”Both faces work as you’d expect, and the only thing to get right is how you trigger them.
await authClient.signOut();Runs in the browser. Takes nothing: the cookie travels with the request, so Better Auth knows which session to kill.
await auth.api.signOut({ headers: await headers() });The server face your action calls. Hand it the request headers so it can read the session cookie.
Either one deletes the session row, clears the cookie, and sends the user back to /sign-in.
Closing the open redirect on ?next=
Section titled “Closing the open redirect on ?next=”One hardening step remains: where the user lands after sign-in, the redirectTo your action already returned.
You’ll build this convenience often.
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 where they were headed.
But it becomes an open redirect the moment you do the obvious thing and redirect(searchParams.get('next')) with whatever is 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 treat as an absolute address to another origin.
The victim signs in through your trusted domain, and your own app then 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 /, not //, and must not be an absolute http(s):// URL.
The course centralizes this in safeNext(url) (from lib/redirects.ts, per the security baseline in the code conventions), which your action’s ok({ redirectTo: safeNext(formData.get('next')) }) was already routing through.
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.
redirect(safeNext(formData.get('next')));safeNext returns the path only when it’s a same-site /... path; anything absolute or protocol-relative falls back to a safe default like /dashboard. Untrusted input can never steer the user off your origin.
Don’t over-trust the library here.
Better Auth validates its own internal redirects against the trustedOrigins you configure, but not yours.
Any next value your form and redirect code handles is on you, and safeNext is how you handle it.
The whole sign-in, end to end
Section titled “The whole sign-in, end to end”Now watch the happy path run end to end, with no branches.
Submit. The user submits their email, password, and the remember-me choice. Nothing has touched the server yet; this step belongs to the browser.
Parse. The action parses with Zod, normalizes the email, and coerces the remember-me checkbox into a real boolean before anything touches the database.
Verify hash. auth.api.signInEmail finds the 'credential' account and verifies the password against the stored hash in constant time, so no timing leaks.
Checks pass. The request is under the rate cap, the email is verified, and no second factor is required, so every gate clears.
Rotate session. A fresh session row and token are issued, never reusing any pre-auth value, and the cookie is attached via nextCookies().
Redirect. The action returns ok({ redirectTo }) and the user lands on the allowlisted destination, signed in.
External resources
Section titled “External resources”The signIn.email / signInEmail surface, the rememberMe option, and the sign-in error codes.
The built-in per-IP limiter, the hardened /sign-in/email default (3 requests / 10 seconds), and the production-on / development-off behavior.
Why the failed-attempt counter belongs to the account, not the IP — the canonical ground for the per-account lockout layer.
Why ?next= must be allowlisted before you redirect, and how a same-origin check closes the open-redirect hole.