Skip to content
Chapter 54Lesson 2

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.

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.

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.

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.

Which elevation does this action need?

/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

The whole flow hangs off one call:

app/(app)/settings/security/actions.ts
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.

app/(app)/settings/security/actions.ts
'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.

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: true kills every session. There’s no trusted session to spare.
  • Change-password is authenticated. The user is signed in and actively working, so revokeOtherSessions: true kills 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.

Reset (unauthenticated) revokeSessionsOnPasswordReset: true

After the change

This device the session you are using revoked Phone old session revoked Old laptop old session revoked This device new session just signed in

No current session to trust — everything dies, then the user signs in fresh.

Change from settings (authenticated) revokeOtherSessions: true

After the change

This device the session you are using survives Phone old session revoked Old laptop old session revoked

The session you are using survives; every other key is revoked.

The same starting sessions an instant after the change: reset kills all of them, change-from-settings spares the current one.

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.

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.

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:

lib/auth.ts
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.

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:

app/(app)/settings/security/actions.ts
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.

1 Submit
new email
2 Mint token
the opt-in, not the default 3 Confirm the
CURRENT address
4 User clicks
the link
5 Verify new,
flip user.email
6 Notify both
addresses
The user types the new address in /settings/security. The current email is shown read-only.
1 Submit
new email
2 Mint token
the opt-in, not the default 3 Confirm the
CURRENT address
4 User clicks
the link
5 Verify new,
flip user.email
6 Notify both
addresses
A row appears in the same verification table as email-verification, under a different namespace — change-email:<userId>:<newEmail>.
1 Submit
new email
2 Mint token
the opt-in, not the default 3 Confirm the
CURRENT address
4 User clicks
the link
5 Verify new,
flip user.email
6 Notify both
addresses
With 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.
1 Submit
new email
2 Mint token
the opt-in, not the default 3 Confirm the
CURRENT address
4 User clicks
the link
5 Verify new,
flip user.email
6 Notify both
addresses
Out-of-band. The inbox they already own is the side channel — the same click-the-link motion as email verification.
1 Submit
new email
2 Mint token
the opt-in, not the default 3 Confirm the
CURRENT address
4 User clicks
the link
5 Verify new,
flip user.email
6 Notify both
addresses
user.email flips to newEmail — but only after the current-inbox owner confirmed and the new inbox is verified.
1 Submit
new email
2 Mint token
the opt-in, not the default 3 Confirm the
CURRENT address
4 User clicks
the link
5 Verify new,
flip user.email
6 Notify both
addresses
Your app sends a "your email was changed" notice to both old and new, carrying a "wasn't you?" escape hatch that revokes sessions and forces a reset.

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.

app/(app)/settings/security/reauth.ts
'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.

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:

app/(app)/settings/security/actions.ts
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.

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.

Changing the password requires the current password even on a session that signed in seconds ago.
A valid session, on its own, is enough authority to change the password.
When the session is too old, changing the email re-prompts the user through a real sign-in.
Changing the password leans on freshAge as its primary gate.
Killing the other sessions on a password change is something you have to switch on — it’s off by default.
Changing the email revokes every session by default, just like a reset.

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.