You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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.
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 secondreturnnil, nil
}
// falls through: commits the email change immediatelyuser.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.
GoTrue generates two tokens and sends two emails (old address + new address). ✓
User clicks the link in the new email → POST /verify called with type=email_change.
emailChangeVerify is entered. The guard !Autoconfirm && SecureEmailChangeEnabled && ... evaluates to false (because !Autoconfirm = false). The block is skipped entirely.
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.
User clicks the link in the old email → POST /verify called with type=email_change.
isOtpValid(tokenHash, user.EmailChangeTokenCurrent, ...) returns false because EmailChangeTokenCurrent was cleared in step 5.
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.comawaitsupabase.auth.signInWithPassword({email: 'old@example.com',password: '...'})// 2. Request email change — two emails are sentawaitsupabase.auth.updateUser({email: 'new@example.com'})// 3. Verify new email (succeeds — change commits immediately)awaitsupabase.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)awaitsupabase.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.
Summary
GOTRUE_MAILER_SECURE_EMAIL_CHANGE_ENABLED=true(double_confirm_changes = true) is silently bypassed wheneverGOTRUE_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,emailChangeVerifygates the entire double-confirm logic behind!config.Mailer.Autoconfirm:GOTRUE_MAILER_AUTOCONFIRMcontrols 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 totrue— which is the default in self-hosted quickstart guides and the Supabase local dev stack — silently disables the two-token flow regardless of whatSecureEmailChangeEnabledis set to.Error flow
Setup:
GOTRUE_MAILER_AUTOCONFIRM=true,GOTRUE_MAILER_SECURE_EMAIL_CHANGE_ENABLED=truePATCH /userwith a new email address.POST /verifycalled withtype=email_change.emailChangeVerifyis entered. The guard!Autoconfirm && SecureEmailChangeEnabled && ...evaluates tofalse(because!Autoconfirm = false). The block is skipped entirely.ConfirmEmailChange()and commits the change immediately. This means that the user's email is updated, all tokens are cleared,email_change_confirm_statusreset to 0.POST /verifycalled withtype=email_change.isOtpValid(tokenHash, user.EmailChangeTokenCurrent, ...)returnsfalsebecauseEmailChangeTokenCurrentwas cleared in step 5.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 invalidResult: 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 todouble_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=trueandGOTRUE_MAILER_SECURE_EMAIL_CHANGE_ENABLED=true.Fix
Remove
!config.Mailer.Autoconfirmfrom the guard inemailChangeVerify. The two flags are orthogonal: autoconfirm applies to signup flows;SecureEmailChangeEnabledapplies to email change flows. There is no reason one should override the other.I have attached a PR fixing the above issue below.