Email Change
This guide covers email change for end-users of your app — users registered through your Auth App. To change the email on your own Ghayma dashboard account, see Account → Email Change.
End-users can change the email on their account through your app. The flow is double-confirmed: the user supplies their current password to request the change, then clicks a link sent to the new address to apply it. This prevents silent account takeover from a compromised session.
End-user flow
- The user submits their new email + current password through your app’s UI. Your app calls
auth.changeEmail(...). - The platform sends two emails:
- New address — “Confirm your new email” with a one-time link
- Old address — heads-up notice that a change was requested (no action needed unless the user didn’t initiate it)
- The user keeps using your app signed in with the current email. Render a banner like “Pending change to
[email protected]— [Cancel]” so they know the request is in flight. - The user clicks the link in their new inbox. They land on a page hosted by Ghayma Auth at
https://auth.ghayma.tech/v1/{app-slug}/email/change-confirm?token=..., which applies the change atomically and shows a success page. - On confirm, every session of the user ends (what that means). They are signed out everywhere and must sign in again — this time with the new email.
- A final notice is sent to the old address confirming the swap took effect.
The confirmation page is browser-direct HTML, mirroring /verify-email. There is no auth.confirmEmailChange(...) method in the SDK — the link is the credential.
Integrating in your app
import { GhaymaAuth, AuthError } from "@ghayma/sdk/client";
const auth = new GhaymaAuth({ appSlug: "my-auth-app", storage: "localStorage" });
async function requestEmailChange(newEmail: string, currentPassword: string) {
try {
await auth.changeEmail({
new_email: newEmail,
current_password: currentPassword,
});
// Success: the user will receive a confirmation link at `newEmail`.
// Surface a "Check your inbox" message and keep them signed in.
return { ok: true };
} catch (err) {
if (err instanceof AuthError) {
// 401 — wrong password
// 400 — OAuth account, same email, invalid format, or generic
// "could not start email change" (target already taken)
// 429 — rate limit (3 requests per hour per user+app)
return { ok: false, status: err.status, message: err.message };
}
throw err;
}
}
async function cancelEmailChange() {
// Idempotent — safe to call even if no request is pending.
await auth.cancelEmailChange();
}Showing pending state
Track the pending email in your app’s state after a successful request and clear it when the user next signs in with the new email:
// React example
const [pendingEmail, setPendingEmail] = useState<string | null>(null);
async function handleSubmit(newEmail: string, password: string) {
const result = await requestEmailChange(newEmail, password);
if (result.ok) setPendingEmail(newEmail);
}
async function handleCancel() {
await cancelEmailChange();
setPendingEmail(null);
}
// Render:
// {pendingEmail && (
// <Banner>
// Pending change to {pendingEmail} — <button onClick={handleCancel}>Cancel</button>
// </Banner>
// )}Limitations
- OAuth users cannot change email through your app. Users who signed in with Google or GitHub have their email pinned to the upstream provider. The API returns
400 — this account uses {provider} sign-in; change your email there first. Surface a message pointing them at the provider’s account settings. - One pending change at a time. A new request cancels any prior unconfirmed one. The user only ever has one live confirmation token.
- Cancelled when sessions end. A password reset or change cancels a pending change, and so does anything else that ends every session — a Google or GitHub sign-in that takes over the account, a stolen refresh token, or the user being disabled.
- 1-hour token expiry. Links expire after 1 hour. The user has to start over if they miss the window.
- Rate limits. Maximum 3 requests per hour per user+app on
/email/change-request. The public confirm endpoint is rate-limited at 5 attempts per 5 minutes per IP. - Enumeration mask. If the new address is already registered to another account, the API returns the generic
could not start email changerather than revealing whether the target is taken. Your UI cannot distinguish this from other 400 errors — surface a generic “couldn’t start the change, please try a different address” instead of guessing.
No user.email_changed webhook
There is no webhook for email change. If your backend caches the user’s email (e.g., in your own user table, in a CRM, or in a session cache), it will silently go stale when the user completes a change.
Workarounds:
- Trust the JWT. The access token carries the current
emailclaim. If your backend uses JWT verification (see Auth Integration → JWT Verification), the email is current per request to within the token’s lifetime (15 minutes by default) — no caching needed. - Re-read on next sign-in. When the user signs in again (with the new email), refresh any cached
emailfield from the new JWT orGET /me. The user is forced to sign in after a confirmed change, so this catches every case. - Don’t cache at all. Read
emailfrom the JWT on each request rather than your own database.
This is the most likely source of post-rollout bugs. Audit any code path that reads a user’s email from a long-lived store.
See also
- Client SDK → Change Email — method reference
- Auth Integration → Email Change — HTTP-level endpoints
- Account → Email Change — the parallel flow for Ghayma dashboard users