HomeGuidesSupabase email change security: 8 gaps, tested

Guide

Supabase email change security: 8 gaps, tested

Supabase updateUser({ email }) needs no password, reveals registered emails, and the last link signs in whoever opens it. What we tested, and the fixes.

22 min read

Independent notes from VibeLocked. Not a pentest, and not affiliated with the tools named below.

You asked Cursor, Lovable, Bolt or Claude Code for a "change email" setting on Supabase. It works: a form calls supabase.auth.updateUser({ email }), a link arrives, the email changes. What the happy path test does not show is who else can start that change, what the old inbox is asked to do, who ends up signed in, and what your RLS policies and profiles table believe in the meantime.

The email address is how an account gets recovered. Whoever controls it can reset the password, so an email change is an account takeover when it goes wrong. This guide walks through eight gaps in a typical AI-written Supabase email change, what we saw when we tested each one against the real Supabase Auth server, and the fixes.

Tested on October 29, 2026. We built the open source Supabase Auth server from source (commit c3348e8; the newest master, fcc491d, only adds a SECURITY.md file) and ran it with its 76 migrations on PostgreSQL 17.10, PostgREST 16.5 for the Data API, a local mail catcher, and @supabase/supabase-js 2.118.0. The fix code also ran in a Next.js 16.4.3 app with @supabase/ssr 0.12.7 and a real browser. We tested with Secure email change on (the default) and off, the email changed notification on, a custom email change template, Secure password change, Confirm email off with anonymous sign-ins, MFA, and the per IP rate limit header turned on. We did not test on a hosted Supabase project, so hosted defaults and dashboard behavior come from Supabase's docs, not from us. VibeLocked is independent and not affiliated with Supabase.

The short version

  1. With Secure email change off, a stolen session takes the account. One link to the attacker's inbox finishes the change, and the owner's reset emails go nowhere.
  2. With it on, the old inbox is asked to approve the attacker's address. The default email looks like a routine "confirm your new email" message.
  3. No password or nonce is needed to start a change. Secure password change does not cover email. Only MFA (aal2) does.
  4. The last confirmation link is a full sign in. Whoever opens it gets a new session. Other sessions stay signed in.
  5. updateUser tells any signed-in user which emails are registered. 422 email_exists, even inside the 60 second window.
  6. Scanners and second clicks use up the links. Same as reset links, and PKCE ties the final link to one browser.
  7. Email in the JWT is not proof of anything. Old tokens keep the old address, user_metadata.email is user-writable, and Confirm email off hands out any address.
  8. A profiles.email column the client can write is a fake identity. Policies that read it trust whatever the user typed.

1. Secure email change off: one click from a stolen session

Supabase's updateUser reference says: "By default, email updates sends a confirmation link to both the user's current and new email." With Secure email change on, "confirmation is also required from the old email before the change is applied." Turning it off means the change applies "after only the new email is verified."

Sessions get stolen: an XSS bug reads the tokens Supabase keeps in the browser, a laptop is shared, a token lands in a log. We gave a second client a copy of the owner's session (the "thief") and had it call updateUser({ email: thief }).

Step Secure email change on Secure email change off
updateUser({ email: thief }) from the stolen session Accepted Accepted
Emails sent One to the owner, one to the thief One to the thief only
Thief clicks the link in the thief's inbox #message=Confirmation link accepted. Please proceed to confirm link sent to the other email. Email unchanged Email changed, and the thief's browser got a session
Owner asks for a password reset at their address Reset email arrives Nothing sent (no account has that address any more)
Thief asks for a reset at the thief's address Nothing sent Reset email arrives
Owner signs in with old email and password Works invalid_credentials

With Secure email change off, the owner is locked out in one click, and the thief can set a new password whenever they like. The owner's own session stayed valid and its next refresh showed the thief's email, which is the only sign they would get unless you turn on the notification below.

Fix: keep Secure email change on (Authentication, then Providers, then Email). And turn on the email changed security notification. Supabase's email templates guide lists it among the security emails that "are only sent to users if the respective security notifications have been enabled at a project-level". In self-hosted Auth it is GOTRUE_MAILER_NOTIFICATIONS_EMAIL_CHANGED_ENABLED, off by default in the configuration source. With it on, the owner's old address got "Your email address was changed ... from owner to thief" right after the change. It does not stop the takeover, but it tells the owner to contact you while you can still fix it.

2. Secure email change on: the old inbox approves the thief

Secure email change only helps if the owner does not click. In the Auth source both addresses get the same "Change email address" template, and the default reads:

Confirm your new email address. Follow the link below to confirm thief@... as your new email address. Confirm new email address. If you didn't request this change, you can safely ignore this email.

That is what landed in the owner's inbox in our test, subject "Confirm your new email address". A busy owner can read that as a routine confirmation. When the thief had already confirmed the new address and the owner then clicked the link in their own inbox, the email changed to the thief's address and the owner's browser was signed in. Either order works: we confirmed old first and new second, and new first and old second, and both completed.

Fix: give the old address a different message. The template gets {{ .SendingTo }} (the address this copy goes to) next to {{ .Email }} (the current address) and {{ .NewEmail }}. SendingTo is in the source but not in the docs' variable list, so test it on your project before relying on it. This template ran on our server and each address got its own text:

{{ if eq .SendingTo .Email }}
<h2>Someone asked to change your account email to {{ .NewEmail }}</h2>
<p>If this was you, approve it: <a href="{{ .SiteURL }}/auth/confirm-email?token_hash={{ .TokenHash }}">Approve the change</a></p>
<p>If this was not you, do not click. Reset your password and sign out of other devices.</p>
{{ else }}
<h2>Confirm your new email address</h2>
<p><a href="{{ .SiteURL }}/auth/confirm-email?token_hash={{ .TokenHash }}">Confirm {{ .NewEmail }}</a></p>
{{ end }}

The subject is one setting for both copies, so keep it neutral ("Email change request for your account"). The link points at your own page, which is fix 6.

3. No password, no nonce: only MFA slows it down

OWASP's Authentication Cheat Sheet says that for users without MFA you should "Request the user's current password for identity verification" before an email change. Supabase Auth does not. In user.go the nonce and current password checks belong to the password branch only.

Session updateUser({ password }) updateUser({ email })
Secure password change on, session aged 25 hours in the database 400 reauthentication_needed Accepted, no nonce
User with a verified TOTP factor, new aal1 password sign in Also needs aal2 (source; we tested email only) 401 insufficient_aal: "AAL2 session is required to update email or password when MFA is enabled."
Same user, aal2 session Accepted

So the settings in Supabase Auth security settings that protect the password do nothing for the email, which is the stronger key. Two things help:

  • MFA. Auth enforces aal2 for email changes once a factor is verified.
  • Ask for a recent sign in yourself. The amr claim in the access token keeps the original sign in time: in our test the timestamp stayed the same after a refresh while iat moved. A Server Action can refuse an email change from a session that signed in more than a few minutes ago and send the user to sign in again, which a thief with a copied token cannot do without the password.
// app/account/email/actions.ts
'use server'
import { createClient } from '@/utils/supabase/server'

const RECENT_SIGN_IN_SECONDS = 10 * 60

export async function changeEmail(_prev: unknown, formData: FormData) {
  const supabase = await createClient()
  const { data } = await supabase.auth.getClaims()
  const claims = data?.claims
  if (!claims) return { error: 'Sign in again to change your email.' }

  // Supabase Auth does not ask for a password or nonce before an email change.
  // amr timestamps keep the original sign in time across refreshes, so use them.
  const signedInAt = Math.max(0, ...(claims.amr ?? []).map((a) => (typeof a === 'string' ? 0 : a.timestamp)))
  if (Date.now() / 1000 - signedInAt > RECENT_SIGN_IN_SECONDS) {
    return { error: 'For your security, sign in again before changing your email.', reauth: true }
  }

  const email = String(formData.get('email') ?? '').trim().toLowerCase()
  const { error } = await supabase.auth.updateUser(
    { email },
    { emailRedirectTo: `${process.env.NEXT_PUBLIC_SITE_URL}/account/email` },
  )
  if (error?.code === 'over_email_send_rate_limit') return { error: 'Wait a minute, then try again.' }
  if (error?.code === 'insufficient_aal') return { error: 'Confirm with your authenticator app first.' }
  if (error) return { error: 'We could not start the change. Check the address and try again.' }
  return {
    message: `We sent a link to ${email} and one to your current address. Your email changes after you confirm both.`,
  }
}

getClaims() verifies the token instead of trusting the cookie; getSession vs getUser vs getClaims explains the difference. If your sign in page uses magic links or OAuth, "sign in again" still works the same way, because the new session gets a fresh amr timestamp.

When the final confirmation goes through, Auth issues a brand new session to whoever opened the link (verify.go calls issueRefreshToken after the change). In our test:

  • The final click redirected with #access_token=...&type=email_change, and the session's amr was otp. The user's session count went from 2 to 3.
  • In the Next.js app, a signed-out browser that clicked the final link landed on /account/email?status=changed signed in as the user.
  • No other session ended. A second device refreshed normally afterwards and its new token carried the new email.

So the inbox that confirms last becomes a way to sign in, just like a reset link (see Supabase password reset security, gap 4). Whoever clicks last gets the session: when the thief confirmed first and the owner second, the owner's browser was signed in; in the other order, the thief's would be. After a confirmed email change, consider signing out other sessions from the server with signOut({ scope: 'others' }) for the user who just confirmed, and send them to a fixed account page rather than a dashboard.

5. updateUser tells any signed-in user which emails are registered

Signup has the same enumeration problem from an unauthenticated form: see Supabase signup user enumeration.

Before it sends anything, Auth checks whether the new address belongs to another account and returns email_exists (user.go). One signed-in account is enough to probe:

Request Result
Change to an address with an account 422 email_exists: "A user with this email address has already been registered"
Same address in upper case Same 422 (Auth lowercases addresses)
Change to an unknown address Accepted, change pending
Second request within 60 s, registered address Still 422 email_exists
Second request within 60 s, unknown address 429 over_email_send_rate_limit: "For security purposes, you can only request this after 59 seconds."
40 more PUT /auth/v1/user from one IP, per IP limit on 30 answered (15 422, 15 429), then 429 over_request_rate_limit

The duplicate check runs before the per user email window, so the 60 second limit does not slow the probe down. The only brake we hit was the per IP limit. Supabase's rate limits page lists /auth/v1/user in the group that "Defaults to 30 requests per 5 minutes" per IP. Self-hosted Auth only applies those limits when a rate limit header is configured (GOTRUE_RATE_LIMIT_HEADER, middleware.go); without it our 40 requests all went through. PUT /auth/v1/user has no CAPTCHA check in the route list.

Hiding the error in your UI, as the action above does, keeps your own page quiet, but the answer comes from Auth to any client holding a session, so it is not a real fix. If account existence is sensitive in your app (health, dating, legal), treat it like the other leaks in the login brute force guide: keep the IP limit, watch for one account calling PUT /user many times, and consider letting users change email only through support.

Two more things we saw. Two accounts could both have the same pending new address, and a third person could still sign up with it; the duplicate check does not look at pending changes. And one email change with Secure email change on sends two emails, while the rate limits page lists the built-in email provider at "2 emails per hour", so set up custom SMTP before you ship this feature.

The default template uses {{ .ConfirmationURL }}, a GET to /auth/v1/verify?token=...&type=email_change. As with resets, the first GET does the work. A repeat of the same link, or a click after a link scanner fetched it, gave #error=access_denied&error_code=otp_expired&error_description=Email+link+is+invalid+or+has+expired. A newer updateUser request also replaces older links, and Supabase's email OTP docs say "The Email OTP Expiration setting also governs the validity of ... email change ... links."

With the PKCE flow (the default in @supabase/ssr) the final link came back as ?code=.... Exchanging it in another browser failed with pkce_code_verifier_not_found; the browser that called updateUser exchanged it fine (amr email_change). Users often open the second email on their phone.

Fix: links to your own page, and verify on a button press. The template in fix 2 sends token_hash links for both addresses. The page shows a button, and the action calls verifyOtp:

// app/auth/confirm-email/page.tsx
import { confirmEmailChange } from './actions'

export const metadata = { referrer: 'no-referrer' }

export default async function ConfirmEmail({
  searchParams,
}: {
  searchParams: Promise<{ token_hash?: string }>
}) {
  const { token_hash } = await searchParams
  if (!token_hash) return <p>This link is incomplete. Start the email change again.</p>
  return (
    <form action={confirmEmailChange}>
      <input type="hidden" name="token_hash" value={token_hash} />
      <button type="submit">Confirm this email change</button>
    </form>
  )
}
// app/auth/confirm-email/actions.ts
'use server'
import { redirect } from 'next/navigation'
import { createClient } from '@/utils/supabase/server'

export async function confirmEmailChange(formData: FormData) {
  const token_hash = String(formData.get('token_hash') ?? '')
  const supabase = await createClient()
  const { data, error } = await supabase.auth.verifyOtp({ token_hash, type: 'email_change' })
  if (error) redirect('/account/email?status=expired')
  // First of two links: no session yet, the other inbox still has to confirm.
  if (!data.session) redirect('/account/email?status=one-more')
  redirect('/account/email?status=changed')
}

We ran this page and action in Next.js against our Auth server. Four plain GETs of the two email links, like a scanner, returned the button page and used nothing. A signed-out browser that pressed the button on the old address's link went to ?status=one-more and stayed signed out. A third browser that pressed it on the new address's link went to ?status=changed, signed in. The same link again went to ?status=expired. verifyOtp with the first token returned no session and no error, which is how the action tells the two steps apart. The 6 digit {{ .Token }} codes also worked with verifyOtp({ email, token, type: 'email_change' }), using each address with its own code; a wrong code returned otp_expired without using up the real one.

7. Email in the JWT is not proof of anything

AI tools like policies such as invited_email = auth.jwt() ->> 'email' for invites and team access. We tested what that claim holds.

Case What the policy saw
Change to the invited address requested, not confirmed The JWT kept the old, confirmed email even after a refresh. Invite rows: []
Change away from an address confirmed, request with a token issued before the change The old token still said the old email and still read the old address's invite, 3,599 seconds left on the token. After a refresh: []
updateUser({ data: { email: invited } }), policy on auth.jwt() -> 'user_metadata' ->> 'email' The user wrote the claim and read the invite
Confirm email off: sign up with someone else's address Session returned at once, JWT email was that address and user_metadata.email_verified was true. Read the invite
Confirm email off, anonymous sign-ins on: anonymous user calls updateUser({ email }) Email set right away with no email sent, is_anonymous became false, read the invite

The pending row is the good news: the claim tracks the confirmed address, not new_email. auth.email() reads the same claim, so it has the same limits. The rest matches the warnings in Supabase's RLS guide: raw_user_meta_data "can be updated by the authenticated user" and "is not a good place to store authorization data", and "a JWT is not always up-to-date". The anonymous case is in the source: user.go skips the confirmation email for anonymous users when email autoconfirm is on, and sends one otherwise (we did not test that branch). A permanent user's change still needed confirmation even with Confirm email off.

Fix: keep Confirm email on (Supabase Auth security settings, section 2). Match invites by a token or by user id where you can. When a policy must match by email, read the current confirmed address from auth.users instead of the token:

create schema if not exists private;

create or replace function private.current_email()
returns text
language sql stable security definer set search_path = ''
as $$
  select email from auth.users
  where id = (select auth.uid()) and email_confirmed_at is not null
$$;

revoke all on function private.current_email() from public, anon;
grant usage on schema private to authenticated;
grant execute on function private.current_email() to authenticated;

create policy "invitee reads by current confirmed email"
  on public.invites
  for select
  to authenticated
  using (invited_email = (select private.current_email()));

With this policy, the token issued before the change got [] as soon as the change was confirmed. Keep the function in a schema the Data API does not expose; SECURITY DEFINER functions anyone can call explains why. With Confirm email off, email_confirmed_at was set for the fake signup and for the anonymous user's address too, so this function cannot save you there. If you also need to cut off sessions, the session check in revoking a removed team member's access works the same way.

8. A profiles.email column the client can write

The usual AI-written profiles table has an email column and an "update own profile" policy. RLS checks the row (auth.uid() = id), not what is written into it. In our test a user inserted their own profiles row with someone else's email (201), and a policy that matched invites by profiles.email returned that person's invite.

Fix: copy the email from auth.users, and only let clients write the columns they own. Supabase's user data guide shows the insert trigger; add one for email changes and lock the column:

create table public.profiles (
  id uuid primary key references auth.users(id) on delete cascade,
  email text,
  display_name text
);
alter table public.profiles enable row level security;

create policy "read own profile" on public.profiles
  for select to authenticated using ((select auth.uid()) = id);
create policy "update own profile" on public.profiles
  for update to authenticated
  using ((select auth.uid()) = id) with check ((select auth.uid()) = id);

-- Clients may only change display_name. Rows are created by the trigger.
revoke insert, update, delete on public.profiles from anon, authenticated;
grant update (display_name) on public.profiles to authenticated;

create or replace function public.handle_new_user()
returns trigger language plpgsql security definer set search_path = ''
as $$
begin
  insert into public.profiles (id, email) values (new.id, new.email);
  return new;
end;
$$;

create trigger on_auth_user_created
  after insert on auth.users
  for each row execute function public.handle_new_user();

create or replace function public.handle_user_email_change()
returns trigger language plpgsql security definer set search_path = ''
as $$
begin
  update public.profiles set email = new.email where id = new.id;
  return new;
end;
$$;

create trigger on_auth_user_email_changed
  after update of email on auth.users
  for each row when (old.email is distinct from new.email)
  execute function public.handle_user_email_change();

revoke execute on function public.handle_new_user() from public, anon, authenticated;
revoke execute on function public.handle_user_email_change() from public, anon, authenticated;

What we saw: the trigger created the row with the signup email. A client PATCH of email got 403 with 42501 permission denied for table profiles, while display_name updated fine. While a change was pending, profiles.email kept the old address (Auth writes the pending one to email_change, not email), and after both links it switched to the new one. The table level revoke matters: revoking only the email column leaves the table grant in place, which is the trap in stop users editing their own role, plan, or credits. A failing trigger blocks signups, as the docs warn, so test it before you deploy.

updateUser(attrs, { emailRedirectTo }) sets where the confirmation lands. With Site URL http://localhost:3000 and Redirect URLs http://localhost:3000/**:

emailRedirectTo redirect_to in the email Final click landed on
http://localhost:3000/account/email-changed Same That page, with a session
https://evil.example/steal http://localhost:3000 Site URL, with a session
http://localhost:3000.evil.example/x http://localhost:3000 Site URL, with a session
none http://localhost:3000 Site URL, with a session

updateUser returned no error in any case; Auth quietly replaced the value. The danger is a wildcard in your allow list that matches a host you do not own, because the final link delivers a session. Supabase Auth redirect URLs and wildcards has the pattern tests.

What we tested and what we did not

Tested (scripts and outputs kept with our notes): updateUser({ email }) with Secure email change on and off, the emails each address got, both click orders, repeat clicks, the session from the final click, other devices after the change, and the email changed notification; a stolen session takeover end to end in both modes, including password resets and sign in afterwards; email_exists for registered, upper case and unknown addresses inside and outside the 60 second window, two users with the same pending address, and the per IP limit with the rate limit header on; Secure password change with a session aged in the database; MFA with a real TOTP factor at aal1 and aal2; 6 digit codes, token_hash and PKCE confirmations; four emailRedirectTo values; JWT email and user_metadata claims in RLS while pending, after a change and with Confirm email off and anonymous sign-ins; the profiles triggers and column grants; the private.current_email() policy; the amr timestamp across a refresh.

We also ran the changeEmail action, the custom template and the confirm page and action in a Next.js 16.4.3 app (next build and next start) with Chromium through Playwright, plus plain GETs as a scanner. All code samples type-check with TypeScript 7.0.2.

Not tested: a hosted Supabase project, the dashboard, Supabase's built-in email service, hosted defaults for security notifications, real inbox scanners or mail clients, CAPTCHA, signOut({ scope: 'others' }) after a change, the "sign in again" branch of changeEmail in the running app (we only checked that amr timestamps survive a refresh), phone changes, SSO and OAuth-only users, and Send Email hooks.

Error codes you will see

Code What it means in an email change
email_exists The new address belongs to another account. Any signed-in user can see this
over_email_send_rate_limit A change was requested for this user within the window (60 s by default)
over_request_rate_limit Per IP limit on PUT /auth/v1/user
insufficient_aal The user has MFA and the session is aal1
otp_expired Link or code already used, replaced by a newer request, past expiry, or wrong
pkce_code_verifier_not_found PKCE link opened in a different browser
reauthentication_needed Password changes only. Email changes did not ask for it

The full list is in Supabase's Auth error codes.

Checklist

  • Secure email change is on
  • Email changed security notification is on
  • The email change template tells the old address it is approving a move to a new address, using {{ .SendingTo }}
  • Both links go to your own page with {{ .TokenHash }}, and verifyOtp runs only on a button press
  • The change email action runs on the server and requires a recent sign in (or MFA)
  • emailRedirectTo is a fixed path on your site, and no Redirect URLs wildcard matches hosts you do not own
  • Custom SMTP is set up (each secure change sends two emails)
  • Confirm email is on
  • No RLS policy trusts user_metadata or a client-written email column
  • Email-based policies read the confirmed address from auth.users, not the JWT
  • profiles.email is filled by triggers on auth.users, and clients cannot insert or update it

Copy-paste prompt for your AI coding tool

Audit the email change flow in this Supabase app. Do not print or ask for any keys. Find every call to updateUser with an email, verifyOtp with type email_change, exchangeCodeForSession, and any email change email template in the repo or supabase/config.toml. Flag: email changes started from browser code or without a recent sign in check; emailRedirectTo values built from the URL or user input; routes that verify on a GET; email templates that send the old address the same "confirm your new email" text; config with secure email change, email confirmation or the email changed notification turned off; RLS policies that compare against auth.jwt() ->> 'email', auth.email(), user_metadata, or an email column clients can write; profiles tables whose email column is set by the client instead of a trigger on auth.users. Propose: a Server Action that checks the amr sign in time before calling updateUser, a template that uses {{ .SendingTo }} and {{ .TokenHash }} links to our own confirm page, a button submit that calls verifyOtp, triggers that copy auth.users.email into profiles with column grants that block client writes, and a security definer helper that returns the confirmed email from auth.users for policies. Show the diff before applying it.

More prompts: security review prompt for AI coding tools.

Check what your app exposes with LeakCheck

LeakCheck scans a live app URL or a public GitHub repo, read only. It looks for exposed keys such as a Supabase service role key in your bundle, which would let anyone change any user's email through the admin API and skip every check in this guide. It does not sign in, change emails, read your email templates or test your Auth settings, so the checks above are still yours to do.

Sources

Questions

How does changing the email work in Supabase?

A signed-in user calls supabase.auth.updateUser({ email }). Nothing changes yet: Auth stores the address as new_email and emails a confirmation link. With Secure email change on (the default), both the current and the new address get a link and the change only happens after both are confirmed. In our test the first link answered 'Confirmation link accepted. Please proceed to confirm link sent to the other email' and the second one applied the change.

Can a stolen Supabase session change the account email and lock the owner out?

With Secure email change off, yes. In our test a copy of the owner's session changed the email to the attacker's address, the attacker clicked the one link in their own inbox, and the change was done. The owner's password reset to the old address sent nothing, the attacker's reset worked, and the owner's old email and password stopped working. With Secure email change on, the old inbox has to confirm too, so the same attack stalled unless the owner clicked their link.

Does Supabase ask for the password or a reauthentication nonce before an email change?

No. In our test, with Secure password change on and a session older than 24 hours, updateUser({ password }) failed with reauthentication_needed, but updateUser({ email }) on the same session started the change with no nonce. The only extra check Auth applies is MFA: if the user has a verified factor, an aal1 session gets insufficient_aal and needs aal2.

Does updateUser({ email }) reveal whether an email is already registered?

Yes, to any signed-in user. Changing to an address that has an account returned 422 email_exists, 'A user with this email address has already been registered', also in upper case. An unknown address started the change. Inside the 60 second window the answers were still different (422 versus 429), and only the per IP limit on PUT /auth/v1/user stopped the loop after 30 requests.

Is auth.jwt() ->> 'email' safe to use in RLS policies?

Not on its own. While a change is pending the claim keeps the old, confirmed address, which is good. But after a change, an access token issued before it still carried the old email and still read rows matched to that email for the rest of its lifetime. With Confirm email off, anyone can sign up as an address they do not own and get a JWT with that email. user_metadata.email is writable by the user. Match on user id, or read the current confirmed email from auth.users in a security definer function.

How do I keep a profiles table email in sync with Supabase Auth?

Copy it from auth.users with triggers, and do not let clients write the column. In our test an AFTER UPDATE OF email trigger on auth.users kept profiles.email on the old address while the change was pending and switched it when both links were confirmed. With insert and update revoked from authenticated and update granted only on display_name, a client PATCH of profiles.email failed with 42501.