Changing the password and the email
Let a signed-in user rotate their own password and email through Better Auth, re-proving identity before each sensitive change.
In Password reset you handed a forgotten password back to someone who, by definition, couldn’t prove who they were. This lesson is the mirror image. The user is signed in, sitting in their account settings, and wants to change the very credentials that signed them in: their password and their email.
You’ll stand up /settings/security, wire changePassword and changeEmail against Better Auth, and re-prompt for identity when the session has gone stale. The judgment call underneath that work is which kind of re-proof each change demands, which sessions survive a credential change, and why the change-email link goes to the address the user is leaving. This lesson covers only credential changes for a signed-in user; forgotten-password recovery is the unauthenticated sibling in Password reset.
Two ways to re-prove identity
Section titled “Two ways to re-prove identity”The reflex is to gate every protected action on one question: is there a session? That gate is right for changing a display name or a notification preference. It is dangerously wrong for changing a password.
Picture the threat. You’re signed in on a friend’s laptop and step away for coffee. For those ninety seconds is there a session? returns true for whoever is in the chair, and if that check were the only thing guarding your password, they could rotate it and lock you out. A valid session proves someone signed in here at some point; it does not prove you are making this particular request right now.
Sensitive actions need a stronger idea: elevation . Before a high-stakes mutation runs, the app re-proves the actor by asking for something the session alone doesn’t carry. There are two different ways to do this, for two different situations.
Tier one: re-prove the credential
Section titled “Tier one: re-prove the credential”When the action changes a credential the user already has, the strongest check is to make them re-supply it. Changing the password is the clean case: the form asks for the current password before setting a new one. This holds even on a session created two seconds ago, because re-proving the actual credential is a stronger guarantee than checking how recently someone signed in. Better Auth’s changePassword requires a currentPassword field, so the gate is enforced for you.
Tier two: re-prove with a fresh sign-in
Section titled “Tier two: re-prove with a fresh sign-in”The harder case is a high-stakes action with no credential to re-supply: changing the email hands over a sign-in identifier, but there’s no “current email” to type back. Enabling two-factor, adding a passkey, and deleting the account have the same shape.
For these, the gate is freshAge , the freshness window you set in Session and cookie config. A session is fresh if it was authenticated within freshAge, ten minutes in this course, so high-stakes actions re-prompt often. When a freshAge-gated action runs on a stale session, it returns 'requires-re-authentication' and the UI prompts for a real sign-in, which mints a fresh session before the action can fire. We build that branch later.
The boundary between the two tiers is whose code consults the gate. Library endpoints own it: changePassword enforces the current password, while changeEmail, two-factor, and passkey endpoints enforce freshAge themselves. A destructive action you write yourself, such as deleting an account, has to check freshAge explicitly in your own code. The gate is the same either way.
That’s three situations. Walk the decision, committing to a branch at each step before reading the leaf.
The form requires the current password, and Better Auth’s changePassword enforces it.
freshAge is not consulted: re-proving the actual credential is a stronger guarantee than checking how recently someone signed in, so this holds even on a session created two seconds ago.
This is the tier for change-password.
There’s no per-action credential to re-supply, so the gate is session freshness: authenticated within freshAge, ten minutes in this course.
Library endpoints (change-email, 2FA, passkeys) check it automatically; app-owned destructive actions call the freshAge helper themselves.
A stale session returns 'requires-re-authentication', and the UI re-prompts through a real signIn.email that mints a fresh session before the action can fire.
Ordinary mutations, such as updating a display name or flipping a preference, don’t need elevation. They still need authorization at the action boundary (the right user, acting on their own data), but not a re-proof of identity. This is the only tier where the “is there a session?” reflex actually fits.
The /settings/security page
Section titled “The /settings/security page”/settings/security is a protected route: the proxy from the previous lesson bounces signed-out users away from anything under /settings, and the layout’s requireUser() from Reading the session everywhere is the validating read that confirms a real session sits behind the cookie.
That gate proves the user is signed in. It does not prove the user is still you — the seam where the borrowed-laptop attack lands. So elevation is layered on top of the page’s protection, per action: put credential-change forms (or the active-sessions list from the next lesson) behind a merely-protected surface that never elevates, and you’ve handed the borrowed laptop a way to mutate credentials.
The page is three independent forms — three <form> elements, three Server Actions, three useActionState hooks — so one form’s error never touches another’s:
- Change password: current, new, confirm.
- Change email: the current email shown read-only, plus the new one.
- A links section: out to two-factor setup, passkeys, and the active-sessions list (the next lesson). These are navigation, not forms.
The forms are the same shape you’ve written since useActionState — uncontrolled inputs with defaultValue, a <SubmitButton> reading pending from useFormStatus — so we cover only the actions behind them.
Each form switches on its own Result, drawn from a small shared catalog: 'ok', 'invalid-credentials' (the current password was wrong), 'email-taken', 'requires-re-authentication', and 'no-password-set' (the OAuth-only edge we reach last). Two of them, 'invalid-credentials' and 'email-taken', must read as the same failure the user already saw at sign-in: copy like “that email is already in use” hands an attacker an oracle for which addresses have accounts, so the enumeration discipline from Password sign-up applies here unchanged.
The surface on disk:
Directoryapp/
Directory(app)/
Directorysettings/
Directorysecurity/
- page.tsx renders the three forms, reads the current email server-side
- actions.ts
changePassword,changeEmail, the Server Actions Directory_components/
- change-password-form.tsx
- change-email-form.tsx
- security-links.tsx to 2FA, passkeys, active sessions
Changing the password
Section titled “Changing the password”The whole flow hangs off one call:
await auth.api.changePassword({ body: { currentPassword, newPassword, revokeOtherSessions: true, }, headers: await headers(),});You wrote none of what happens behind that call. The library compares currentPassword in constant time against the scrypt hash on the user’s 'credential' account row, exactly the posture from Password sign-up. If the compare fails, the change is refused. If it passes, the library enforces the minimum password length (twelve, the course floor), hashes newPassword, and updates the stored hash. No plaintext password leaves your action, and you never touch a hashing function.
revokeOtherSessions: true is what makes this a real rotation. A password change often means the old password is compromised, so every session minted under the old credential has to die. The library defaults this to false, so set it to true at the call site every time. It is the authenticated cousin of revokeSessionsOnPasswordReset from Password reset, with one deliberate difference covered below.
Notice what’s not in that call: no freshAge check, no role check, no custom “is this really you” logic. The currentPassword field is the elevation. The form sends it even when the session is brand new, for the two reasons from the last section, defense-in-depth and library enforcement. Re-proving the credential is the gate, and the credential is right there in the request body.
The action below is the same five-seam skeleton (parse, authorize, mutate, revalidate, return) you’ve now written four times, so it ships as a plain code block. Two lines carry the weight, and they’re marked.
'use server';
const changePasswordSchema = z .object({ // Only *checked*, never *set* — a non-empty guard is all it needs. currentPassword: z.string().min(1), // *Setting* a credential, so the sign-up floor applies again. newPassword: z.string().min(12), confirmPassword: z.string(), }) .refine((data) => data.newPassword === data.confirmPassword, { error: 'Passwords do not match.', path: ['confirmPassword'], });
export async function changePassword( _prev: ChangePasswordState, formData: FormData,): Promise<ChangePasswordState> { const parsed = changePasswordSchema.safeParse( Object.fromEntries(formData), ); if (!parsed.success) { return err( 'validation', 'Check the highlighted fields.', z.flattenError(parsed.error).fieldErrors, ); }
// No authorize seam: the library's currentPassword check *is* the elevation.
try { await auth.api.changePassword({ body: { currentPassword: parsed.data.currentPassword, newPassword: parsed.data.newPassword, revokeOtherSessions: true, }, headers: await headers(), }); } catch (error) { if (error instanceof APIError) { return mapChangePasswordError(error); } throw error; }
return ok(null);}One branch deserves a callout. When currentPassword is wrong, mapChangePasswordError returns 'invalid-credentials', the same message no matter why the compare failed. Don’t let the copy distinguish “wrong password” from anything else. That’s the enumeration discipline from Password sign-in applied to a new surface: a precise error message is a precise hint to an attacker.
Which sessions die, and why
Section titled “Which sessions die, and why”Both reset and change-password rest on one rule: a credential change must invalidate the sessions minted under the old credential. What differs is which sessions count as “old.”
- Reset was unauthenticated. The user couldn’t prove who they were, which was the entire problem, so
revokeSessionsOnPasswordReset: truekills every session. There’s no trusted session to spare. - Change-password is authenticated. The user is signed in and actively working, so
revokeOtherSessions: truekills every session except the current one. That session just re-proved the credential, and signing the user out of the tab they’re standing in would be hostile and pointless.
Same principle, opposite disposition, and the deciding factor is whether the current session can be trusted. The two panels below show the same account with the same starting sessions the instant after the change, so the only thing that varies is which keys survive.
revokeSessionsOnPasswordReset: true After the change
No current session to trust — everything dies, then the user signs in fresh.
revokeOtherSessions: true After the change
The session you are using survives; every other key is revoked.
Both knobs are opt-in, both default to off, and both are a line you have to write. The course makes that a rule: credential-mutating actions pass revokeOtherSessions: true. The library won’t do it for you, and the happy path won’t remind you, so without that line the old sessions quietly live on.
Changing the email
Section titled “Changing the email”Change-email reuses every primitive from Email verification: the verification table, the one-time hashed token, and the constant-time lookup. The mechanics aren’t the lesson. The lesson is one counter-intuitive decision, and it’s the place where the library’s default is the less secure option: the confirmation link goes to the address the user is leaving, not the one they’re moving to.
The config, and what the default does
Section titled “The config, and what the default does”changeEmail is disabled by default. You enable it on the user block of your auth config, but the default flow it gives you isn’t what you want:
export const auth = betterAuth({ user: { changeEmail: { enabled: true, sendChangeEmailConfirmation: async ({ user, newEmail, url }) => { await sendEmail({ to: user.email, subject: 'Confirm your email change', react: ChangeEmailConfirmation({ url, newEmail }), }); }, }, },});With enabled: true and nothing else, Better Auth sends the verification link to the new address and flips user.email only once the user clicks it. That proves the user controls the new mailbox; it proves nothing about whether they control the existing account.
The opt-in sendChangeEmailConfirmation callback closes the gap. Its to field is user.email, the current address, so the library confirms the request on the inbox the user already controls before the change proceeds. The library still mints the token and builds the url; your callback only delivers it, using the same sendEmail shape from Email verification.
Why confirm the old address
Section titled “Why confirm the old address”Verifying the new address answers one question: does this person control the inbox they’re moving to? That’s all the default does. Confirming the old address answers the question that matters: does this person control the account right now?
That second check closes a specific attack. An attacker who briefly grabs a session can try to change the email to one they own, and if the only check is “verify the new inbox,” they pass it trivially and lock the real owner out. The email is a sign-in identifier (signIn.email({ email, password })), so changing it is a takeover-grade action. Confirming on the old address means the attacker can’t complete the change without also controlling the inbox the real owner still has.
This is the same shape you met with reset-revocation: the secure behavior is the flag you turn on, not the default. Wire only enabled: true and you ship a change-email that a stolen session can drive. For most web apps, confirming the current address plus a clear notification is enough.
The call, the notices, and the reused token
Section titled “The call, the notices, and the reused token”The call inside the Server Action is small. It’s the same auth.api.* server face you wrapped for change-password, so a thrown APIError lands in your try/catch and maps to a Result:
await auth.api.changeEmail({ body: { newEmail, callbackURL: '/settings/security', }, headers: await headers(),});With sendChangeEmailConfirmation wired, the library confirms the current address first; on the click (and the new-address verification), it flips user.email to newEmail and lands the user back on callbackURL.
The library sends the verification token and nothing else. The notifications that make this safe in production are your code, sent through the same sendEmail pipeline you built on Resend and React Email:
- At request time, a “your email is being changed to X” heads-up to the current address.
- After the flip, a “your email was changed” notice to both addresses, carrying a “wasn’t you?” link that revokes all sessions and forces a password reset.
That second notice isn’t a nicety. A silent email change gives the real owner no chance to react. Notify both addresses, always.
The token is the same verification row from Email verification, one-time and hashed at rest, under a different identifier namespace: change-email:<userId>:<newEmail>. Only the namespace and entry point differ, so there’s nothing new to learn here.
One choice is a product call rather than a fixed default. The library does not revoke sessions on an email change, because the password still works. But if your product treats the email as a sign-in identifier, which it does, revoke anyway; at minimum, the “wasn’t you?” link gives the real owner a revoke-all-and-reset escape hatch. Decide it against your threat model.
The third stage of the flow below, confirming the current address, is the one to slow down on.
new email
CURRENT address
the link
flip user.email
addresses
/settings/security.
The current email is shown read-only.
new email
CURRENT address
the link
flip user.email
addresses
verification table as
email-verification, under a different namespace — change-email:<userId>:<newEmail>.
new email
CURRENT address
the link
flip user.email
addresses
sendChangeEmailConfirmation wired, the confirmation
goes to the address they're leaving — proving they
control the account now. The library's default skips this
and verifies only the new inbox; turning it on is the deliberate
choice that closes the takeover path.
new email
CURRENT address
the link
flip user.email
addresses
new email
CURRENT address
the link
flip user.email
addresses
user.email flips to newEmail — but only
after the current-inbox owner confirmed and the new inbox is
verified.
new email
CURRENT address
the link
flip user.email
addresses
The re-authentication prompt
Section titled “The re-authentication prompt”This is where freshAge, the second tier from the top of the lesson, becomes code. A high-stakes action runs behind a freshness check; change-email is the in-lesson example, but two-factor, passkeys, and account deletion follow the same shape. When the session is older than ten minutes, the action returns 'requires-re-authentication'. The form switches on that discriminant, replaces itself with a small re-auth prompt (“for security, sign in again to continue”), and on a successful sign-in returns the user to /settings/security with the action ready to retry.
Treat 'requires-re-authentication' as a continuation, not an error. It’s the same kind of signal as 'requires-second-factor' from Password sign-in: not “you failed” but “you’re not done; here’s the next screen.” So it doesn’t belong in the generic error union in lib/result.ts. It’s a discriminant the action produces and the form routes on. How the action detects a stale session is version-dependent, but the outward signal is always the same.
Now the rule you can’t bend. The re-prompt must call signIn.email, the real sign-in endpoint, rather than a hand-rolled modal that verifies the password against a custom endpoint. The real endpoint carries protections you inherit for free: the rate limiting that comes later in the course, plus session rotation and session fixation defense. Rotating the session on every real sign-in is what defeats fixation, and a custom password-check skips that rotation along with everything else.
These are the two tiers as two code paths: change-password re-proves through the currentPassword field, because a credential exists to re-supply, while the freshAge re-prompt re-proves through a fresh sign-in, because there’s no per-action credential.
'use server';
// Don't do this. A custom password-check that re-implements sign-in.export async function reauthThenChangeEmail( _prev: ChangeEmailState, formData: FormData,): Promise<ChangeEmailState> { const { password, newEmail } = parse(formData); const account = await getCredentialAccount(); const ok = await verifyScrypt(password, account.password); if (!ok) return err('invalid-credentials', 'Wrong password.'); // "proved" identity — now fire the real change return changeEmail(_prev, formData);}Re-implements sign-in, badly. It re-hashes and compares the password by hand, inheriting none of the real endpoint’s protections: no rate limit, no session rotation, no fixation defense. Each is now a failure mode you handle yourself.
'use client';
export function ChangeEmailForm() { const [state, action] = useActionState(changeEmail, initial);
if (state.code === 'requires-re-authentication') { return ( <ReauthPrompt onSubmit={async ({ email, password }) => { // The real endpoint: fresh, rotated session for free. await authClient.signIn.email({ email, password }); // Identity re-proved — re-fire the original action. startTransition(() => action(lastFormData)); }} /> ); }
return <form action={action}>{/* current email, new email */}</form>;}Routes through the real sign-in. signIn.email issues a fresh, rotated session and carries the rate limit and fixation defenses for free; the form just re-fires the original action once identity is re-proved.
The OAuth-only user
Section titled “The OAuth-only user”A user who signed up with Google back in Social sign-in (OAuth) has no 'credential' account row, because they never set a password. So when they open the change-password form, changePassword has no currentPassword to verify and can’t succeed. The library returns something like 'no-password-set'. Ground the exact code against $ERROR_CODES on the client rather than hardcoding the string, the same discipline you’ve used for every error mapping.
Don’t surface that as a raw error. Detect the OAuth-only state in your error mapper and return something the form can act on, such as “You sign in with Google. Set a password to enable email and password sign-in,” with a path to setPassword. Match against the code read off auth.$ERROR_CODES, never a hardcoded literal, since the exact value is version-volatile:
const codes = auth.$ERROR_CODES;
const mapChangePasswordError = (error: APIError): ChangePasswordState => { if (error.body?.code === codes.CREDENTIAL_ACCOUNT_NOT_FOUND) { return err('no-password-set', 'You sign in with Google. Set a password to add email sign-in.'); } return err('invalid-credentials', 'That password is incorrect.');};Keep two things straight about setPassword. It is a different call from changePassword: it adds the missing 'credential' row rather than rotating an existing one. And it is server-only: because it sets a credential with no current password to re-prove, the library refuses to expose it to the client, so it runs inside a Server Action.
You saw the mirror of this in Social sign-in (OAuth): an OAuth-only user who mistyped an email and password at sign-in got a “you sign in with Google” branch instead of a bare failure. It’s the same account state on two surfaces. An account can simply lack a password, and a polished app handles that gracefully wherever it surfaces.
Review and field mistakes
Section titled “Review and field mistakes”Test the spine of the lesson before the field mistakes. More than one of these is true.
Which of these are true about elevation for credential changes? Select all that apply.
freshAge as its primary gate.signIn.email; and revokeOtherSessions is opt-in, off by default. Elevation isn’t one gate — it’s two. Password change re-proves the credential (the current-password field is the gate, so a valid session alone isn’t enough and freshAge is never consulted here). Email and the other no-credential actions re-prove freshness via freshAge, re-prompting through a real sign-in when stale. And revocation is always something you ask for: revokeOtherSessions: true on password change, off by default — while an email change revokes nothing on its own unless you add it.Now the field mistakes, the ones that pass every happy-path test and fail in production. Each is a one-liner to pattern-match in your own review.
The one thing the user still can’t do is see those sessions: the phone they revoked, the old laptop, the device they’re on right now. The next lesson builds that surface, an active-sessions list with per-device revocation, where revokeOtherSessions stops being a flag you pass and becomes a button the user can press.
External resources
Section titled “External resources”The changePassword and changeEmail surface, the changeEmail default vs the sendChangeEmailConfirmation opt-in, and server-only setPassword for OAuth-only users.
Grounding for the elevation tier, freshAge, and the stale-session signal. Verify exact option and error names against your installed version.
Building the change-email confirmation and 'your email was changed' notice templates.
Canonical ground for re-authentication on sensitive actions and session invalidation on credential change.