Skip to content
Chapter 53Lesson 1

Password sign-up

Build an email-and-password sign-up flow on Better Auth, hashing the password, writing the account rows, and closing the user-enumeration leak.

Picture the simplest sign-up form: name, email, password, and a button. That one press is the whole lesson, and it has to do three things well: hash the password into the right table, answer “that email is taken” without telling an attacker which emails exist, and land the user somewhere correct on success. A 2026 sign-up does not drop the user into the dashboard.

Last chapter you stood up Better Auth’s auth instance: four tables migrated, cookies hardened, session helpers wired, but no flow a person can run yet. This lesson wires that flow, end to end, landing the user on a calm “check your inbox” page.

It stops the moment the verification email is queued: no email sent, no token minted, no session issued. Sending the email and handling the verify click come in Email verification. That is exactly where a hardened sign-up is supposed to stop.

Better Auth ships sign-up turned off. You opt in with an emailAndPassword block, four lines added to the betterAuth({ ... }) call from last chapter in lib/auth.ts, beside the adapter and nextCookies().

Each setting below shows its default and why we move off it:

emailAndPassword: {
enabled: true,
requireEmailVerification: true,
autoSignIn: false,
minPasswordLength: 12,
},

The on switch. Better Auth doesn’t serve a sign-up route until this is true, so nothing in this lesson works without it.

emailAndPassword: {
enabled: true,
requireEmailVerification: true,
autoSignIn: false,
minPasswordLength: 12,
},

The hinge. This flips a successful sign-up from “credential stored, session issued, you’re in” to “credential stored, verification email queued, no session.” The success page and the enumeration defense both follow from this one boolean. Default is false.

emailAndPassword: {
enabled: true,
requireEmailVerification: true,
autoSignIn: false,
minPasswordLength: 12,
},

Turn this off so the user never holds a session before their email is confirmed. The default true signs them straight in, which contradicts requireEmailVerification: you can’t withhold trust until they verify and log them in at the same time. Default is true.

emailAndPassword: {
enabled: true,
requireEmailVerification: true,
autoSignIn: false,
minPasswordLength: 12,
},

The library’s floor is 8; we raise it to 12. Length is the cheap structural minimum, not the whole strength story, entropy comes later in this lesson.

1 / 1

You don’t hash anything — no hashing call, no cost parameter, no salt. When signUpEmail runs, Better Auth hashes the password with scrypt by default and writes the result to one column: account.password.

That column is on account, not user — the auth model from last chapter, where your identity is one row and every way to prove it is a separate row. A password is one such proof, so it lives on an account row with providerId: 'credential', alongside any others (a Google login, a passkey) you add later. Your user row never holds a secret. The per-password salt is handled for you too.

Rolling your own auth is where developers ship the classic credential-storage disasters: md5, sha256 with no salt, a string comparison that leaks timing. With emailAndPassword, the correct answer is the only one available.

You can swap the algorithm:

lib/auth.ts
// only for a measured constraint — the scrypt default is the right call
emailAndPassword: { password: { hash, verify } },

Reach for it only under a concrete constraint, such as a compliance rule that names Argon2id . It is never a day-one choice — you can ship a correct sign-up without knowing a single Argon2 cost parameter.

from the sign-up form password
user who you are
email name no password — no secret here
The password leaves the form, gets hashed, and lands on `account`, never on `user`. Identity holds no secret; the credential row does.

Better Auth gives you one instance reachable two ways: a client face for the browser and a server face for your backend. They do the same job with opposite failure shapes, and reconciling them is the action’s job.

const { data, error } = await authClient.signUp.email({
name,
email,
password,
callbackURL: '/dashboard',
});
if (error) {
// error.code: 'USER_ALREADY_EXISTS', 'PASSWORD_TOO_SHORT', ...
}

Failure is a value you branch on. The call returns { data, error }, with the string on error.code. But it runs in the browser, with no place to validate input or to keep the library’s wording out of the UI.

Why the action and not a raw client call? The same action-boundary discipline you learned with Server Actions: the input is parsed on the server because the client can’t be trusted, the library’s throw becomes a typed Result the form branches on, and a library message never leaks to the UI.

The action follows the five-seam shape parse → authorize → mutate → revalidate → return, and sign-up specializes a couple of the seams in instructive ways.

'use server';
const signUpSchema = z.object({
name: z.string().min(1),
email: z.string().trim().toLowerCase().pipe(z.email()),
password: z.string().min(12),
});
export async function signUp(
prevState: Result<{ email: string }> | null,
formData: FormData,
): Promise<Result<{ email: string }>> {
const parsed = signUpSchema.safeParse(Object.fromEntries(formData));
if (!parsed.success) {
return err('validation', 'Check the highlighted fields.', z.flattenError(parsed.error).fieldErrors);
}
const { name, email, password } = parsed.data;
try {
await auth.api.signUpEmail({ body: { name, email, password }, headers: await headers() });
} catch (error) {
return mapSignUpError(error);
}
return ok({ email });
}

parse. Object.fromEntries(formData) turns the form fields into an object, then safeParse runs signUpSchema (lines 3–7). The schema is the contract: z.email() is the top-level email builder, and min(12) mirrors the server floor. The email line is load-bearing, so it gets its own step next.

'use server';
const signUpSchema = z.object({
name: z.string().min(1),
email: z.string().trim().toLowerCase().pipe(z.email()),
password: z.string().min(12),
});
export async function signUp(
prevState: Result<{ email: string }> | null,
formData: FormData,
): Promise<Result<{ email: string }>> {
const parsed = signUpSchema.safeParse(Object.fromEntries(formData));
if (!parsed.success) {
return err('validation', 'Check the highlighted fields.', z.flattenError(parsed.error).fieldErrors);
}
const { name, email, password } = parsed.data;
try {
await auth.api.signUpEmail({ body: { name, email, password }, headers: await headers() });
} catch (error) {
return mapSignUpError(error);
}
return ok({ email });
}

Email normalization is a real watch-out. Without it, Ada@acme.com and ada@acme.com are different strings that both slip past the unique index, giving one human two accounts. Order matters: .trim().toLowerCase() runs before .pipe(z.email()), so the email is canonical and a stray trailing space won’t fail the format check. Normalize first, validate second.

'use server';
const signUpSchema = z.object({
name: z.string().min(1),
email: z.string().trim().toLowerCase().pipe(z.email()),
password: z.string().min(12),
});
export async function signUp(
prevState: Result<{ email: string }> | null,
formData: FormData,
): Promise<Result<{ email: string }>> {
const parsed = signUpSchema.safeParse(Object.fromEntries(formData));
if (!parsed.success) {
return err('validation', 'Check the highlighted fields.', z.flattenError(parsed.error).fieldErrors);
}
const { name, email, password } = parsed.data;
try {
await auth.api.signUpEmail({ body: { name, email, password }, headers: await headers() });
} catch (error) {
return mapSignUpError(error);
}
return ok({ email });
}

parse failure. On a bad form, return err('validation', ...) with z.flattenError(parsed.error).fieldErrors, the flat Record<string, string[]> the Result is shaped around. The form reads it per field.

'use server';
const signUpSchema = z.object({
name: z.string().min(1),
email: z.string().trim().toLowerCase().pipe(z.email()),
password: z.string().min(12),
});
export async function signUp(
prevState: Result<{ email: string }> | null,
formData: FormData,
): Promise<Result<{ email: string }>> {
const parsed = signUpSchema.safeParse(Object.fromEntries(formData));
if (!parsed.success) {
return err('validation', 'Check the highlighted fields.', z.flattenError(parsed.error).fieldErrors);
}
const { name, email, password } = parsed.data;
try {
await auth.api.signUpEmail({ body: { name, email, password }, headers: await headers() });
} catch (error) {
return mapSignUpError(error);
}
return ok({ email });
}

authorize, the empty seam. Sign-up is the one action with no caller to authorize: it’s a public endpoint, and anyone may sign up. The seam is named so you know it was considered and left empty, not forgotten.

'use server';
const signUpSchema = z.object({
name: z.string().min(1),
email: z.string().trim().toLowerCase().pipe(z.email()),
password: z.string().min(12),
});
export async function signUp(
prevState: Result<{ email: string }> | null,
formData: FormData,
): Promise<Result<{ email: string }>> {
const parsed = signUpSchema.safeParse(Object.fromEntries(formData));
if (!parsed.success) {
return err('validation', 'Check the highlighted fields.', z.flattenError(parsed.error).fieldErrors);
}
const { name, email, password } = parsed.data;
try {
await auth.api.signUpEmail({ body: { name, email, password }, headers: await headers() });
} catch (error) {
return mapSignUpError(error);
}
return ok({ email });
}

mutate. The single library call, wrapped in try/catch because the server API throws. headers: await headers() hands Better Auth the request headers (headers() is async in Next.js 16). There’s no db.transaction, because the library owns the writes. revalidate is empty too: nothing cached changes on sign-up.

'use server';
const signUpSchema = z.object({
name: z.string().min(1),
email: z.string().trim().toLowerCase().pipe(z.email()),
password: z.string().min(12),
});
export async function signUp(
prevState: Result<{ email: string }> | null,
formData: FormData,
): Promise<Result<{ email: string }>> {
const parsed = signUpSchema.safeParse(Object.fromEntries(formData));
if (!parsed.success) {
return err('validation', 'Check the highlighted fields.', z.flattenError(parsed.error).fieldErrors);
}
const { name, email, password } = parsed.data;
try {
await auth.api.signUpEmail({ body: { name, email, password }, headers: await headers() });
} catch (error) {
return mapSignUpError(error);
}
return ok({ email });
}

return. On success, ok({ email }) passes the email back so the “check your inbox” page can echo the exact address. On a caught throw, mapSignUpError(error) turns it into a Result code. Which failures reach this catch, and which one doesn’t, is the next section.

1 / 1

The form that consumes this Result through useActionState, rendering field errors and pending state, is the form shape you’ve already built; the Result plugs in unchanged.

One vocabulary note before the hard part. APIError is what your catch receives. The next section is about which failures arrive there, and the one case that never does.

Answering the same way whether the email exists

Section titled “Answering the same way whether the email exists”

A user types an email that’s already registered. What comes back, the instant you tell them “that email is taken”?

The threat has a name: user enumeration . Suppose your sign-up is friendly: a fresh email gets “check your inbox,” a taken one gets “that email’s already registered, sign in instead.” You’ve built a free oracle. An attacker scripts your endpoint against ten million emails and, purely from which message comes back, sorts them into “has an account here” and “doesn’t.” That list is raw material for credential stuffing and for targeted phishing (“we noticed unusual activity on your AcmeCorp account…”). The leak isn’t the breach; it’s what makes the breach cheap.

You already closed this leak in the first section. With requireEmailVerification: true (equivalently, autoSignIn: false), Better Auth’s sign-up endpoint returns the same 200 whether the email is new or taken: both paths queue a verification email, neither issues a session, and the two responses are byte-for-byte identical. The config behind the calm “check your inbox” experience is the same config that closes the enumeration hole.

The defense is closed at the source. On a taken email, auth.api.signUpEmail does not throw; it returns the same generic success as a fresh sign-up, and even hashes a password it will never store so the timing of the two paths matches and can’t be used as a side-channel. There is no USER_ALREADY_EXISTS for your catch to mishandle. Your mapSignUpError only ever sees genuine failures like PASSWORD_TOO_SHORT or INVALID_EMAIL, none of which reveal whether an account exists.

So how does the real owner learn someone tried to register with their address? Better Auth’s answer is onExistingUserSignUp, a callback on emailAndPassword that fires only when this protection is active (requireEmailVerification: true or autoSignIn: false). It runs server-side, off the response path, so the attacker sees nothing while the owner gets a heads-up out of band:

lib/auth.ts
emailAndPassword: {
enabled: true,
requireEmailVerification: true,
autoSignIn: false,
minPasswordLength: 12,
onExistingUserSignUp: async ({ user }, request) => {
// fires server-side on a taken-email sign-up — the response stays generic
void sendEmail({
to: user.email,
subject: 'Did you try to sign up?',
text: 'Someone used your email to sign up. If it was you, sign in instead.',
});
},
},
// a "helpful" pre-check bolted on before the signUpEmail call
if (await emailAlreadyRegistered(email)) {
return err('conflict', 'That email is already registered. Sign in instead.');
}
await auth.api.signUpEmail({ body: { name, email, password }, headers: await headers() });

Reopens the leak you just closed. The library already answers a taken email with a generic success; the moment you add a check that renders a distinct “already registered” message, you’ve rebuilt the oracle by hand. It’s tempting because it’s friendlier, and wrong because it’s the exact tell the attacker scripts. To warn the owner, use onExistingUserSignUp, not a branch in your response.

There is a legitimate version of the friendly message. Some products do say “this email is already registered, sign in instead,” because for low-value accounts the support tickets saved (“why won’t it let me sign up?”) outweigh the harvesting risk. The rule isn’t “never be friendly”; it’s decide it on purpose, with the enumeration cost on the table. Default to opaque, and trade it away only when the product genuinely warrants it.

An attacker scripts your sign-up endpoint against a list of emails, reading only the response to sort them into “has an account” and “doesn’t.” Which of these responses hand them that answer? Select all that apply.

An inline field error under the email input reading “this email is already in use.”
The same “check your inbox” page, with a verification email sent, for every submission.
A redirect to /sign-in?reason=existing when the email is taken, but to the inbox page when it’s new.
A 200 response with the identical body whether or not the email already existed.
A custom “is this email already registered?” pre-check that swaps the copy when it returns true.

Two fields are mandatory: email and password. One more earns its place, name, because you’ll address the person by it in the first email. Everything else is an additionalFields decision. A custom column you declare on the user config (as you did last chapter) lands typed on signUpEmail’s body and on the session’s user:

lib/auth.ts
// top-level user config — a sibling of emailAndPassword, not nested inside it
user: {
additionalFields: {
companyName: { type: 'string', required: false, input: true },
role: { type: 'string', required: false, input: false },
},
},

The input flag is a security boundary, not a formatting option. input: true exposes the field on sign-up’s body, so the user may set it; input: false keeps the column server- and admin-only, writable by no sign-up payload. That wall separates a field the form collects from one the app assigns, and role belongs on the safe side: let a user set their own role and an attacker’s first move is to register as admin.

So keep sign-up minimal and defer the rest to onboarding, since every extra required field is friction at the funnel’s highest-drop-off moment, the form a stranger meets before you’ve given them any value. companyName is input: true but collected later, once the account exists; role is input: false, assigned by the app. Reach for additionalFields only when you need it.

Encouraging strong passwords without trusting the meter

Section titled “Encouraging strong passwords without trusting the meter”

minPasswordLength: 12 is the only password rule the server enforces. To nudge users past that floor, drop a client-side strength meter on the form (zxcvbn-ts is the usual pick) that estimates entropy as the user types and shows a live bar from “weak” to “strong.”

That bar is encouragement, not enforcement. The meter’s score is never sent to the server, and the server never trusts it: the client suggests, minPasswordLength is the rule.

For elevated-risk products there’s a check beyond length: a k-anonymity lookup against HaveIBeenPwned that rejects passwords from public breaches. That’s the senior reach when you handle money or admin surfaces, named here rather than built.

The action returned ok, but with autoSignIn: false there’s no session and no dashboard to redirect to. Success is a “check your inbox” view instead: a line confirming the email the user typed (the email you threaded through ok({ email })), plus a resend button wired to authClient.sendVerificationEmail({ email }). That endpoint is rate-limited by design, so it can’t be used to flood an inbox.

Check your inbox

We sent a verification link to ada@acme.com. Click it to finish setting up your account.

Resend is rate-limited — it can't be used to flood an inbox.

The whole success state: no session, no dashboard, just a confirmation and a way to resend.

The page deliberately dead-ends — no session, no /dashboard, no protected content — until the user clicks the verification link, which issues the session and opens the app (covered in Email verification).

So what’s in the database the instant this page renders? Exactly three rows, and no session:

written — 3 rows
user who they are
emailVerified false

The identity row. Created, but not yet confirmed.

account how they prove it
providerId 'credential' password <scrypt hash>

The credential row. Password hashed by the library.

verification pending token
identifier <email>

The pending email-verification token. Owned by no user yet.

not written
session no login yet
does not exist

No session is issued at sign-up — autoSignIn is off.

Three rows written at sign-up; the session row that doesn't exist yet.

Each row is read later: Password sign-in checks emailVerified, and Email verification consumes the verification row when the link is clicked.

Scrub through the whole sign-up below, from the button press to the inbox page, with what’s true at each step underneath. Watch step five, where the outcome is identical whether or not the email already existed — the enumeration defense made visible.

1 Submit name · email · password
2 Parse Zod · trim + lowercase
3 Hash + write rows scrypt · 3 rows
4 Queue email verify mail · no session
5 Return ok ok({ email })
6 Check inbox dead-end until verified
session: not decided yet enumeration: leak stays closed

Submit. The user fills in name, email, and password and presses the button. This is the only step the browser owns; nothing has happened server-side yet.

1 Submit name · email · password
2 Parse Zod · trim + lowercase
3 Hash + write rows scrypt · 3 rows
4 Queue email verify mail · no session
5 Return ok ok({ email })
6 Check inbox dead-end until verified
session: not decided yet enumeration: leak stays closed

Parse. The action runs signUpSchema.safeParse and trims + lowercases the email, so Ada@acme.com and ada@acme.com collapse into one canonical address before anything touches the database.

1 Submit name · email · password
2 Parse Zod · trim + lowercase
3 Hash + write rows scrypt · 3 rows
4 Queue email verify mail · no session
5 Return ok ok({ email })
6 Check inbox dead-end until verified
session: not decided yet enumeration: leak stays closed

Hash and write rows. auth.api.signUpEmail runs. The library hashes the password with scrypt and writes three rows: user (emailVerified: false), account (providerId: 'credential', the hash), and verification.

1 Submit name · email · password
2 Parse Zod · trim + lowercase
3 Hash + write rows scrypt · 3 rows
4 Queue email verify mail · no session
5 Return ok ok({ email })
6 Check inbox dead-end until verified
no session yet — autoSignIn off enumeration: leak stays closed

Queue email. A verification email is queued. No session is issued: with autoSignIn off, the credential exists but the user is not logged in.

1 Submit name · email · password
2 Parse Zod · trim + lowercase
3 Hash + write rows scrypt · 3 rows
4 Queue email verify mail · no session
5 Return ok ok({ email })
6 Check inbox dead-end until verified
no session yet — autoSignIn off same answer whether the email existed

Return ok. The action returns ok({ email }) — the same success whether or not the email was already taken, so there was never a taken-email throw to leak.

1 Submit name · email · password
2 Parse Zod · trim + lowercase
3 Hash + write rows scrypt · 3 rows
4 Queue email verify mail · no session
5 Return ok ok({ email })
6 Check inbox dead-end until verified
no session yet — autoSignIn off enumeration: leak stays closed

Check inbox. The user lands on the “check your inbox” page, a dead-end until verified. The session, the dashboard, and emailVerified: true all lie ahead.

The library docs are the option surface this lesson configured, the OWASP cheat sheet is the canonical ground for the enumeration discussion, and the two extras let you go hands-on with the ideas the lesson only named.