Skip to content

[BUG] double_confirm_changes silently bypassed when mailer_autoconfirm is true. #2600

Description

@shkuls

Summary

GOTRUE_MAILER_SECURE_EMAIL_CHANGE_ENABLED=true (double_confirm_changes = true) is silently bypassed whenever GOTRUE_MAILER_AUTOCONFIRM=true. The email change commits after only one confirmation link is clicked, making the security guarantee of double-confirm ineffective.

Reported upstream on the supabase/supabase repo:

I was able to reproduce both the above issues on the self-hosted supabase.


Root cause

In internal/api/verify.go, emailChangeVerify gates the entire double-confirm logic behind !config.Mailer.Autoconfirm:

if !config.Mailer.Autoconfirm &&
    config.Mailer.SecureEmailChangeEnabled &&
    user.EmailChangeConfirmStatus == zeroConfirmation &&
    user.GetEmail() != "" {
    // hold after first token — wait for second
    return nil, nil
}
// falls through: commits the email change immediately
user.ConfirmEmailChange(tx, zeroConfirmation)

GOTRUE_MAILER_AUTOCONFIRM controls whether new signups require email confirmation. It is completely unrelated to email change confirmation. But because it is ANDed into the double-confirm guard, setting it to true — which is the default in self-hosted quickstart guides and the Supabase local dev stack — silently disables the two-token flow regardless of what SecureEmailChangeEnabled is set to.


Error flow

Setup: GOTRUE_MAILER_AUTOCONFIRM=true, GOTRUE_MAILER_SECURE_EMAIL_CHANGE_ENABLED=true

  1. User calls PATCH /user with a new email address.
  2. GoTrue generates two tokens and sends two emails (old address + new address). ✓
  3. User clicks the link in the new emailPOST /verify called with type=email_change.
  4. emailChangeVerify is entered. The guard !Autoconfirm && SecureEmailChangeEnabled && ... evaluates to false (because !Autoconfirm = false). The block is skipped entirely.
  5. GoTrue falls through to ConfirmEmailChange() and commits the change immediately. This means that the user's email is updated, all tokens are cleared, email_change_confirm_status reset to 0.
  6. User clicks the link in the old emailPOST /verify called with type=email_change.
  7. isOtpValid(tokenHash, user.EmailChangeTokenCurrent, ...) returns false because EmailChangeTokenCurrent was cleared in step 5.
  8. GoTrue returns otp_expired / Token has expired or is invalid.
  sequenceDiagram
      actor User
      participant Auth
      participant Email

      User->>Auth: Change email to new@example.com
      Auth->>Email: Send confirmation to old@example.com
      Auth->>Email: Send confirmation to new@example.com

      User->>Auth: Click link in new@ email
      Auth->>Auth: autoconfirm=true, skip double-confirm check
      Auth-->>User: ✅ Email changed to new@example.com

      User->>Auth: Click link in old@ email
      Auth-->>User: ❌ Token has expired or is invalid
Loading

Result: The "confirm from your old address" step never executes. The change is fully committed after one click, indistinguishable from SecureEmailChangeEnabled=false. The purpose ofdouble confirm was defeated.


Security implication

With this bug, that protection is absent on any deployment where mailer_autoconfirm=true (self-hosted default), even if the operator explicitly opted in to double_confirm_changes. A feature that is on in config is off in practice, with no warning or error.


Reproduction

Requires: local Supabase stack with GOTRUE_MAILER_AUTOCONFIRM=true and GOTRUE_MAILER_SECURE_EMAIL_CHANGE_ENABLED=true.

// 1. Sign in as user with old@example.com
await supabase.auth.signInWithPassword({ email: 'old@example.com', password: '...' })

// 2. Request email change — two emails are sent
await supabase.auth.updateUser({ email: 'new@example.com' })

// 3. Verify new email (succeeds — change commits immediately)
await supabase.auth.verifyOtp({ token_hash: '<token from new@ email>', type: 'email_change' })
// user.email is now new@example.com ← change already done after one click

// 4. Verify old email (fails — token already cleared)
await supabase.auth.verifyOtp({ token_hash: '<token from old@ email>', type: 'email_change' })
// → "Token has expired or is invalid"

Fix

Remove !config.Mailer.Autoconfirm from the guard in emailChangeVerify. The two flags are orthogonal: autoconfirm applies to signup flows; SecureEmailChangeEnabled applies to email change flows. There is no reason one should override the other.

I have attached a PR fixing the above issue below.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions