Changing the password and the email
Lets a signed-in user rotate their own password and email through Better Auth, re-proving identity at the right tier before each sensitive change.
In Password reset you handed a forgotten password back to someone who, by definition, couldn’t prove who they were. That was the whole problem the flow existed to solve. This lesson is the mirror image. Here the user is signed in, sitting in their account settings, and they want to change the very credentials that signed them in: their password and their email.
That sounds like the easy case. The user is already authenticated, so what’s left to decide? The answer turns out to be the most interesting judgment call in the chapter, and it’s the one beginners get wrong. By the end you’ll have stood up /settings/security, wired changePassword and changeEmail against Better Auth, and re-prompted the user for their identity when their session has gone stale.
Three questions sit underneath that work, and an experienced engineer asks them before touching the wiring:
- An action this sensitive needs more than a valid session. What’s the difference between “you’re signed in” and “prove it’s still you”, and which of those does each change demand?
- A credential just changed. Which sessions die, and which one survives?
- Change-email reuses the verification machinery you built in Verifying email, so why does the confirmation link go to the address the user is leaving?
A few topics this lesson deliberately leaves to their own lessons. It does not cover password reset for forgotten passwords; that’s Password reset, the unauthenticated sibling you just met, which we’ll lean on here as a contrast. It doesn’t cover the elevation rules for two-factor or passkeys (those come with their own lessons), account deletion, or rate-limiting these endpoints. Each gets named where its seam appears.
Signed in is not “still you”
Section titled “Signed in is not “still you””Here’s the trap a beginner falls into. They reach for one gate on every protected action: is there a session? If yes, proceed. That gate is correct for changing a display name or flipping a notification preference. It is dangerously wrong for changing a password.
Picture the threat concretely. You’re signed in on a friend’s laptop, and you step away to grab a coffee. In those ninety seconds your session is wide open, and “is there a session?” returns true for whoever is sitting in the chair. If that single check were the only thing standing between them and your password, they could rotate it, lock you out, and own the account. A valid session proves someone signed in here at some point. It does not prove that you are the person making this particular request right now.
So sensitive actions need a second, 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. The subtle part is that there are two different ways to re-prove, for two different situations, and collapsing them into one is the mistake this section exists to prevent.
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 possible check is to make them re-supply that credential. Changing the password is the clean case: the form always asks for the current password before it will set a new one.
This holds even when the session was created two seconds ago, for two reasons that stack on top of each other. First, defense-in-depth: the borrowed-laptop attacker has the session but not the password, so requiring the current password closes that exact hole. Second, library enforcement: Better Auth’s changePassword requires a currentPassword field, so you couldn’t skip it if you wanted to. Re-proving the actual credential is a stronger guarantee than checking how recently someone signed in, so for password change the current-password field is the load-bearing gate.
Tier two: re-prove with a fresh sign-in
Section titled “Tier two: re-prove with a fresh sign-in”Now the harder case, where the action is high-stakes but there’s no existing credential to re-supply. Changing the email is the example. You’re handing over a sign-in identifier, but there’s no “current email password” to type back. Enabling or disabling two-factor, adding a passkey, and deleting the account have the same shape: nothing to re-prove field by field.
For these, the app falls back to a different signal: freshAge . You set this back in Session lifetimes and cookie hardening, and this is the first lesson that actually spends it. A session is fresh if it was authenticated within freshAge, which is ten minutes in this course. (Better Auth defaults to a full day; the course tightened it precisely so that high-stakes actions re-prompt often.) Freshness is about recency of proof, not whether a session exists at all.
When a freshAge-gated action runs and the session is older than ten minutes, the action comes back with 'requires-re-authentication', and the UI re-prompts the user to sign in again. That real sign-in mints a fresh session, after which the action can fire. We’ll build that branch later in the lesson.
One rule unifies both tiers:
A final wrinkle is worth naming so you know where the boundary sits. When the endpoint belongs to the library, the library owns the gate: changePassword enforces the current password, while changeEmail, two-factor, and passkey endpoints enforce freshAge themselves. But destructive actions you write yourself, such as deleting an account or exporting all of a user’s data, have to reach the same freshAge gate explicitly, by checking freshness in your own action. We won’t build that app-owned side here. Just know that the gate is the same, and the only difference is whether the library or your code is the one consulting it.
That’s three situations, not one. Walk the decision the way an experienced engineer does, committing to a branch at each step before reading the leaf.
The form requires the current password, and Better Auth’s changePassword enforces it, so you couldn’t skip it if you wanted to.
freshAge is not consulted here: re-proving the actual credential is a stronger guarantee than checking how recently someone signed in.
The current-password field 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, which is ten minutes in this course.
Library-owned endpoints (change-email, 2FA, passkeys) check it automatically, while app-owned destructive actions call the freshAge helper themselves.
A stale session comes back with '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 beginner’s “is there a session?” reflex actually fits.
The /settings/security page
Section titled “The /settings/security page”Before the per-form mechanics, get oriented in the surface. /settings/security is a protected route: the proxy you built in the previous lesson already bounced signed-out users away from anything under /settings, and the layout’s requireUser() from Reading the session everywhere with one call shape is the validating read that confirms there’s a real session behind the cookie.
Keep one distinction sharp, because it’s the seam where the borrowed-laptop attack lands: that gate proves the user is signed in. It does not prove the user is still you. Elevation is layered on top of the page’s protection, per action. If you ever put the credential-change forms (or the active-sessions list from the next lesson) behind a surface that’s merely protected and never elevates, you’ve handed the borrowed laptop a way to mutate credentials.
The page is three independent forms, and the word that matters is independent: three separate <form> elements, three Server Actions, three useActionState hooks. One form’s error never touches another’s. The forms are the same shape you’ve written since useActionState — pending state and the result, namely uncontrolled inputs with defaultValue and a <SubmitButton> reading pending from useFormStatus, so we won’t re-teach the wiring, only the actions behind it.
- 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 not forms, just navigation to the surfaces that own those flows.
Each form switches on its own Result, and across the whole surface those results draw from a small catalog worth naming once: 'ok', 'invalid-credentials' (the current password was wrong), 'email-taken', 'requires-re-authentication', and 'no-password-set' (the OAuth-only edge we’ll get to last). Two of those, 'invalid-credentials' and 'email-taken', have to read as the same shape of failure the user already learned to expect at sign-in. Pointed, certain copy like “that email is already in use” hands an attacker an oracle for which addresses have accounts, so the enumeration discipline you’ve carried since Password sign-up applies here unchanged.
Here’s the shape of the surface on disk, small and co-located.
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”Start with the call, because everything else hangs off it.
await auth.api.changePassword({ body: { currentPassword, newPassword, revokeOtherSessions: true, }, headers: await headers(),});Walk what the library does behind that one call, because you wrote none of it and you shouldn’t have to. It takes currentPassword and compares it in constant time against the scrypt hash stored on the user’s 'credential' account row, exactly the posture from Password sign-up. If that 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. You never see a plaintext password leave your action, and you never touch a hashing function.
The one line that turns this from a toy into a real rotation is revokeOtherSessions: true. It matters because a password change means the old password may be compromised, which is often the reason someone changes it. So every session that was minted under the old credential has to die. The library defaults this knob to false, so flip it to true at the call site, every time. This is the authenticated cousin of revokeSessionsOnPasswordReset from Password reset: the same principle, with one deliberate difference we’ll get to in a moment.
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. The tier and the code line up: re-proving the credential is the gate, and the credential is right there in the request body.
Here’s the action in full. It’s the same five-seam skeleton (parse, authorize, mutate, revalidate, return) that you’ve now written four times, so it ships as a plain code block rather than a stepped walkthrough. 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', and it returns the same 'invalid-credentials' no matter why the compare failed. Don’t let the copy distinguish “wrong password” from anything else. That’s the same enumeration discipline from Password sign-in, now applied to a new surface: a precise error message is a precise hint to an attacker.
Which sessions die, and why the contrast matters
Section titled “Which sessions die, and why the contrast matters”This section is short, but it’s the part that turns two flows into one principle.
Both reset and change-password rest on the same 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. Nothing is spared, because 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. Signing the user out of the tab they’re standing in would be hostile and unnecessary, since that session just re-proved the credential.
The principle is the same in both cases, but the disposition is opposite, and the reason is exactly whether the current session can be trusted. The following two panels show the same account the instant after the change, with the same starting sessions, 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’s conventions make 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 sessions just quietly live on.
Changing the email
Section titled “Changing the email”Change-email reuses every primitive from Verifying email: the verification table, the one-time hashed token, and the constant-time lookup. So the mechanics aren’t the lesson. The lesson is one decision, and it’s the most counter-intuitive call in the chapter: the confirmation link goes to the address the user is leaving, not the one they’re moving to. Lead with that, because it’s also the place where the library’s default is the less secure option, and only a deliberate choice fixes it.
The config, and what the default actually does
Section titled “The config, and what the default actually does”changeEmail is disabled by default, and you enable it on the user block of your auth config. The moment you flip it on, you get a flow, and it’s worth knowing exactly what that default flow does, because it’s not 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 }), }); }, }, },});By default, 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 from the new inbox. That proves the user controls the new mailbox. It proves nothing about whether they control the existing account.
The opt-in sendChangeEmailConfirmation callback is what closes the gap. Look at its to field in the snippet above: user.email, the current address. With this wired, 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 you wrote in Verifying email.
Why confirm the old address
Section titled “Why confirm the old address”Two threat models, side by side, make the call obvious.
Verifying the new address answers one question: does this person control the inbox they’re moving to? That’s useful, but it’s the wrong question to lead with, and it’s all the default does.
Confirming the old address answers a different question: does this person control the account right now? That’s the question that matters, because it 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. A student who wires only enabled: true ships a change-email that a stolen session can drive. The gold standard, if you want the full posture, is dual verification, where the old address confirms the request and the new address confirms ownership. For most SaaS, confirming the current address plus a clear notification is enough.
The call, the notices, and the row you already own
Section titled “The call, the notices, and the row you already own”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’s send is the verification token and nothing else. The notifications that make this safe in production are your code, sent through the same sendEmail pipeline you already 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 last notice isn’t a nicety. A silent email change is a takeover-detection failure: if the real owner gets no warning, they have no chance to react. Notify both addresses, always.
The token itself is something you already own in full. It’s the same verification table from Verifying email, with a different identifier namespace: change-email:<userId>:<newEmail> instead of the email-verification namespace. It’s one-time use by deletion, with the library’s default expiry, hashed at rest, and looked up in constant time. The only differences are the namespace and the entry point, so there’s nothing new to learn about tokens here. That’s exactly why that lesson pointed forward to this one as “same primitive, different door.”
One more decision is a product call rather than a fixed default. Change-email doesn’t carry “credential compromise” semantics the way password change does, because the password still works, so the library does not revoke sessions on an email change. The defensible move is to revoke anyway if your product treats the email as a sign-in identifier, which it does. At minimum, the “wasn’t you?” link gives the real owner a revoke-all-and-reset escape hatch. Decide it against your threat model, not on autopilot.
Walk the whole flow once, end to end. Scrub through it and watch what becomes true at each stage; the third stage 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”We’ve deferred one branch twice now. This is where freshAge, the second tier from the top of the lesson, finally becomes code.
A high-stakes action runs behind a freshness check. Change-email is the in-lesson example; two-factor, passkeys, and account deletion are the same shape at their own seams. When the session is older than ten minutes, the action returns 'requires-re-authentication'. The form switches on that discriminant and 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.
Model 'requires-re-authentication' carefully: it is not an error. It’s a continuation, the same kind of signal as 'requires-second-factor' from Password sign-in, which didn’t mean “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 knows the session is stale is version-dependent: either the library’s own freshness gate threw and your mapXError translated it, or your action read the session’s freshness directly. Either way the outward signal is the same 'requires-re-authentication'.)
Now the rule you can’t bend, and the reason this section earns a side-by-side comparison. The re-prompt must call signIn.email, the real sign-in endpoint. Do not hand-roll a re-auth modal that verifies the password against some custom endpoint. The real sign-in carries protections you get for free by routing through it: the rate limiting that comes later in the course, and the session-rotation and fixation defenses baked into the endpoint. A custom password-check throws all of that away.
What is session fixation ? It’s an attack where a victim is tricked into using a session identifier the attacker already knows. Rotating the session on every real sign-in defeats it, because the session that exists after sign-in is brand new. Re-rolling your own auth check skips that rotation. The whole point of routing through signIn.email is to inherit these defenses rather than reimplement them and forget one.
Note how the two tiers now show up as two distinct code paths. Change-password re-proves through the currentPassword field, because a credential exists to re-supply. The freshAge re-prompt re-proves through a fresh sign-in, because there’s no per-action credential. The goal is the same, to re-prove identity, but the mechanism differs. The distinction from the top of the lesson is now concrete in code.
Here’s the wrong way and the right way, side by side. The contrast is the lesson.
'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, then proceeds, inheriting none of the real endpoint’s protections: no rate limit, no session rotation, no fixation defense. Every one of those is now a failure mode you have to 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”One edge case is left to close. It’s a single check rather than a flow, so we’ll keep it tight.
A user who signed up with Google back in Social sign-in with OAuth has no 'credential' account row. There is no password, because they never set one. So when they open the change-password form, changePassword has no currentPassword to verify, and it 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 a result the form can turn into something the user 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 string 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.');};Two things are worth keeping straight about setPassword itself. It is a different call from changePassword: it adds the missing 'credential' row rather than rotating an existing one, so don’t conflate them. 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. It runs inside a Server Action, never as a client authClient call.
You saw the mirror of this in Social sign-in with OAuth: there, an OAuth-only user who mistyped an email-and-password at sign-in got a friendly “you sign in with Google” branch instead of a bare failure. It’s the same account state on two surfaces. Naming both cements the point: an account can simply lack a password, and a polished app handles that gracefully wherever it surfaces.
Wrapping up
Section titled “Wrapping up”You can now change the credentials that sign a user in. The hard part was never the wiring. It was the judgment layered on top: re-proving identity at the right tier, and revoking the sessions a credential change should revoke.
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.Finally, 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.
Every change now re-proves identity at the right tier and revokes the sessions it should. 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.