Skip to content

admin.generateLink(email_change_new): properties.hashed_token diverges from stored email_change_token_new #2538

@benshump

Description

@benshump

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions