Olympus Docs
CookbookAuth flows

Secure email change flow

User changes their email, without losing access

A user wants to change their email from old@example.com to new@example.com. Sounds simple but several traps.

Threat model

  • Attacker takes over account, changes email to theirs, prevents user from recovering.
  • User makes a typo in new email, locks themselves out.
  • Old email is still bound to account credentials (passkey, OIDC subject), must update.

Safe pattern

1. User requests email change in settings.
2. Confirmation email sent to OLD email ("If this wasn't you, click here").
3. Verification email sent to NEW email ("Click to confirm").
4. Both must occur; new email becomes primary only after.

Implementation in Kratos

selfservice:
  flows:
    settings:
      ui_url: http://hera:3000/settings
      privileged_session_max_age: 15m

privileged_session_max_age: 15 min after last login, user can change email. Longer requires re-auth.

The settings flow has email-change built in. UI in Hera renders the form.

What Kratos does

When email is changed:

  1. Adds new email to verifiable_addresses with verified: false.
  2. Keeps old email as primary until new is verified.
  3. Sends verification email to new.
  4. Once verified, makes new email primary.

The OLD email is NOT notified by default, you need to add a courier hook.

Notify old email

Add a settings hook:

selfservice:
  flows:
    settings:
      after:
        profile:
          hooks:
            - hook: web_hook
              config:
                url: http://your-backend/internal/notify-old-email
                response: { ignore: true }
export async function POST(req: Request) {
  const { identity, before } = await req.json();
  if (before.traits.email !== identity.traits.email) {
    // Email was changed
    await sendEmail(before.traits.email, "Email change requested", `
      Your [Your App] account email is being changed to ${identity.traits.email}.
      
      If this wasn't you, contact support immediately.
    `);
  }
  return Response.json({ ok: true });
}

Reversal window

For 7 days, allow reversal via a link in the old-email notification:

"Was this you? If not, click here to undo:
  https://your-app.com/revert-email-change?token=..."
CREATE TABLE email_changes (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  identity_id UUID NOT NULL,
  old_email TEXT NOT NULL,
  new_email TEXT NOT NULL,
  changed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  reverted_at TIMESTAMPTZ
);

Reversal:

async function revertEmailChange(token: string) {
  const change = await db`SELECT * FROM email_changes WHERE token = ${hash(token)} AND reverted_at IS NULL`.first();
  if (!change || change.changed_at < NOW() - INTERVAL '7 days') return error();
  
  await kratos.adminPatch(change.identity_id, [
    { op: "replace", path: "/traits/email", value: change.old_email }
  ]);
  await db`UPDATE email_changes SET reverted_at = NOW() WHERE id = ${change.id}`;
  
  // Notify both
  await sendEmail(change.old_email, "Email change reverted", "...");
  await sendEmail(change.new_email, "Your email change to this address was reverted", "...");
}

Privileged session

Email change is sensitive. Require recent auth:

session:
  privileged:
    max_age: 5m  # need to have logged in within last 5 min

If session is older, force re-auth (re-type password + MFA).

Bond to OIDC

If user signs in via Google, "email" is auto-populated from Google. Can they change it?

Two options:

Option A: yes, override

User changes email in settings. Olympus's email is independent from Google's.

Next Google sign-in: Olympus's email stays as the changed one. Google's email is silently ignored.

Option B: no

User can't change email if it came from OIDC. They must change it at Google, then Olympus auto-syncs.

Configure via Kratos schema:

"email": {
  "ory.sh/kratos": {
    "credentials": {
      "password": { "identifier": true },
      "webauthn": { "identifier": true },
      "totp": { "account_name": true }
    },
    "verification": { "via": "email" },
    "recovery": { "via": "email" }
  },
  "readOnly": true   // can't be changed via settings
}

Trade-off: less flexible.

Email confusion edge cases

Changing to an email already on system

User changes to existing@example.com, but that email is on another identity. Conflict.

Kratos rejects with 409 conflict. UI shows: "This email is already in use."

Changing to invalid email

Schema validation: must be a valid email format. Reject before any state change.

Changing case

Alice@example.comalice@example.com. Same effective email but different string. Normalize at input.

Re-verification

After successful email change, treat new email as needing verification (Kratos does this by default). Until verified:

  • Can't use it for recovery.
  • Notifications still go to old email until verified? Or pause notifications?

Practical: send notifications to new immediately (user is using it). Recovery uses verified addresses only.

Audit

Always log:

INSERT INTO security_audit (event, identity_id, metadata)
VALUES ('email_changed', $id, '{"old": "alice@old.com", "new": "alice@new.com"}');

Show in user's activity log.

On this page