Skip to content
Chapter 53Lesson 1

Password sign-up

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

Picture the simplest sign-up form there is: a field for a name, a field for an email, a field for a password, and a button. Someone fills it in and presses the button. That single press is the whole subject of this lesson, and it hides four questions, each with a senior answer:

  • What’s the call? When the form submits, what code runs, and what does it return?
  • Where does the password go? Storing it in plaintext is out of the question, so what hashes it, and into which table does the result land?
  • What comes back if the email is already taken? And the question most people never ask: what happens to your security the moment you tell someone “that email is taken”?
  • What does success even look like? There’s a twist here. A correct 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. The instance is in place but inert, because there’s no flow a person can actually run yet. The pipes are laid; this lesson turns the water on. By the end you’ll have a working email-and-password sign-up that hashes the password for you, writes exactly three rows, leaks nothing about which emails exist, and lands the user on a calm “check your inbox” page.

This lesson deliberately stops short of sending the actual email, minting the token, and handling the verify click. That’s covered in Verifying email. We stop the moment the verification email is queued, and you’ll see that “queued, no session” state is exactly where a hardened sign-up is supposed to stop.

The senior question for this section: what’s the smallest config that makes sign-up exist at all, and which settings does an experienced engineer move off the library’s defaults before shipping?

Better Auth ships sign-up turned off. You opt in with an emailAndPassword block. This is an addition to the betterAuth({ ... }) call you wrote last chapter, not a new file. You’re dropping four lines into lib/auth.ts next to the adapter and nextCookies() that are already there.

Here are the four settings, each shown as its default and why we move off it:

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

The on switch. Nothing in this lesson exists without it: sign-up isn’t a route Better Auth serves until this is true.

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

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

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

Set alongside the line above so the user never holds a session before their email is confirmed. Default is true, which would sign them straight in. Combining autoSignIn: true with requireEmailVerification: true is a contradictory, broken state: you’d be saying “don’t trust them until they verify” and “log them in immediately” in the same breath. When verification is on, auto-sign-in must be off.

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

The library ships a floor of 8, and 12 is the 2026 senior floor. Length is the cheap structural minimum, not the whole strength story; entropy comes later in this lesson. (maxPasswordLength defaults to 128, which you rarely touch.)

1 / 1

That’s the entire on-ramp. Notice how little of it is logic: it’s four decisions, and three of them are about trust rather than syntax.

The senior question here: where does the password actually go, what algorithm protects it, and is that a decision you have to make?

The answer is reassuring: you don’t hash anything. You never see a hashing call, you never pick a cost parameter, you never write a 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, and that placement is the spine of the whole auth model you met last chapter: your identity is one row, and every way you can prove that identity is a separate row. The password is one way to prove who you are, so it lives on an account row with providerId: 'credential', sitting next to whatever other proofs (a Google login, a passkey) you might add later. Your user row never holds a secret. The per-password salt is handled for you too.

The value of reaching for the library is that it encodes the hashing decision correctly so you can’t get it wrong. Rolling your own auth is exactly where developers reliably ship the classic credential-storage disasters: md5, or sha256 with no salt, or a string comparison that leaks timing. By using emailAndPassword, the right answer is the only answer available to you.

The override door does exist. You can swap the algorithm:

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

But reaching for that door is a measured decision driven by a specific constraint, such as a compliance requirement that names Argon2id or a benchmark that says you need different cost parameters. It is never a day-one choice, and you don’t need to know a single Argon2 cost parameter to ship a correct sign-up. The default is the right call until something concrete tells you otherwise.

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.

The senior question: the browser can call Better Auth’s sign-up directly, so why wrap it in a Server Action at all, and what’s the shape?

Start with the fact that the same operation has two faces. 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 but have opposite failure shapes, which is the whole reason the action exists.

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 client call returns a { data, error } object, and error.code carries the string. This is familiar, but it runs in the browser, so there’s no place to parse input on the server and no place to keep the library’s error wording from reaching the UI.

So why the action and not a raw client call? Three reasons, all of them the action-boundary discipline you learned with Server Actions:

  1. The form’s input gets parsed and validated on the server, because the client can’t be trusted to have done it.
  2. The library’s throw becomes a typed Result the form can branch on without a try/catch of its own.
  3. A library error message never leaks to the UI, so the user sees copy you wrote, not Better Auth’s internal wording.

A client-only call skips all three. Now to the action itself. It follows the five-seam shape every Server Action in this course uses, 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 we configured. The load-bearing part is the email line, which 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, not decoration. Without it, Ada@acme.com and ada@acme.com are different strings and both slip past the unique index, giving one human two accounts. Note the order: .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[]> projection the course’s 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, because it’s a public endpoint and anyone may sign up. The seam is named so you know it was considered and deliberately 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 here, because the library owns the writes. revalidate is empty too: nothing cached changes on sign-up, so there’s no list to refresh.

'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 actually reach this catch, and, crucially, which one doesn’t, is the subject of the very next section, where the enumeration trade lives.

1 / 1

The form that consumes this Result, wiring it through useActionState, rendering field errors, and showing pending state, is exactly the form shape you’ve already built. Don’t re-plumb it here; the action’s Result plugs into that wiring unchanged.

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

Answering the same way whether the email exists

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

This is the decision at the heart of the lesson, so it’s worth slowing down here.

The senior question: a user types an email that’s already registered. What does the response look like, and what happens to your security posture 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,” and a taken one gets “that email’s already registered, sign in instead.” You’ve just built a free oracle. An attacker scripts your sign-up endpoint against a list of ten million emails and, purely from which message comes back, sorts them into “has an account here” and “doesn’t.” That sorted list is the raw material for credential stuffing and for targeted phishing (“we noticed unusual activity on your AcmeCorp account…”). The leak isn’t the breach itself; it’s what makes the breach cheap.

Now the satisfying part. Look back at the first section: you already closed this leak, and you did it before you’d even heard the word “enumeration.” With requireEmailVerification: true (equivalently, autoSignIn: false), Better Auth’s public sign-up endpoint returns the same 200 success whether the email is new or already taken. Both paths queue a verification email. Neither issues a session. There’s nothing for the attacker to distinguish, because the responses are byte-for-byte the same shape.

So one flag buys you two things. The configuration that gives you the calm “check your inbox” experience is the same configuration that closes the enumeration hole. The structural defense and the nice user experience are not two features you wire separately; they’re one decision, and you already made it.

The next part is worth getting precise, because it’s where the library does more for you than you might expect. The defense is closed at the source: with this config, auth.api.signUpEmail on a taken email does not throw. It returns the same generic success as a fresh sign-up. The library 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, because your catch never sees it. Your mapSignUpError is therefore only ever handed genuinely failing sign-ups, PASSWORD_TOO_SHORT or INVALID_EMAIL, none of which say anything about whether an account exists. Map those to their own Result codes freely; there is no enumerating throw left to suppress.

That raises an honest question: if the taken-email path is silent, how does the real account owner ever find out someone tried to register with their address? Better Auth’s answer is a dedicated seam, onExistingUserSignUp, a callback on emailAndPassword that fires only when this enumeration protection is active (that is, requireEmailVerification: true or autoSignIn: false). It runs server-side, off the response path, so notifying the owner costs the attacker nothing observable:

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.',
});
},
},

That’s the senior shape: the response tells an attacker nothing, while the real owner gets a heads-up out of band. The leak is closed without losing the one person who genuinely needs to know.

// 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, but the moment you add a check that lets the form render 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. If you want to warn the owner, that’s the job of onExistingUserSignUp, not a branch in your response.

There is a legitimate version of the friendly message, and knowing when to use it is the real senior skill. Some products do choose to 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. That can be the right call. The rule isn’t “never be friendly”; it’s decide it on purpose, with the enumeration cost on the table, and never sleepwalk into the leak because the friendlier copy felt nicer. Default to opaque, and trade it away deliberately when the product genuinely warrants it.

A quick check that you’ve internalized the exact distinction this section turns on:

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.

Keeping sign-up minimal: name, and the fields you defer

Section titled “Keeping sign-up minimal: name, and the fields you defer”

The senior question: the product owner wants companyName, role, and timezone collected at sign-up. Do they go on the form now?

Two fields are mandatory: email and password. One more is worth collecting at sign-up, name, because you’ll address the person by it in the very first email. Everything beyond those three is an additionalFields decision, and Better Auth gives you a clean hook for it. Custom columns you declared on the user config last chapter become 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 load-bearing detail is the input flag, a security boundary disguised as a config option. input: true exposes the field on sign-up’s body, so the user may set it themselves. input: false keeps the field server- and admin-only: it exists on the row, but no sign-up payload can write it. The canonical case is role. A user must never be able to set their own role at sign-up, or the first thing an attacker does is register as admin. input: false is the wall between “a field the form collects” and “a field the app assigns.” Get that flag wrong on role and you’ve handed out admin.

So back to the product owner’s three fields. The senior call is to keep sign-up minimal and defer everything else to onboarding. Every extra required field at sign-up is friction at the single highest-drop-off moment in the whole funnel, the form a stranger meets before they’ve gotten any value from you. name stays. companyName and timezone are input: true but collected later, in an onboarding step after the account exists. role is input: false and assigned by the app, never the user. Read additionalFields as the hook for when you need it, paired with the default of not reaching for it at sign-up. It buys you one more thing: a declared field also lands typed on getSession().user, which, recall from last chapter, is why custom fields have to be declared to show up in the session cookie cache.

A password-strength meter, and the line you must not cross

Section titled “A password-strength meter, and the line you must not cross”

The senior question: minPasswordLength: 12 is the floor, so how do you nudge users toward a genuinely strong password without ever trusting the browser to enforce it?

Length is the floor, enforced on the server. Entropy is the ceiling, and it’s a UX concern, never a trust boundary. You can drop a client-side strength meter on the form (zxcvbn-ts is the usual pick) that scores the password as the user types and shows a live bar from “weak” to “strong.” That’s good encouragement. But here’s the hard line, the same one running through every form you’ve built: the meter’s score is never sent to the server, and the server never trusts it. The server’s only password rule is minPasswordLength. The client meter is encouragement; server minPasswordLength is enforcement. Client validation is UX and server validation is correctness, and the meter is a textbook instance of that split.

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

The whole teaching of this section is the trust boundary, not the widget. The meter is a half-hour of UI; knowing it can never be load-bearing is the part that matters.

The senior question: the action returned ok, but there’s no session and no dashboard to send anyone to. So what does the user actually see?

Because autoSignIn: false, success is not a redirect to /dashboard. It’s a “check your inbox” view: a line of confirmation copy echoing the email address they typed (the email you threaded through ok({ email })), plus a resend button. Wire the resend to authClient.sendVerificationEmail({ email }). That endpoint is rate-limited by design, so the button can’t be turned into a tool to flood someone’s inbox. You’ll wire the full rate-limit story in a later chapter, but it matters that the protection exists at this call site from day one.

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 hard rule is no session, no /dashboard, no protected content. The session gate you built last chapter would bounce an unverified user out of the app anyway, but the success page isn’t relying on that bounce; it’s deliberately a surface that dead-ends until verified. There’s nowhere to go from here except to your email. The click on the verification link that finally lifts this state, issuing the session and opening the app, is covered in Verifying email.

So what’s true in the database the instant this page renders? Exactly three rows now exist, and naming them is the concrete payoff of the whole lesson:

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, one session that doesn't exist yet.

Each of those rows has a consumer waiting downstream, which is what ties this one feature into the chapter’s shared substrate. The next lesson, Password sign-in, reads emailVerified to decide whether to let someone in. Verifying email consumes the verification row when the link is clicked. You’ve written the rows; later lessons read them.

You’ve met this feature in six pieces; now see it as one motion. Scrub through the sequence below. It’s the entire sign-up from the button press to the inbox page, with what’s true at each step written underneath. Pay particular attention to step five: the outcome is identical whether or not the email already existed, which is 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. Nothing has happened server-side yet; this is the only step the browser owns.

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. You never see the hashing call.

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 through the email pipeline. No session is issued: autoSignIn is off, so the credential exists but the user is not logged in. This is the hinge the whole lesson turns on.

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 }), and the library handed back the same success whether or not the email was already taken, so there was never a taken-email throw to leak. The enumeration defense holds at the source.

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 still lie ahead, in the next two lessons.

And one last check: drag the steps into the order they actually run. This is the temporal model the diagram just walked, and rebuilding it from memory is how it sticks.

Order the steps a password sign-up takes, from the button press to the success page. Drag the items into the correct order, then press Check.

The user submits name, email, and password
The action parses the input with Zod and normalizes the email
auth.api.signUpEmail runs and the library hashes the password
Three rows are written: user, account, and verification
A verification email is queued — no session is issued
The action returns ok and the user lands on “check your inbox”

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.