Summary
admin.generateLink({ type: 'email_change_new', email, newEmail }) returns a properties.hashed_token value computed from the OLD email, but stores auth.users.email_change_token_new computed from the NEW email. Downstream consumers relying on the response's hashed_token as the verify token will silently fail verifyOtp.
Reproduce
const { data } = await supabaseAdmin.auth.admin.generateLink({
type: 'email_change_new',
email: 'old@example.com',
newEmail: 'new@example.com',
});
// data.properties.hashed_token → hash from params.Email (old)
// auth.users.email_change_token_new → hash from params.NewEmail (new)
// → DIVERGENT
await supabaseAdmin.auth.verifyOtp({
type: 'email_change',
token_hash: data.properties.hashed_token, // wrong; matches OLD-email hash
});
// → token not found
Source
From supabase/auth master:
internal/api/mail.go adminGenerateLink:
hashedToken = crypto.GenerateTokenHash(params.Email, otp) (returned in response)
user.EmailChangeTokenNew = crypto.GenerateTokenHash(params.NewEmail, otp) (stored for the new-arm)
internal/mailer/templatemailer/templatemailer.go GetEmailActionLink:
- For
email_change_new, URL token is sourced from user.EmailChangeTokenNew (stored), not the response field.
So properties.action_link correctly contains the verify token; properties.hashed_token does not.
Why current-arm doesn't hit this
email_change_current uses params.Email on both sides; hashedToken === EmailChangeTokenCurrent. No mismatch.
Suggested fix
One of:
- Response
hashed_token should expose the value matching the stored column for the requested type.
- OR document the divergence: consumers should extract tokens from
properties.action_link (new URL(action_link).searchParams.get('token')) — that's what GetEmailActionLink itself does internally.
Workaround for downstream callers
Until upstream resolves, extract the verify token from properties.action_link:
const token = new URL(arm.properties.action_link).searchParams.get('token');
This matches the stored column for all arms because the mailer builds the link from the stored value directly.
Adjacent issues
Summary
admin.generateLink({ type: 'email_change_new', email, newEmail })returns aproperties.hashed_tokenvalue computed from the OLD email, but storesauth.users.email_change_token_newcomputed from the NEW email. Downstream consumers relying on the response'shashed_tokenas the verify token will silently failverifyOtp.Reproduce
Source
From
supabase/authmaster:internal/api/mail.goadminGenerateLink:hashedToken = crypto.GenerateTokenHash(params.Email, otp)(returned in response)user.EmailChangeTokenNew = crypto.GenerateTokenHash(params.NewEmail, otp)(stored for the new-arm)internal/mailer/templatemailer/templatemailer.goGetEmailActionLink:email_change_new, URL token is sourced fromuser.EmailChangeTokenNew(stored), not the response field.So
properties.action_linkcorrectly contains the verify token;properties.hashed_tokendoes not.Why current-arm doesn't hit this
email_change_currentusesparams.Emailon both sides;hashedToken === EmailChangeTokenCurrent. No mismatch.Suggested fix
One of:
hashed_tokenshould expose the value matching the stored column for the requestedtype.properties.action_link(new URL(action_link).searchParams.get('token')) — that's whatGetEmailActionLinkitself does internally.Workaround for downstream callers
Until upstream resolves, extract the verify token from
properties.action_link:This matches the stored column for all arms because the mailer builds the link from the stored value directly.
Adjacent issues