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

    Fields

    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