Sign in: opaque errors and safe redirects
Accounts can now be created and verified.
This lesson adds the sign-in action: a verified user signs in and lands where they were headed, an unverified user is turned away with a resend link, and a hostile ?next= value is defused before it can hijack the redirect.
Your mission
Section titled “Your mission”signInAction mirrors the sign-up action you already wrote: parse Object.fromEntries(formData) through a Zod schema, call auth.api.* inside a try/catch, return the canonical Result shape on failure.
The mechanics aren’t the lesson.
The lesson is the two security decisions wrapped around the call to auth.api.signInEmail, each easy to get subtly wrong.
The first is error handling.
A failed sign-in tempts you to be helpful, “no account with that email,” then “wrong password,” but that pair is an enumeration oracle: it lets an attacker learn which emails are registered, one request at a time.
So wrong-email and wrong-password collapse into a single opaque message that reveals neither.
The unverified case is safe to distinguish precisely because it only surfaces after the password matched, which already proves the caller controls the account, so “verify your email” leaks nothing new.
You write neither branch by hand: the provided mapAuthError helper turns Better Auth’s error codes into the right Result, so reuse it.
The second is the redirect.
?next= rides in from the URL, so it is attacker-controlled: anyone can send a victim a link carrying any next they like.
Pass it straight to redirect() and you have an open redirect, a page on your own domain that bounces visitors to an external origin, a ready-made launchpad for phishing.
So run ?next= through the provided safeNext guard first, which is already written and tested.
You touch no UI.
The SignInForm already carries next as a hidden input, renders your error card from the action’s result, and shows a resend link when that result is forbidden.
Your job is the action behind it.
Out of scope: the request-time gate that produces ?next=.
This lesson only consumes and sanitizes it.
/sign-in with a verified account’s correct credentials redirects to /dashboard./sign-in?next=/dashboard/billing redirects to /dashboard/billing./sign-in?next=//evil.com or ?next=https://evil.com redirects to /dashboard, never to the external origin.signInEmail.Coding time
Section titled “Coding time”Write signInAction in src/app/(auth)/sign-in/actions.ts against the brief and the tests, then open the walkthrough below to compare.
Reference solution and walkthrough
Only one file changes this lesson. Here it is in full.
'use server';
import type { Route } from 'next';import { redirect } from 'next/navigation';import { z } from 'zod';
import { auth } from '@/lib/auth';import { mapAuthError } from '@/lib/auth/error-mapping';import { safeNext } from '@/lib/redirects';import { err, type Result } from '@/lib/result';
const SignInSchema = z.strictObject({ email: z.string().trim().toLowerCase().pipe(z.email()), password: z.string().min(1), next: z.string().optional(),});
export const signInAction = async ( _prevState: Result<never> | null, formData: FormData,): Promise<Result<never>> => { const parsed = SignInSchema.safeParse(Object.fromEntries(formData)); if (!parsed.success) { return err( 'validation', 'Check the highlighted fields.', z.flattenError(parsed.error).fieldErrors, ); }
// No authorize seam: the credential check is the authorization.
const { email, password } = parsed.data; try { await auth.api.signInEmail({ body: { email, password } }); } catch (e) { return mapAuthError(e); }
const next = safeNext(parsed.data.next); redirect((next ?? '/dashboard') as Route);};The schema
Section titled “The schema”SignInSchema mirrors the sign-up schema with two deliberate differences.
The first is password: z.string().min(1).
Sign-up enforces a twelve-character floor because that is where the strength gate belongs; sign-in has none.
A password is either right or wrong, and only auth.api.signInEmail can say which, so Zod checks for presence only.
A short password isn’t rejected here; it goes to the credential check and fails like any other wrong password.
A .min(12) would be a category error, letting an attacker rule out short passwords without ever hitting your auth backend.
The second is next, carried as z.string().optional().
The form submits it as a hidden field, so it arrives in the same FormData as the credentials and parses at the same boundary: one validation seam for every value crossing into the action, with nothing pulled raw off formData afterward.
The parse-and-bail shape
Section titled “The parse-and-bail shape”A failed safeParse returns the canonical validation Result and stops, the same shape sign-up returned.
This covers the untested requirement: a malformed email or empty password fails the parse and short-circuits before the try, so signInEmail is never called.
The form re-renders inline field errors through its existing useActionState and <FieldError> wiring, with no work for you.
Where the security lives
Section titled “Where the security lives”try { await auth.api.signInEmail({ body: { email, password } });} catch (e) { return mapAuthError(e);}There is no if for “wrong password” and none for “unverified”.
Both outcomes flow through the single catch and the single mapAuthError call, where the branching lives:
const code = error.body?.code;if (code === 'INVALID_EMAIL_OR_PASSWORD') { return err('unauthorized', 'Invalid email or password.');}if (code === 'EMAIL_NOT_VERIFIED') { return err('forbidden', 'Verify your email before signing in.');}Two things there matter.
The opaque message is one string for both a missing account and a wrong password.
Better Auth raises the same INVALID_EMAIL_OR_PASSWORD code for either, so the mapper returns one identical unauthorized result and closes the enumeration vector.
The test pins this by asserting the two messages are byte-identical: any wording difference rebuilds the oracle, so the urge to be more helpful here is the bug.
The unverified refusal looks like a missing branch.
Your action never checks verification, yet an unverified account is reliably refused, because requireEmailVerification: true on the auth instance makes signInEmail validate the password first and only then check verification, throwing EMAIL_NOT_VERIFIED.
mapAuthError turns that into a forbidden result, which the form keys its resend link on.
That ordering is why distinguishing the unverified case is safe: the message can’t surface until the password has already matched, so it tells an attacker who doesn’t control the account nothing.
Both failure branches return a Result and never call redirect(), and a session is issued only on the success path, so a failed sign-in sets no cookie: nothing was granted, so there is nothing to revoke.
Closing the open redirect
Section titled “Closing the open redirect”const next = safeNext(parsed.data.next);redirect((next ?? '/dashboard') as Route);safeNext stands between an attacker-controlled string and the browser’s address bar.
Here is the whole of it:
export const safeNext = (raw: unknown): string | undefined => { if (typeof raw !== 'string') { return undefined; } if (!raw.startsWith('/') || raw.startsWith('//') || raw.includes(':')) { return undefined; } return raw;};It admits one shape, a string starting with a single /, and rejects everything else clause by clause:
- Not starting with
/is not a same-origin path at all. - Starting with
//is a protocol-relative URL: the browser reads//evil.comas the current scheme plus that host and resolves it tohttps://evil.com. This is why a naivestartsWith('/')check is not enough. - Containing
:catches absolute URLs (https://evil.com) and thejavascript:scheme, neither of which a same-origin path can contain.
Anything that fails returns undefined and the caller falls back to /dashboard, so a hostile ?next= doesn’t error the request, it just quietly loses.
A valid relative path like /dashboard/billing passes through untouched.
The as Route cast reads oddly at first.
The project runs with typedRoutes: true, which types redirect() against the routes that actually exist so TypeScript catches a typo’d path at build time.
But next is a runtime string, unknowable at type-check time, so the compiler can’t confirm it points at a real route.
The cast asserts that it does, which is safe because safeNext has already guaranteed a same-origin path: the cast sits downstream of the guard, not in place of it.
Reference for signInEmail, requireEmailVerification, and the EMAIL_NOT_VERIFIED error this action maps.
Why an attacker-controlled ?next= is an open-redirect vector, and how allow-list validation like safeNext closes it.
The account-enumeration rule behind the byte-identical error message for wrong email and wrong password.
Moment of truth
Section titled “Moment of truth”Run the lesson’s test suite:
pnpm test:lesson 4The suite seeds a verified and an unverified account, then runs the action against each.
All seven tests should pass: the redirect lands on /dashboard and honors a valid ?next= while rejecting hostile ones, wrong email and wrong password return the same opaque message, the unverified account gets forbidden, and no failure path creates a session.
✓ tests/lessons/Lesson 4.test.ts (7 tests)
Test Files 1 passed (1) Tests 7 passed (7)Two things the tests can’t see; confirm them in the browser:
session_token cookie set on either (check DevTools → Application → Cookies).authClient.sendVerificationEmail.