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.
Turning on the provider
Section titled “Turning on the provider”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.)
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 library owns the hash
Section titled “The library owns the hash”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:
// only for a measured constraint — the scrypt default is the right callemailAndPassword: { 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.
password user who you are account how you prove it Writing the sign-up action
Section titled “Writing the sign-up action”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.
// inside our Server Actionawait auth.api.signUpEmail({ body: { name, email, password }, headers: await headers(),});Failure is a throw. The server call does the opposite. On a genuine failure (a password under the floor, a malformed email) it throws an APIError rather than returning { error }. The same codes live on error.body.code, with error.status like 'UNPROCESSABLE_ENTITY' (422). Part of the action’s job is to catch that throw and translate it into the course’s Result. (A taken email is the interesting exception: it doesn’t throw here, and the next section is all about why.)
So why the action and not a raw client call? Three reasons, all of them the action-boundary discipline you learned with Server Actions:
- The form’s input gets parsed and validated on the server, because the client can’t be trusted to have done it.
- The library’s throw becomes a typed
Resultthe form can branch on without atry/catchof its own. - 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.
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:
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 callif (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.
function mapSignUpError(error: unknown) { if (error instanceof APIError) { // a taken email never reaches here — the library returned ok instead if (error.body?.code === 'PASSWORD_TOO_SHORT') { return err('validation', 'Password is too short.', { password: ['Too short.'] }); } } return err('internal', 'Something went wrong. Try again.');}No USER_ALREADY_EXISTS branch, because there’s no such throw to catch. Under requireEmailVerification: true, a taken email already returned a generic success, so mapSignUpError only ever sees genuine, non-enumerating failures such as a short password. The comment is the kind production code keeps: it explains the non-obvious security reason a branch is absent.
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.
/sign-in?reason=existing when the email is taken, but to the inbox page when it’s new.200 response with the identical body whether or not the email already existed.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:
// top-level user config — a sibling of emailAndPassword, not nested inside ituser: { 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 success state: check your inbox
Section titled “The success state: check your inbox”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 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:
user who they are The identity row. Created, but not yet confirmed.
account how they prove it The credential row. Password hashed by the library.
verification pending token The pending email-verification token. Owned by no user yet.
session no login yet
No session is issued at sign-up — autoSignIn is off.
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.
The whole flow, end to end
Section titled “The whole flow, end to end”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.
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.
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.
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.
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.
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.
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.
auth.api.signUpEmail runs and the library hashes the password user, account, and verification ok and the user lands on “check your inbox” External resources
Section titled “External resources”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.
The full option surface: requireEmailVerification, autoSignIn, minPasswordLength, the scrypt default and password.hash/verify override, plus the enumeration-safe 200.
The canonical source behind the enumeration section — generic responses, registration discrepancy factors, and the UX trade-off named on purpose.
Cosden Solutions builds the whole sign-up / sign-in / logout flow with Server Actions end to end — the moving version of this lesson's static pieces (35 min).
The breach-list check the lesson named as the senior reach — try the k-anonymity range query that verifies a password without ever sending it.