Repository: supabase/gotrue
Pull request #1079 (2023-04-14 08:09:09)

fix: make migration idempotent
## What kind of change does this PR introduce?
* Migration to remove duplicate index wasn't idempotent


Patch:
diff --git a/README.md b/README.md
index c6e711a9..63432aa6 100644
--- a/README.md
+++ b/README.md
@@ -707,7 +707,7 @@ Or Messagebird credentials, which can be obtained in the [Dashboard](https://das
 
 ### CAPTCHA
 
-- If enabled, CAPTCHA will check the request body for the `hcaptcha_token` field and make a verification request to the CAPTCHA provider.
+- If enabled, CAPTCHA will check the request body for the `captcha_token` field and make a verification request to the CAPTCHA provider.
 
 `SECURITY_CAPTCHA_ENABLED` - `string`
 
@@ -715,12 +715,12 @@ Whether captcha middleware is enabled
 
 `SECURITY_CAPTCHA_PROVIDER` - `string`
 
-for now the only option supported is: `hcaptcha`
+for now the only options supported are: `hcaptcha` and `turnstile`
 
 - `SECURITY_CAPTCHA_SECRET` - `string`
 - `SECURITY_CAPTCHA_TIMEOUT` - `string`
 
-Retrieve from hcaptcha account
+Retrieve from hcaptcha or turnstile account
 
 ### Reauthentication
 
diff --git a/cmd/admin_cmd.go b/cmd/admin_cmd.go
index 5537ced7..8f4e80ee 100644
--- a/cmd/admin_cmd.go
+++ b/cmd/admin_cmd.go
@@ -66,7 +66,7 @@ func adminCreateUser(config *conf.GlobalConfiguration, args []string) {
 	defer db.Close()
 
 	aud := getAudience(config)
-	if user, err := models.IsDuplicatedEmail(db, args[0], aud); user != nil {
+	if user, err := models.IsDuplicatedEmail(db, args[0], aud, nil); user != nil {
 		logrus.Fatalf("Error creating new user: user already exists")
 	} else if err != nil {
 		logrus.Fatalf("Error checking user email: %+v", err)
diff --git a/internal/api/admin.go b/internal/api/admin.go
index 38f5ddbd..81ac549e 100644
--- a/internal/api/admin.go
+++ b/internal/api/admin.go
@@ -320,7 +320,7 @@ func (a *API) adminUserCreate(w http.ResponseWriter, r *http.Request) error {
 		if err != nil {
 			return err
 		}
-		if user, err := models.IsDuplicatedEmail(db, params.Email, aud); err != nil {
+		if user, err := models.IsDuplicatedEmail(db, params.Email, aud, nil); err != nil {
 			return internalServerError("Database error checking email").WithInternalError(err)
 		} else if user != nil {
 			return unprocessableEntityError(DuplicateEmailMsg)
diff --git a/internal/api/external.go b/internal/api/external.go
index 1700c609..4351d994 100644
--- a/internal/api/external.go
+++ b/internal/api/external.go
@@ -73,9 +73,9 @@ func (a *API) ExternalProviderRedirect(w http.ResponseWriter, r *http.Request) e
 	flowType := getFlowFromChallenge(codeChallenge)
 
 	flowStateID := ""
-	var codeChallengeMethodType models.CodeChallengeMethod
 	if flowType == models.PKCEFlow {
-		if codeChallengeMethodType, err = models.ParseCodeChallengeMethod(codeChallengeMethod); err != nil {
+		codeChallengeMethodType, err := models.ParseCodeChallengeMethod(codeChallengeMethod)
+		if err != nil {
 			return err
 		}
 		flowState, err := models.NewFlowState(providerType, codeChallenge, codeChallengeMethodType, models.OAuth)
@@ -225,9 +225,10 @@ func (a *API) internalExternalProviderCallback(w http.ResponseWriter, r *http.Re
 	if flowState != nil {
 		// This means that the callback is using PKCE
 		// Set the flowState.AuthCode to the query param here
-		q := url.Values{}
-		q.Set("code", flowState.AuthCode)
-		rurl += "?" + q.Encode()
+		rurl, err = a.prepPKCERedirectURL(rurl, flowState.AuthCode)
+		if err != nil {
+			return err
+		}
 	} else if token != nil {
 		q := url.Values{}
 		q.Set("provider_token", providerAccessToken)
diff --git a/internal/api/mail.go b/internal/api/mail.go
index f4fa1b24..73f5b834 100644
--- a/internal/api/mail.go
+++ b/internal/api/mail.go
@@ -149,7 +149,7 @@ func (a *API) GenerateLink(w http.ResponseWriter, r *http.Request) error {
 					return unprocessableEntityError("Signup requires a valid password")
 				}
 				if len(params.Password) < config.PasswordMinLength {
-					return unprocessableEntityError(fmt.Sprintf("Password should be at least %d characters", config.PasswordMinLength))
+					return invalidPasswordLengthError(config.PasswordMinLength)
 				}
 				signupParams := &SignupParams{
 					Email:    params.Email,
@@ -182,7 +182,7 @@ func (a *API) GenerateLink(w http.ResponseWriter, r *http.Request) error {
 			if terr != nil {
 				return unprocessableEntityError("The new email address provided is invalid")
 			}
-			if duplicateUser, terr := models.IsDuplicatedEmail(tx, params.NewEmail, user.Aud); terr != nil {
+			if duplicateUser, terr := models.IsDuplicatedEmail(tx, params.NewEmail, user.Aud, user); terr != nil {
 				return internalServerError("Database error checking email").WithInternalError(terr)
 			} else if duplicateUser != nil {
 				return unprocessableEntityError(DuplicateEmailMsg)
@@ -338,14 +338,18 @@ func (a *API) sendMagicLink(tx *storage.Connection, u *models.User, mailer maile
 }
 
 // sendEmailChange sends out an email change token to the new email.
-func (a *API) sendEmailChange(tx *storage.Connection, config *conf.GlobalConfiguration, u *models.User, mailer mailer.Mailer, email string, referrerURL string, otpLength int) error {
+func (a *API) sendEmailChange(tx *storage.Connection, config *conf.GlobalConfiguration, u *models.User, mailer mailer.Mailer, email string, referrerURL string, otpLength int, flowType models.FlowType) error {
 	var err error
+	if u.EmailChangeSentAt != nil && !u.EmailChangeSentAt.Add(config.SMTP.MaxFrequency).Before(time.Now()) {
+		return MaxFrequencyLimitError
+	}
 	otpNew, err := crypto.GenerateOtp(otpLength)
 	if err != nil {
 		return err
 	}
 	u.EmailChange = email
-	u.EmailChangeTokenNew = fmt.Sprintf("%x", sha256.Sum224([]byte(u.EmailChange+otpNew)))
+	token := fmt.Sprintf("%x", sha256.Sum224([]byte(u.EmailChange+otpNew)))
+	u.EmailChangeTokenNew = addFlowPrefixToToken(token, flowType)
 
 	otpCurrent := ""
 	if config.Mailer.SecureEmailChangeEnabled && u.GetEmail() != "" {
@@ -353,7 +357,8 @@ func (a *API) sendEmailChange(tx *storage.Connection, config *conf.GlobalConfigu
 		if err != nil {
 			return err
 		}
-		u.EmailChangeTokenCurrent = fmt.Sprintf("%x", sha256.Sum224([]byte(u.GetEmail()+otpCurrent)))
+		currentToken := fmt.Sprintf("%x", sha256.Sum224([]byte(u.GetEmail()+otpCurrent)))
+		u.EmailChangeTokenCurrent = addFlowPrefixToToken(currentToken, flowType)
 		if err != nil {
 			return err
 		}
diff --git a/internal/api/mfa.go b/internal/api/mfa.go
index 1605bcf8..dc555aee 100644
--- a/internal/api/mfa.go
+++ b/internal/api/mfa.go
@@ -4,7 +4,6 @@ import (
 	"bytes"
 	"encoding/json"
 	"net/http"
-	"time"
 
 	"net/url"
 
@@ -78,8 +77,7 @@ func (a *API) EnrollFactor(w http.ResponseWriter, r *http.Request) error {
 		return unprocessableEntityError("MFA enrollment only supported for non-SSO users at this time")
 	}
 
-	factorType := params.FactorType
-	if factorType != models.TOTP {
+	if params.FactorType != models.TOTP {
 		return badRequestError("factor_type needs to be totp")
 	}
 
@@ -105,7 +103,7 @@ func (a *API) EnrollFactor(w http.ResponseWriter, r *http.Request) error {
 
 	numVerifiedFactors := 0
 	for _, factor := range factors {
-		if factor.Status == models.FactorStateVerified.String() {
+		if factor.IsVerified() {
 			numVerifiedFactors += 1
 		}
 	}
@@ -190,11 +188,9 @@ func (a *API) ChallengeFactor(w http.ResponseWriter, r *http.Request) error {
 		return err
 	}
 
-	creationTime := challenge.CreatedAt
-	expiryTime := creationTime.Add(time.Second * time.Duration(config.MFA.ChallengeExpiryDuration))
 	return sendJSON(w, http.StatusOK, &ChallengeFactorResponse{
 		ID:        challenge.ID,
-		ExpiresAt: expiryTime.Unix(),
+		ExpiresAt: challenge.GetExpiryTime(config.MFA.ChallengeExpiryDuration).Unix(),
 	})
 }
 
@@ -217,7 +213,7 @@ func (a *API) VerifyFactor(w http.ResponseWriter, r *http.Request) error {
 		return badRequestError("invalid body: unable to parse JSON").WithInternalError(err)
 	}
 
-	if factor.UserID != user.ID {
+	if !factor.IsOwnedBy(user) {
 		return internalServerError(InvalidFactorOwnerErrorMessage)
 	}
 
@@ -233,8 +229,7 @@ func (a *API) VerifyFactor(w http.ResponseWriter, r *http.Request) error {
 		return badRequestError("Challenge and verify IP addresses mismatch")
 	}
 
-	hasExpired := time.Now().After(challenge.CreatedAt.Add(time.Second * time.Duration(config.MFA.ChallengeExpiryDuration)))
-	if hasExpired {
+	if challenge.HasExpired(config.MFA.ChallengeExpiryDuration) {
 		err := a.db.Transaction(func(tx *storage.Connection) error {
 			if terr := tx.Destroy(challenge); terr != nil {
 				return internalServerError("Database error deleting challenge").WithInternalError(terr)
@@ -264,7 +259,7 @@ func (a *API) VerifyFactor(w http.ResponseWriter, r *http.Request) error {
 		if terr = challenge.Verify(tx); terr != nil {
 			return terr
 		}
-		if factor.Status != models.FactorStateVerified.String() {
+		if !factor.IsVerified() {
 			if terr = factor.UpdateStatus(tx, models.FactorStateVerified); terr != nil {
 				return terr
 			}
@@ -306,10 +301,10 @@ func (a *API) UnenrollFactor(w http.ResponseWriter, r *http.Request) error {
 	factor := getFactor(ctx)
 	session := getSession(ctx)
 
-	if factor.Status == models.FactorStateVerified.String() && session.GetAAL() != models.AAL2.String() {
+	if factor.IsVerified() && !session.IsAAL2() {
 		return badRequestError("AAL2 required to unenroll verified factor")
 	}
-	if factor.UserID != user.ID {
+	if !factor.IsOwnedBy(user) {
 		return internalServerError(InvalidFactorOwnerErrorMessage)
 	}
 
diff --git a/internal/api/middleware.go b/internal/api/middleware.go
index 9f9d5c5e..8300a19f 100644
--- a/internal/api/middleware.go
+++ b/internal/api/middleware.go
@@ -158,13 +158,13 @@ func (a *API) verifyCaptcha(w http.ResponseWriter, req *http.Request) (context.C
 		return ctx, nil
 	}
 
-	verificationResult, err := security.VerifyRequest(req, strings.TrimSpace(config.Security.Captcha.Secret))
+	verificationResult, err := security.VerifyRequest(req, strings.TrimSpace(config.Security.Captcha.Secret), config.Security.Captcha.Provider)
 	if err != nil {
-		return nil, internalServerError("hCaptcha verification process failed").WithInternalError(err)
+		return nil, internalServerError("captcha verification process failed").WithInternalError(err)
 	}
 
 	if !verificationResult.Success {
-		return nil, badRequestError("hCaptcha protection: request disallowed (%s)", strings.Join(verificationResult.ErrorCodes, ", "))
+		return nil, badRequestError("captcha protection: request disallowed (%s)", strings.Join(verificationResult.ErrorCodes, ", "))
 
 	}
 
diff --git a/internal/api/pkce.go b/internal/api/pkce.go
index f06982d3..8564b969 100644
--- a/internal/api/pkce.go
+++ b/internal/api/pkce.go
@@ -1,10 +1,11 @@
 package api
 
 import (
-	"github.com/supabase/gotrue/internal/models"
-	"github.com/supabase/gotrue/internal/storage"
 	"regexp"
 	"time"
+
+	"github.com/supabase/gotrue/internal/models"
+	"github.com/supabase/gotrue/internal/storage"
 )
 
 const (
@@ -39,11 +40,12 @@ func addFlowPrefixToToken(token string, flowType models.FlowType) string {
 	return token
 }
 
-func issueAuthCode(tx *storage.Connection, user *models.User, expiryDuration time.Duration) (string, error) {
-	flowState, err := models.FindFlowStateByUserID(tx, user.ID.String())
-	if models.IsNotFoundError(err) {
-		return "", badRequestError("No valid flow state found for user.")
-	} else if err != nil {
+func issueAuthCode(tx *storage.Connection, user *models.User, expiryDuration time.Duration, authenticationMethod models.AuthenticationMethod) (string, error) {
+	flowState, err := models.FindFlowStateByUserID(tx, user.ID.String(), authenticationMethod)
+	if err != nil {
+		if models.IsNotFoundError(err) {
+			return "", badRequestError("No valid flow state found for user.")
+		}
 		return "", err
 	}
 
diff --git a/internal/api/recover.go b/internal/api/recover.go
index 237a9c03..d3276fe3 100644
--- a/internal/api/recover.go
+++ b/internal/api/recover.go
@@ -46,7 +46,6 @@ func (a *API) Recover(w http.ResponseWriter, r *http.Request) error {
 		return badRequestError("Could not read verification params: %v", err)
 	}
 
-	var codeChallengeMethod models.CodeChallengeMethod
 	flowType := getFlowFromChallenge(params.CodeChallenge)
 	if err := params.Validate(); err != nil {
 		return err
@@ -70,6 +69,10 @@ func (a *API) Recover(w http.ResponseWriter, r *http.Request) error {
 		mailer := a.Mailer(ctx)
 		referrer := a.getReferrer(r)
 		if isPKCEFlow(flowType) {
+			codeChallengeMethod, terr := models.ParseCodeChallengeMethod(params.CodeChallengeMethod)
+			if terr != nil {
+				return terr
+			}
 			if terr := models.NewFlowStateWithUserID(tx, models.Recovery.String(), params.CodeChallenge, codeChallengeMethod, models.Recovery, &(user.ID)); terr != nil {
 				return terr
 			}
diff --git a/internal/api/resend.go b/internal/api/resend.go
index a17ad233..967da487 100644
--- a/internal/api/resend.go
+++ b/internal/api/resend.go
@@ -132,7 +132,7 @@ func (a *API) Resend(w http.ResponseWriter, r *http.Request) error {
 			}
 			return a.sendPhoneConfirmation(ctx, tx, user, params.Phone, phoneConfirmationOtp, smsProvider, sms_provider.SMSProvider)
 		case emailChangeVerification:
-			return a.sendEmailChange(tx, config, user, mailer, params.Email, referrer, config.Mailer.OtpLength)
+			return a.sendEmailChange(tx, config, user, mailer, params.Email, referrer, config.Mailer.OtpLength, models.ImplicitFlow)
 		case phoneChangeVerification:
 			smsProvider, terr := sms_provider.GetSmsProvider(*config)
 			if terr != nil {
diff --git a/internal/api/signup.go b/internal/api/signup.go
index 9ddb6b90..c643efde 100644
--- a/internal/api/signup.go
+++ b/internal/api/signup.go
@@ -35,7 +35,7 @@ func (p *SignupParams) Validate(passwordMinLength int, smsProvider string) error
 		return unprocessableEntityError("Signup requires a valid password")
 	}
 	if len(p.Password) < passwordMinLength {
-		return unprocessableEntityError(fmt.Sprintf("Password should be at least %d characters", passwordMinLength))
+		return invalidPasswordLengthError(passwordMinLength)
 	}
 	if p.Email != "" && p.Phone != "" {
 		return unprocessableEntityError("Only an email address or phone number should be provided on signup.")
@@ -122,7 +122,7 @@ func (a *API) Signup(w http.ResponseWriter, r *http.Request) error {
 		if err != nil {
 			return err
 		}
-		user, err = models.IsDuplicatedEmail(db, params.Email, params.Aud)
+		user, err = models.IsDuplicatedEmail(db, params.Email, params.Aud, nil)
 	case "phone":
 		if !config.External.Phone.Enabled {
 			return badRequestError("Phone signups are disabled")
@@ -184,8 +184,10 @@ func (a *API) Signup(w http.ResponseWriter, r *http.Request) error {
 				}); terr != nil {
 					return terr
 				}
-				if terr := models.NewFlowStateWithUserID(tx, params.Provider, params.CodeChallenge, codeChallengeMethod, models.EmailSignup, &user.ID); terr != nil {
-					return terr
+				if ok := isPKCEFlow(flowType); ok {
+					if terr := models.NewFlowStateWithUserID(tx, params.Provider, params.CodeChallenge, codeChallengeMethod, models.EmailSignup, &user.ID); terr != nil {
+						return terr
+					}
 				}
 				if terr = sendConfirmation(tx, user, mailer, config.SMTP.MaxFrequency, referrer, config.Mailer.OtpLength, flowType); terr != nil {
 					if errors.Is(terr, MaxFrequencyLimitError) {
@@ -287,9 +289,6 @@ func (a *API) Signup(w http.ResponseWriter, r *http.Request) error {
 		metering.RecordLogin("password", user.ID)
 		return sendJSON(w, http.StatusOK, token)
 	}
-	if isPKCEFlow(flowType) {
-		return sendJSON(w, http.StatusOK, map[string]interface{}{})
-	}
 	return sendJSON(w, http.StatusOK, user)
 }
 
diff --git a/internal/api/ssoadmin.go b/internal/api/ssoadmin.go
index e720d03e..19f4211e 100644
--- a/internal/api/ssoadmin.go
+++ b/internal/api/ssoadmin.go
@@ -279,6 +279,7 @@ func (a *API) adminSSOProvidersUpdate(w http.ResponseWriter, r *http.Request) er
 	}
 
 	modified := false
+	updateSAMLProvider := false
 
 	provider := getSSOProvider(ctx)
 
@@ -298,6 +299,7 @@ func (a *API) adminSSOProvidersUpdate(w http.ResponseWriter, r *http.Request) er
 		}
 
 		provider.SAMLProvider.MetadataXML = string(rawMetadata)
+		updateSAMLProvider = true
 		modified = true
 	}
 
@@ -360,7 +362,7 @@ func (a *API) adminSSOProvidersUpdate(w http.ResponseWriter, r *http.Request) er
 				}
 			}
 
-			if updateAttributeMapping {
+			if updateAttributeMapping || updateSAMLProvider {
 				if terr := tx.Eager().Update(&provider.SAMLProvider); terr != nil {
 					return terr
 				}
diff --git a/internal/api/token.go b/internal/api/token.go
index b3520586..491830b2 100644
--- a/internal/api/token.go
+++ b/internal/api/token.go
@@ -600,7 +600,7 @@ func (a *API) PKCE(ctx context.Context, w http.ResponseWriter, r *http.Request)
 		return err
 	}
 	if flowState.IsExpired(a.config.External.FlowStateExpiryDuration) {
-		return forbiddenError("invalid oauth state, oauth state has expired")
+		return forbiddenError("invalid flow state, flow state has expired")
 	}
 
 	user, err := models.FindUserByID(db, *flowState.UserID)
diff --git a/internal/api/user.go b/internal/api/user.go
index 84643d32..100f5056 100644
--- a/internal/api/user.go
+++ b/internal/api/user.go
@@ -2,6 +2,7 @@ package api
 
 import (
 	"encoding/json"
+	"errors"
 	"net/http"
 
 	"github.com/fatih/structs"
@@ -14,13 +15,15 @@ import (
 
 // UserUpdateParams parameters for updating a user
 type UserUpdateParams struct {
-	Email    string                 `json:"email"`
-	Password *string                `json:"password"`
-	Nonce    string                 `json:"nonce"`
-	Data     map[string]interface{} `json:"data"`
-	AppData  map[string]interface{} `json:"app_metadata,omitempty"`
-	Phone    string                 `json:"phone"`
-	Channel  string                 `json:"channel"`
+	Email               string                 `json:"email"`
+	Password            *string                `json:"password"`
+	Nonce               string                 `json:"nonce"`
+	Data                map[string]interface{} `json:"data"`
+	AppData             map[string]interface{} `json:"app_metadata,omitempty"`
+	Phone               string                 `json:"phone"`
+	Channel             string                 `json:"channel"`
+	CodeChallenge       string                 `json:"code_challenge"`
+	CodeChallengeMethod string                 `json:"code_challenge_method"`
 }
 
 // UserGet returns a user
@@ -69,7 +72,7 @@ func (a *API) UserUpdate(w http.ResponseWriter, r *http.Request) error {
 			return err
 		}
 		var duplicateUser *models.User
-		if duplicateUser, err = models.IsDuplicatedEmail(a.db, params.Email, aud); err != nil {
+		if duplicateUser, err = models.IsDuplicatedEmail(a.db, params.Email, aud, user); err != nil {
 			return internalServerError("Database error checking email").WithInternalError(err)
 		} else if duplicateUser != nil {
 			return unprocessableEntityError(DuplicateEmailMsg)
@@ -175,7 +178,20 @@ func (a *API) UserUpdate(w http.ResponseWriter, r *http.Request) error {
 			}
 			mailer := a.Mailer(ctx)
 			referrer := a.getReferrer(r)
-			if terr = a.sendEmailChange(tx, config, user, mailer, params.Email, referrer, config.Mailer.OtpLength); terr != nil {
+			flowType := getFlowFromChallenge(params.CodeChallenge)
+			if isPKCEFlow(flowType) {
+				codeChallengeMethod, terr := models.ParseCodeChallengeMethod(params.CodeChallengeMethod)
+				if terr != nil {
+					return terr
+				}
+				if terr := models.NewFlowStateWithUserID(tx, models.EmailChange.String(), params.CodeChallenge, codeChallengeMethod, models.EmailChange, &user.ID); terr != nil {
+					return terr
+				}
+			}
+			if terr = a.sendEmailChange(tx, config, user, mailer, params.Email, referrer, config.Mailer.OtpLength, flowType); terr != nil {
+				if errors.Is(terr, MaxFrequencyLimitError) {
+					return tooManyRequestsError("For security purposes, you can only request this once every 60 seconds")
+				}
 				return internalServerError("Error sending change email").WithInternalError(terr)
 			}
 		}
diff --git a/internal/api/verify.go b/internal/api/verify.go
index 6b287fc5..a508c96f 100644
--- a/internal/api/verify.go
+++ b/internal/api/verify.go
@@ -92,8 +92,13 @@ func (a *API) verifyGet(w http.ResponseWriter, r *http.Request) error {
 		authCode    string
 	)
 	var flowType models.FlowType
+	var authenticationMethod models.AuthenticationMethod
 	if strings.HasPrefix(params.Token, PKCEPrefix) {
 		flowType = models.PKCEFlow
+		authenticationMethod, err = models.ParseAuthenticationMethod(params.Type)
+		if err != nil {
+			return err
+		}
 	} else {
 		flowType = models.ImplicitFlow
 	}
@@ -110,7 +115,6 @@ func (a *API) verifyGet(w http.ResponseWriter, r *http.Request) error {
 		if terr != nil {
 			return terr
 		}
-
 		switch params.Type {
 		case signupVerification, inviteVerification:
 			user, terr = a.signupVerify(r, ctx, tx, user)
@@ -142,7 +146,7 @@ func (a *API) verifyGet(w http.ResponseWriter, r *http.Request) error {
 				return internalServerError("Failed to set JWT cookie. %s", terr)
 			}
 		} else if isPKCEFlow(flowType) {
-			if authCode, terr = issueAuthCode(tx, user, a.config.External.FlowStateExpiryDuration); terr != nil {
+			if authCode, terr = issueAuthCode(tx, user, a.config.External.FlowStateExpiryDuration, authenticationMethod); terr != nil {
 				return badRequestError("No associated flow state found. %s", terr)
 			}
 		}
@@ -163,9 +167,10 @@ func (a *API) verifyGet(w http.ResponseWriter, r *http.Request) error {
 		q.Set("type", params.Type)
 		rurl = token.AsRedirectURL(rurl, q)
 	} else if isPKCEFlow(flowType) {
-		q := url.Values{}
-		q.Set("code", authCode)
-		rurl += "?" + q.Encode()
+		rurl, err = a.prepPKCERedirectURL(rurl, authCode)
+		if err != nil {
+			return err
+		}
 	}
 	http.Redirect(w, r, rurl, http.StatusSeeOther)
 	return nil
@@ -368,6 +373,17 @@ func (a *API) prepRedirectURL(message string, rurl string) string {
 	return rurl + "#" + q.Encode()
 }
 
+func (a *API) prepPKCERedirectURL(rurl, code string) (string, error) {
+	u, err := url.Parse(rurl)
+	if err != nil {
+		return "", err
+	}
+	q := u.Query()
+	q.Set("code", code)
+	u.RawQuery = q.Encode()
+	return u.String(), nil
+}
+
 func (a *API) emailChangeVerify(r *http.Request, ctx context.Context, conn *storage.Connection, params *VerifyParams, user *models.User) (*models.User, error) {
 	config := a.config
 
@@ -545,7 +561,7 @@ func isOtpValid(actual, expected string, sentAt *time.Time, otpExp uint) bool {
 	if expected == "" || sentAt == nil {
 		return false
 	}
-	return !isOtpExpired(sentAt, otpExp) && (actual == expected)
+	return !isOtpExpired(sentAt, otpExp) && ((actual == expected) || ("pkce_"+actual == expected))
 }
 
 func isOtpExpired(sentAt *time.Time, otpExp uint) bool {
diff --git a/internal/conf/configuration.go b/internal/conf/configuration.go
index 8579932c..b104e6eb 100644
--- a/internal/conf/configuration.go
+++ b/internal/conf/configuration.go
@@ -235,14 +235,14 @@ func (c *CaptchaConfiguration) Validate() error {
 		return nil
 	}
 
-	if c.Provider != "hcaptcha" {
+	if c.Provider != "hcaptcha" && c.Provider != "turnstile" {
 		return fmt.Errorf("unsupported captcha provider: %s", c.Provider)
 	}
 
 	c.Secret = strings.TrimSpace(c.Secret)
 
 	if c.Secret == "" {
-		return errors.New("hcaptcha provider secret is empty")
+		return errors.New("captcha provider secret is empty")
 	}
 
 	return nil
diff --git a/internal/models/challenge.go b/internal/models/challenge.go
index f86d5656..c4ec259a 100644
--- a/internal/models/challenge.go
+++ b/internal/models/challenge.go
@@ -22,8 +22,6 @@ func (Challenge) TableName() string {
 	return tableName
 }
 
-const ChallengePrefix = "challenge"
-
 func NewChallenge(factor *Factor, ipAddress string) (*Challenge, error) {
 	id := uuid.Must(uuid.NewV4())
 
@@ -44,10 +42,18 @@ func FindChallengeByChallengeID(tx *storage.Connection, challengeID uuid.UUID) (
 }
 
 // Update the verification timestamp
-func (f *Challenge) Verify(tx *storage.Connection) error {
+func (c *Challenge) Verify(tx *storage.Connection) error {
 	now := time.Now()
-	f.VerifiedAt = &now
-	return tx.UpdateOnly(f, "verified_at")
+	c.VerifiedAt = &now
+	return tx.UpdateOnly(c, "verified_at")
+}
+
+func (c *Challenge) HasExpired(expiryDuration float64) bool {
+	return time.Now().After(c.GetExpiryTime(expiryDuration))
+}
+
+func (c *Challenge) GetExpiryTime(expiryDuration float64) time.Time {
+	return c.CreatedAt.Add(time.Second * time.Duration(expiryDuration))
 }
 
 func findChallenge(tx *storage.Connection, query string, args ...interface{}) (*Challenge, error) {
diff --git a/internal/models/errors.go b/internal/models/errors.go
index d51a7170..47513719 100644
--- a/internal/models/errors.go
+++ b/internal/models/errors.go
@@ -11,10 +11,6 @@ func IsNotFoundError(err error) bool {
 		return true
 	case RefreshTokenNotFoundError, *RefreshTokenNotFoundError:
 		return true
-	case InstanceNotFoundError, *InstanceNotFoundError:
-		return true
-	case TotpSecretNotFoundError, *TotpSecretNotFoundError:
-		return true
 	case IdentityNotFoundError, *IdentityNotFoundError:
 		return true
 	case ChallengeNotFoundError, *ChallengeNotFoundError:
@@ -65,13 +61,6 @@ func (e RefreshTokenNotFoundError) Error() string {
 	return "Refresh Token not found"
 }
 
-// InstanceNotFoundError represents when an instance is not found.
-type InstanceNotFoundError struct{}
-
-func (e InstanceNotFoundError) Error() string {
-	return "Instance not found"
-}
-
 // FactorNotFoundError represents when a user is not found.
 type FactorNotFoundError struct{}
 
@@ -86,12 +75,6 @@ func (e ChallengeNotFoundError) Error() string {
 	return "Challenge not found"
 }
 
-type TotpSecretNotFoundError struct{}
-
-func (e TotpSecretNotFoundError) Error() string {
-	return "Totp Secret not found"
-}
-
 // SSOProviderNotFoundError represents an error when a SSO Provider can't be
 // found.
 type SSOProviderNotFoundError struct{}
diff --git a/internal/models/factor.go b/internal/models/factor.go
index faa1ad26..64a7f9ff 100644
--- a/internal/models/factor.go
+++ b/internal/models/factor.go
@@ -3,6 +3,7 @@ package models
 import (
 	"database/sql"
 	"fmt"
+	"strings"
 	"time"
 
 	"github.com/gobuffalo/pop/v6"
@@ -42,6 +43,7 @@ const (
 	Invite
 	MagicLink
 	EmailSignup
+	EmailChange
 )
 
 func (authMethod AuthenticationMethod) String() string {
@@ -64,11 +66,16 @@ func (authMethod AuthenticationMethod) String() string {
 		return "magiclink"
 	case EmailSignup:
 		return "email/signup"
+	case EmailChange:
+		return "email_change"
 	}
 	return ""
 }
 
 func ParseAuthenticationMethod(authMethod string) (AuthenticationMethod, error) {
+	if strings.HasSuffix(authMethod, "signup") {
+		authMethod = "email/signup"
+	}
 	switch authMethod {
 	case "oauth":
 		return OAuth, nil
@@ -88,6 +95,8 @@ func ParseAuthenticationMethod(authMethod string) (AuthenticationMethod, error)
 		return MagicLink, nil
 	case "email/signup":
 		return EmailSignup, nil
+	case "email_change":
+		return EmailChange, nil
 	}
 	return 0, fmt.Errorf("unsupported authentication method %q", authMethod)
 }
@@ -195,6 +204,14 @@ func (f *Factor) DowngradeSessionsToAAL1(tx *storage.Connection) error {
 	return updateFactorAssociatedSessions(tx, f.UserID, f.ID, AAL1.String())
 }
 
+func (f *Factor) IsOwnedBy(user *User) bool {
+	return f.UserID == user.ID
+}
+
+func (f *Factor) IsVerified() bool {
+	return f.Status == FactorStateVerified.String()
+}
+
 func DeleteFactorsByUserId(tx *storage.Connection, userId uuid.UUID) error {
 	if err := tx.RawQuery("DELETE FROM "+(&pop.Model{Value: Factor{}}).TableName()+" WHERE user_id = ?", userId).Exec(); err != nil {
 		return err
diff --git a/internal/models/flow_state.go b/internal/models/flow_state.go
index 4b61333b..006ea64e 100644
--- a/internal/models/flow_state.go
+++ b/internal/models/flow_state.go
@@ -134,9 +134,9 @@ func FindFlowStateByID(tx *storage.Connection, id string) (*FlowState, error) {
 	return obj, nil
 }
 
-func FindFlowStateByUserID(tx *storage.Connection, id string) (*FlowState, error) {
+func FindFlowStateByUserID(tx *storage.Connection, id string, authenticationMethod AuthenticationMethod) (*FlowState, error) {
 	obj := &FlowState{}
-	if err := tx.Eager().Q().Where("user_id = ?", id).Last(obj); err != nil {
+	if err := tx.Eager().Q().Where("user_id = ? and authentication_method = ?", id, authenticationMethod).Last(obj); err != nil {
 		if errors.Cause(err) == sql.ErrNoRows {
 			return nil, FlowStateNotFoundError{}
 		}
@@ -166,5 +166,5 @@ func (f *FlowState) VerifyPKCE(codeVerifier string) error {
 }
 
 func (f *FlowState) IsExpired(expiryDuration time.Duration) bool {
-	return f.CreatedAt.After(time.Now().Add(expiryDuration))
+	return time.Now().After(f.CreatedAt.Add(expiryDuration))
 }
diff --git a/internal/models/linking.go b/internal/models/linking.go
index 2696682f..16bffab1 100644
--- a/internal/models/linking.go
+++ b/internal/models/linking.go
@@ -78,8 +78,12 @@ func DetermineAccountLinking(tx *storage.Connection, provider, sub string, email
 			return AccountLinkingResult{}, terr
 		}
 
-		if terr := tx.Q().Eager().Where("email in (?) and is_sso_user is false", emails).All(&similarUsers); terr != nil {
-			return AccountLinkingResult{}, terr
+		if !strings.HasPrefix(provider, "sso:") {
+			// there can be multiple user accounts with the same email when is_sso_user is true
+			// so we just do not consider those similar user accounts
+			if terr := tx.Q().Eager().Where("email in (?) and is_sso_user is false", emails).All(&similarUsers); terr != nil {
+				return AccountLinkingResult{}, terr
+			}
 		}
 	}
 
diff --git a/internal/models/sessions.go b/internal/models/sessions.go
index c502a7a7..ad6fa1e2 100644
--- a/internal/models/sessions.go
+++ b/internal/models/sessions.go
@@ -201,3 +201,7 @@ func (s *Session) GetAAL() string {
 	}
 	return *(s.AAL)
 }
+
+func (s *Session) IsAAL2() bool {
+	return s.GetAAL() == AAL2.String()
+}
diff --git a/internal/models/user.go b/internal/models/user.go
index 848c25ff..d45d4657 100644
--- a/internal/models/user.go
+++ b/internal/models/user.go
@@ -291,6 +291,12 @@ func (u *User) ConfirmEmailChange(tx *storage.Connection, status int) error {
 		return err
 	}
 
+	if !u.IsConfirmed() {
+		if err := u.Confirm(tx); err != nil {
+			return err
+		}
+	}
+
 	identity, err := FindIdentityByIdAndProvider(tx, u.ID.String(), "email")
 	if err != nil {
 		if IsNotFoundError(err) {
@@ -508,7 +514,8 @@ func FindUserByPhoneChangeAndAudience(tx *storage.Connection, phone, aud string)
 }
 
 // IsDuplicatedEmail returns whether a user exists with a matching email and audience.
-func IsDuplicatedEmail(tx *storage.Connection, email, aud string) (*User, error) {
+// If a currentUser is provided, we will need to filter out any identities that belong to the current user.
+func IsDuplicatedEmail(tx *storage.Connection, email, aud string, currentUser *User) (*User, error) {
 	var identities []Identity
 
 	if err := tx.Eager().Q().Where("email = ?", strings.ToLower(email)).All(&identities); err != nil {
@@ -522,7 +529,9 @@ func IsDuplicatedEmail(tx *storage.Connection, email, aud string) (*User, error)
 	userIDs := make(map[string]uuid.UUID)
 	for _, identity := range identities {
 		if !identity.IsForSSOProvider() {
-			userIDs[identity.UserID.String()] = identity.UserID
+			if (currentUser != nil && currentUser.ID != identity.UserID) || (currentUser == nil) {
+				userIDs[identity.UserID.String()] = identity.UserID
+			}
 		}
 	}
 
diff --git a/internal/security/hcaptcha.go b/internal/security/captcha.go
similarity index 67%
rename from internal/security/hcaptcha.go
rename to internal/security/captcha.go
index 91d4cac1..9525eb58 100644
--- a/internal/security/hcaptcha.go
+++ b/internal/security/captcha.go
@@ -10,6 +10,7 @@ import (
 	"strings"
 	"time"
 
+	"fmt"
 	"github.com/pkg/errors"
 	"github.com/supabase/gotrue/internal/utilities"
 )
@@ -44,7 +45,7 @@ func init() {
 	Client = &http.Client{Timeout: defaultTimeout}
 }
 
-func VerifyRequest(r *http.Request, secretKey string) (VerificationResponse, error) {
+func VerifyRequest(r *http.Request, secretKey, captchaProvider string) (VerificationResponse, error) {
 	bodyBytes, err := utilities.GetBodyBytes(r)
 	if err != nil {
 		return VerificationResponse{}, err
@@ -59,38 +60,53 @@ func VerifyRequest(r *http.Request, secretKey string) (VerificationResponse, err
 	captchaResponse := strings.TrimSpace(requestBody.Security.Token)
 
 	if captchaResponse == "" {
-		return VerificationResponse{}, errors.New("no hCaptcha response (captcha_token) found in request")
+		return VerificationResponse{}, errors.New("no captcha response (captcha_token) found in request")
 	}
 
 	clientIP := utilities.GetIPAddress(r)
+	captchaURL, err := GetCaptchaURL(captchaProvider)
+	if err != nil {
+		return VerificationResponse{}, err
+	}
 
-	return verifyCaptchaCode(captchaResponse, secretKey, clientIP)
+	return verifyCaptchaCode(captchaResponse, secretKey, clientIP, captchaURL)
 }
 
-func verifyCaptchaCode(token string, secretKey string, clientIP string) (VerificationResponse, error) {
+func verifyCaptchaCode(token, secretKey, clientIP, captchaURL string) (VerificationResponse, error) {
 	data := url.Values{}
 	data.Set("secret", secretKey)
 	data.Set("response", token)
 	data.Set("remoteip", clientIP)
 	// TODO (darora): pipe through sitekey
 
-	r, err := http.NewRequest("POST", "https://hcaptcha.com/siteverify", strings.NewReader(data.Encode()))
+	r, err := http.NewRequest("POST", captchaURL, strings.NewReader(data.Encode()))
 	if err != nil {
-		return VerificationResponse{}, errors.Wrap(err, "couldn't initialize request object for hcaptcha check")
+		return VerificationResponse{}, errors.Wrap(err, "couldn't initialize request object for captcha check")
 	}
 	r.Header.Add("Content-Type", "application/x-www-form-urlencoded")
 	r.Header.Add("Content-Length", strconv.Itoa(len(data.Encode())))
 	res, err := Client.Do(r)
 	if err != nil {
-		return VerificationResponse{}, errors.Wrap(err, "failed to verify hcaptcha response")
+		return VerificationResponse{}, errors.Wrap(err, "failed to verify captcha response")
 	}
 	defer utilities.SafeClose(res.Body)
 
 	var verificationResponse VerificationResponse
 
 	if err := json.NewDecoder(res.Body).Decode(&verificationResponse); err != nil {
-		return VerificationResponse{}, errors.Wrap(err, "failed to decode hcaptcha response: not JSON")
+		return VerificationResponse{}, errors.Wrap(err, "failed to decode captcha response: not JSON")
 	}
 
 	return verificationResponse, nil
 }
+
+func GetCaptchaURL(captchaProvider string) (string, error) {
+	switch captchaProvider {
+	case "hcaptcha":
+		return "https://hcaptcha.com/siteverify", nil
+	case "turnstile":
+		return "https://challenges.cloudflare.com/turnstile/v0/siteverify", nil
+	default:
+		return "", fmt.Errorf("captcha Provider %q could not be found", captchaProvider)
+	}
+}
diff --git a/migrations/20230322519590_add_flow_state_table.up.sql b/migrations/20230322519590_add_flow_state_table.up.sql
index a9b473e0..a8842e5b 100644
--- a/migrations/20230322519590_add_flow_state_table.up.sql
+++ b/migrations/20230322519590_add_flow_state_table.up.sql
@@ -16,5 +16,5 @@ create table if not exists {{ index .Options "Namespace" }}.flow_state(
        created_at timestamptz null,
        updated_at timestamptz null
 );
-create index idx_auth_code on {{ index .Options "Namespace" }}.flow_state(auth_code);
-comment on table {{ index .Options "Namespace" }}.flow_state is 'stores metadata for oauth provider logins';
+create index if not exists idx_auth_code on {{ index .Options "Namespace" }}.flow_state(auth_code);
+comment on table {{ index .Options "Namespace" }}.flow_state is 'stores metadata for pkce logins';
diff --git a/migrations/20230402418590_add_authentication_method_to_flow_state_table.up.sql b/migrations/20230402418590_add_authentication_method_to_flow_state_table.up.sql
index 41ebf6e6..e83af856 100644
--- a/migrations/20230402418590_add_authentication_method_to_flow_state_table.up.sql
+++ b/migrations/20230402418590_add_authentication_method_to_flow_state_table.up.sql
@@ -1,2 +1,6 @@
 alter table {{index .Options "Namespace" }}.flow_state
-add column authentication_method text not null;
+add column if not exists authentication_method text not null;
+create index if not exists idx_user_id_auth_method on {{index .Options "Namespace" }}.flow_state (user_id, authentication_method);
+
+-- Update comment as we have generalized the table
+comment on table {{ index .Options "Namespace" }}.flow_state is 'stores metadata for pkce logins';
diff --git a/migrations/20230411005111_remove_duplicate_idx.up.sql b/migrations/20230411005111_remove_duplicate_idx.up.sql
index 543b3492..dc23931c 100644
--- a/migrations/20230411005111_remove_duplicate_idx.up.sql
+++ b/migrations/20230411005111_remove_duplicate_idx.up.sql
@@ -1,1 +1,1 @@
-drop index {{index .Options "Namespace" }}.refresh_tokens_token_idx;
+drop index if exists {{index .Options "Namespace" }}.refresh_tokens_token_idx;


Test patch:
diff --git a/internal/api/mfa_test.go b/internal/api/mfa_test.go
index 8383c884..f54a1551 100644
--- a/internal/api/mfa_test.go
+++ b/internal/api/mfa_test.go
@@ -137,7 +137,7 @@ func (ts *MFATestSuite) TestEnrollFactor() {
 			factors, err := models.FindFactorsByUser(ts.API.db, user)
 			ts.Require().NoError(err)
 			latestFactor := factors[len(factors)-1]
-			require.Equal(ts.T(), models.FactorStateUnverified.String(), latestFactor.Status)
+			require.False(ts.T(), latestFactor.IsVerified())
 			if c.friendlyName != "" && c.expectedCode == http.StatusOK {
 				require.Equal(ts.T(), c.friendlyName, latestFactor.FriendlyName)
 			}
@@ -402,7 +402,7 @@ func (ts *MFATestSuite) TestSessionsMaintainAALOnRefresh() {
 	require.NoError(ts.T(), err)
 	ctx, err = ts.API.maybeLoadUserOrSession(ctx)
 	require.NoError(ts.T(), err)
-	require.Equal(ts.T(), models.AAL2.String(), getSession(ctx).GetAAL())
+	require.True(ts.T(), getSession(ctx).IsAAL2())
 }
 
 // Performing MFA Verification followed by a sign in should return an AAL1 session and an AAL2 session
@@ -431,7 +431,7 @@ func (ts *MFATestSuite) TestMFAFollowedByPasswordSignIn() {
 	require.Equal(ts.T(), models.AAL1.String(), getSession(ctx).GetAAL())
 	session, err := models.FindSessionByUserID(ts.API.db, token.User.ID)
 	require.NoError(ts.T(), err)
-	require.Equal(ts.T(), models.AAL2.String(), session.GetAAL())
+	require.True(ts.T(), session.IsAAL2())
 }
 
 func signUp(ts *MFATestSuite, email, password string) (signUpResp AccessTokenResponse) {
diff --git a/internal/api/middleware_test.go b/internal/api/middleware_test.go
index ada47cc2..0d1ad20e 100644
--- a/internal/api/middleware_test.go
+++ b/internal/api/middleware_test.go
@@ -17,8 +17,9 @@ import (
 )
 
 const (
-	HCaptchaSecret   string = "0x0000000000000000000000000000000000000000"
-	HCaptchaResponse string = "10000000-aaaa-bbbb-cccc-000000000001"
+	HCaptchaSecret         string = "0x0000000000000000000000000000000000000000"
+	CaptchaResponse        string = "10000000-aaaa-bbbb-cccc-000000000001"
+	TurnstileCaptchaSecret string = "1x0000000000000000000000000000000AA"
 )
 
 type MiddlewareTestSuite struct {
@@ -42,8 +43,6 @@ func TestMiddlewareFunctions(t *testing.T) {
 
 func (ts *MiddlewareTestSuite) TestVerifyCaptchaValid() {
 	ts.Config.Security.Captcha.Enabled = true
-	ts.Config.Security.Captcha.Provider = "hcaptcha"
-	ts.Config.Security.Captcha.Secret = HCaptchaSecret
 
 	adminClaims := &GoTrueClaims{
 		Role: "supabase_admin",
@@ -51,22 +50,44 @@ func (ts *MiddlewareTestSuite) TestVerifyCaptchaValid() {
 	adminJwt, err := jwt.NewWithClaims(jwt.SigningMethodHS256, adminClaims).SignedString([]byte(ts.Config.JWT.Secret))
 	require.NoError(ts.T(), err)
 	cases := []struct {
-		desc          string
-		adminJwt      string
-		captcha_token string
+		desc             string
+		adminJwt         string
+		captcha_token    string
+		captcha_provider string
 	}{
 		{
 			"Valid captcha response",
 			"",
-			HCaptchaResponse,
+			CaptchaResponse,
+			"hcaptcha",
+		},
+		{
+			"Valid captcha response",
+			"",
+			CaptchaResponse,
+			"turnstile",
+		},
+		{
+			"Ignore captcha if admin role is present",
+			adminJwt,
+			"",
+			"hcaptcha",
 		},
 		{
 			"Ignore captcha if admin role is present",
 			adminJwt,
 			"",
+			"turnstile",
 		},
 	}
 	for _, c := range cases {
+		ts.Config.Security.Captcha.Provider = c.captcha_provider
+		if c.captcha_provider == "turnstile" {
+			ts.Config.Security.Captcha.Secret = TurnstileCaptchaSecret
+		} else if c.captcha_provider == "hcaptcha" {
+			ts.Config.Security.Captcha.Secret = HCaptchaSecret
+		}
+
 		var buffer bytes.Buffer
 		require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]interface{}{
 			"email":    "test@example.com",
@@ -122,7 +143,17 @@ func (ts *MiddlewareTestSuite) TestVerifyCaptchaInvalid() {
 				Secret:   "test",
 			},
 			http.StatusBadRequest,
-			"hCaptcha protection: request disallowed (not-using-dummy-secret)",
+			"captcha protection: request disallowed (not-using-dummy-secret)",
+		},
+		{
+			"Captcha validation failed",
+			&conf.CaptchaConfiguration{
+				Enabled:  true,
+				Provider: "turnstile",
+				Secret:   "anothertest",
+			},
+			http.StatusBadRequest,
+			"captcha protection: request disallowed (invalid-input-secret)",
 		},
 	}
 	for _, c := range cases {
@@ -133,7 +164,7 @@ func (ts *MiddlewareTestSuite) TestVerifyCaptchaInvalid() {
 				"email":    "test@example.com",
 				"password": "secret",
 				"gotrue_meta_security": map[string]interface{}{
-					"captcha_token": HCaptchaResponse,
+					"captcha_token": CaptchaResponse,
 				},
 			}))
 			req := httptest.NewRequest(http.MethodPost, "http://localhost", &buffer)
diff --git a/internal/api/verify_test.go b/internal/api/verify_test.go
index ec4c9ead..a1e167ee 100644
--- a/internal/api/verify_test.go
+++ b/internal/api/verify_test.go
@@ -127,76 +127,122 @@ func (ts *VerifyTestSuite) TestVerifyPasswordRecovery() {
 }
 
 func (ts *VerifyTestSuite) TestVerifySecureEmailChange() {
-	u, err := models.FindUserByEmailAndAudience(ts.API.db, "test@example.com", ts.Config.JWT.Aud)
-	require.NoError(ts.T(), err)
-	u.EmailChangeSentAt = &time.Time{}
-	require.NoError(ts.T(), ts.API.db.Update(u))
+	currentEmail := "test@example.com"
+	newEmail := "new@example.com"
 
-	// Request body
-	var buffer bytes.Buffer
-	require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]interface{}{
-		"email": "new@example.com",
-	}))
+	// Change from new email to current email and back to new email
+	cases := []struct {
+		desc         string
+		body         map[string]interface{}
+		isPKCE       bool
+		currentEmail string
+		newEmail     string
+	}{
+		{
+			desc: "Implict Flow Email Change",
+			body: map[string]interface{}{
+				"email": newEmail,
+			},
+			isPKCE:       false,
+			currentEmail: currentEmail,
+			newEmail:     newEmail,
+		},
+		{
+			desc: "PKCE Email Change",
+			body: map[string]interface{}{
+				"email": currentEmail,
+				// Code Challenge needs to be at least 43 characters long
+				"code_challenge":        "6b151854-cc15-4e29-8db7-3d3a9f15b3066b151854-cc15-4e29-8db7-3d3a9f15b306",
+				"code_challenge_method": models.SHA256.String(),
+			},
+			isPKCE:       true,
+			currentEmail: newEmail,
+			newEmail:     currentEmail,
+		},
+	}
 
-	// Setup request
-	req := httptest.NewRequest(http.MethodPut, "http://localhost/user", &buffer)
-	req.Header.Set("Content-Type", "application/json")
+	for _, c := range cases {
+		u, err := models.FindUserByEmailAndAudience(ts.API.db, c.currentEmail, ts.Config.JWT.Aud)
+		require.NoError(ts.T(), err)
 
-	// Generate access token for request
-	var token string
-	token, err = generateAccessToken(ts.API.db, u, nil, time.Second*time.Duration(ts.Config.JWT.Exp), ts.Config.JWT.Secret)
-	require.NoError(ts.T(), err)
-	req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token))
+		u.EmailChangeSentAt = &time.Time{}
+		require.NoError(ts.T(), ts.API.db.Update(u))
 
-	// Setup response recorder
-	w := httptest.NewRecorder()
-	ts.API.handler.ServeHTTP(w, req)
-	assert.Equal(ts.T(), http.StatusOK, w.Code)
+		// Request body
+		var buffer bytes.Buffer
+		require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(c.body))
 
-	u, err = models.FindUserByEmailAndAudience(ts.API.db, "test@example.com", ts.Config.JWT.Aud)
-	require.NoError(ts.T(), err)
+		// Setup request
+		req := httptest.NewRequest(http.MethodPut, "http://localhost/user", &buffer)
+		req.Header.Set("Content-Type", "application/json")
 
-	assert.WithinDuration(ts.T(), time.Now(), *u.EmailChangeSentAt, 1*time.Second)
-	assert.False(ts.T(), u.IsConfirmed())
+		// Generate access token for request
+		var token string
+		token, err = generateAccessToken(ts.API.db, u, nil, time.Second*time.Duration(ts.Config.JWT.Exp), ts.Config.JWT.Secret)
+		require.NoError(ts.T(), err)
+		req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token))
 
-	// Verify new email
-	reqURL := fmt.Sprintf("http://localhost/verify?type=%s&token=%s", emailChangeVerification, u.EmailChangeTokenNew)
-	req = httptest.NewRequest(http.MethodGet, reqURL, nil)
+		// Setup response recorder
+		w := httptest.NewRecorder()
+		ts.API.handler.ServeHTTP(w, req)
+		assert.Equal(ts.T(), http.StatusOK, w.Code)
 
-	w = httptest.NewRecorder()
-	ts.API.handler.ServeHTTP(w, req)
+		u, err = models.FindUserByEmailAndAudience(ts.API.db, c.currentEmail, ts.Config.JWT.Aud)
+		require.NoError(ts.T(), err)
 
-	require.Equal(ts.T(), http.StatusSeeOther, w.Code)
-	urlVal, err := url.Parse(w.Result().Header.Get("Location"))
-	ts.Require().NoError(err, "redirect url parse failed")
-	v, err := url.ParseQuery(urlVal.Fragment)
-	ts.Require().NoError(err)
-	ts.Require().NotEmpty(v.Get("message"))
+		assert.WithinDuration(ts.T(), time.Now(), *u.EmailChangeSentAt, 1*time.Second)
+		assert.False(ts.T(), u.IsConfirmed())
 
-	u, err = models.FindUserByEmailAndAudience(ts.API.db, "test@example.com", ts.Config.JWT.Aud)
-	require.NoError(ts.T(), err)
-	assert.Equal(ts.T(), singleConfirmation, u.EmailChangeConfirmStatus)
+		// Verify new email
+		reqURL := fmt.Sprintf("http://localhost/verify?type=%s&token=%s", emailChangeVerification, u.EmailChangeTokenNew)
+		req = httptest.NewRequest(http.MethodGet, reqURL, nil)
 
-	// Verify old email
-	reqURL = fmt.Sprintf("http://localhost/verify?type=%s&token=%s", emailChangeVerification, u.EmailChangeTokenCurrent)
-	req = httptest.NewRequest(http.MethodGet, reqURL, nil)
+		w = httptest.NewRecorder()
+		ts.API.handler.ServeHTTP(w, req)
 
-	w = httptest.NewRecorder()
-	ts.API.handler.ServeHTTP(w, req)
-	require.Equal(ts.T(), http.StatusSeeOther, w.Code)
-
-	urlVal, err = url.Parse(w.Header().Get("Location"))
-	ts.Require().NoError(err, "redirect url parse failed")
-	v, err = url.ParseQuery(urlVal.Fragment)
-	ts.Require().NoError(err)
-	ts.Require().NotEmpty(v.Get("access_token"))
-	ts.Require().NotEmpty(v.Get("expires_in"))
-	ts.Require().NotEmpty(v.Get("refresh_token"))
-
-	// user's email should've been updated to new@example.com
-	u, err = models.FindUserByEmailAndAudience(ts.API.db, "new@example.com", ts.Config.JWT.Aud)
-	require.NoError(ts.T(), err)
-	require.Equal(ts.T(), zeroConfirmation, u.EmailChangeConfirmStatus)
+		require.Equal(ts.T(), http.StatusSeeOther, w.Code)
+		urlVal, err := url.Parse(w.Result().Header.Get("Location"))
+		ts.Require().NoError(err, "redirect url parse failed")
+		v, err := url.ParseQuery(urlVal.Fragment)
+		ts.Require().NoError(err)
+		ts.Require().NotEmpty(v.Get("message"))
+
+		u, err = models.FindUserByEmailAndAudience(ts.API.db, c.currentEmail, ts.Config.JWT.Aud)
+		require.NoError(ts.T(), err)
+		assert.Equal(ts.T(), singleConfirmation, u.EmailChangeConfirmStatus)
+
+		// Verify old email
+		reqURL = fmt.Sprintf("http://localhost/verify?type=%s&token=%s", emailChangeVerification, u.EmailChangeTokenCurrent)
+		req = httptest.NewRequest(http.MethodGet, reqURL, nil)
+
+		w = httptest.NewRecorder()
+		ts.API.handler.ServeHTTP(w, req)
+		require.Equal(ts.T(), http.StatusSeeOther, w.Code)
+
+		urlVal, err = url.Parse(w.Header().Get("Location"))
+		ts.Require().NoError(err, "redirect url parse failed")
+		if !c.isPKCE {
+			v, err = url.ParseQuery(urlVal.Fragment)
+			ts.Require().NoError(err)
+			ts.Require().NotEmpty(v.Get("access_token"))
+			ts.Require().NotEmpty(v.Get("expires_in"))
+			ts.Require().NotEmpty(v.Get("refresh_token"))
+		} else if c.isPKCE {
+			v, err = url.ParseQuery(urlVal.RawQuery)
+			ts.Require().NoError(err)
+			ts.Require().NotEmpty(v.Get("code"))
+		}
+
+		// user's email should've been updated to newEmail
+		u, err = models.FindUserByEmailAndAudience(ts.API.db, c.newEmail, ts.Config.JWT.Aud)
+		require.NoError(ts.T(), err)
+		require.Equal(ts.T(), zeroConfirmation, u.EmailChangeConfirmStatus)
+
+		// Reset confirmation status after each test
+		u.EmailConfirmedAt = nil
+		require.NoError(ts.T(), ts.API.db.Update(u))
+
+	}
 }
 
 func (ts *VerifyTestSuite) TestExpiredConfirmationToken() {
@@ -576,8 +622,9 @@ func (ts *VerifyTestSuite) TestVerifyPKCEOTP() {
 	require.NoError(ts.T(), ts.API.db.Update(u))
 
 	cases := []struct {
-		desc    string
-		payload *VerifyParams
+		desc                 string
+		payload              *VerifyParams
+		authenticationMethod models.AuthenticationMethod
 	}{
 		{
 			desc: "Verify banned user on signup",
@@ -585,6 +632,7 @@ func (ts *VerifyTestSuite) TestVerifyPKCEOTP() {
 				Type:  "signup",
 				Token: u.ConfirmationToken,
 			},
+			authenticationMethod: models.EmailSignup,
 		},
 		{
 			desc: "Verify magiclink",
@@ -592,33 +640,34 @@ func (ts *VerifyTestSuite) TestVerifyPKCEOTP() {
 				Type:  "magiclink",
 				Token: u.RecoveryToken,
 			},
+			authenticationMethod: models.MagicLink,
 		},
 	}
 	for _, c := range cases {
-		var buffer bytes.Buffer
-		require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(c.payload))
-		codeChallenge := "codechallengecodechallengcodechallengcodechallengcodechallenge" + c.payload.Type
-		flowState, err := models.NewFlowState(models.MagicLink.String(), codeChallenge, models.SHA256, models.MagicLink)
-		require.NoError(ts.T(), err)
-		flowState.UserID = &(u.ID)
-		require.NoError(ts.T(), ts.API.db.Create(flowState))
+		ts.Run(c.desc, func() {
+			var buffer bytes.Buffer
+			require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(c.payload))
+			codeChallenge := "codechallengecodechallengcodechallengcodechallengcodechallenge" + c.payload.Type
+			err := models.NewFlowStateWithUserID(ts.API.db, c.authenticationMethod.String(), codeChallenge, models.SHA256, c.authenticationMethod, &u.ID)
+			require.NoError(ts.T(), err)
 
-		requestUrl := fmt.Sprintf("http://localhost/verify?type=%v&token=%v", c.payload.Type, c.payload.Token)
-		req := httptest.NewRequest(http.MethodGet, requestUrl, &buffer)
-		req.Header.Set("Content-Type", "application/json")
+			requestUrl := fmt.Sprintf("http://localhost/verify?type=%v&token=%v", c.payload.Type, c.payload.Token)
+			req := httptest.NewRequest(http.MethodGet, requestUrl, &buffer)
+			req.Header.Set("Content-Type", "application/json")
 
-		w := httptest.NewRecorder()
-		ts.API.handler.ServeHTTP(w, req)
-		assert.Equal(ts.T(), http.StatusSeeOther, w.Code)
-		rURL, _ := w.Result().Location()
+			w := httptest.NewRecorder()
+			ts.API.handler.ServeHTTP(w, req)
+			assert.Equal(ts.T(), http.StatusSeeOther, w.Code)
+			rURL, _ := w.Result().Location()
 
-		u, err = models.FindUserByEmailAndAudience(ts.API.db, "test@example.com", ts.Config.JWT.Aud)
-		require.NoError(ts.T(), err)
-		assert.True(ts.T(), u.IsConfirmed())
+			u, err = models.FindUserByEmailAndAudience(ts.API.db, "test@example.com", ts.Config.JWT.Aud)
+			require.NoError(ts.T(), err)
+			assert.True(ts.T(), u.IsConfirmed())
 
-		f, err := url.ParseQuery(rURL.RawQuery)
-		require.NoError(ts.T(), err)
-		assert.NotEmpty(ts.T(), f.Get("code"))
+			f, err := url.ParseQuery(rURL.RawQuery)
+			require.NoError(ts.T(), err)
+			assert.NotEmpty(ts.T(), f.Get("code"))
+		})
 	}
 
 }
diff --git a/internal/models/user_test.go b/internal/models/user_test.go
index f54b5f60..0949a65e 100644
--- a/internal/models/user_test.go
+++ b/internal/models/user_test.go
@@ -162,19 +162,19 @@ func (ts *UserTestSuite) TestFindUserWithRefreshToken() {
 func (ts *UserTestSuite) TestIsDuplicatedEmail() {
 	_ = ts.createUserWithEmail("david.calavera@netlify.com")
 
-	e, err := IsDuplicatedEmail(ts.db, "david.calavera@netlify.com", "test")
+	e, err := IsDuplicatedEmail(ts.db, "david.calavera@netlify.com", "test", nil)
 	require.NoError(ts.T(), err)
 	require.NotNil(ts.T(), e, "expected email to be duplicated")
 
-	e, err = IsDuplicatedEmail(ts.db, "davidcalavera@netlify.com", "test")
+	e, err = IsDuplicatedEmail(ts.db, "davidcalavera@netlify.com", "test", nil)
 	require.NoError(ts.T(), err)
-	require.Nil(ts.T(), e, "expected email to not be duplicated")
+	require.Nil(ts.T(), e, "expected email to not be duplicated", nil)
 
-	e, err = IsDuplicatedEmail(ts.db, "david@netlify.com", "test")
+	e, err = IsDuplicatedEmail(ts.db, "david@netlify.com", "test", nil)
 	require.NoError(ts.T(), err)
-	require.Nil(ts.T(), e, "expected same email to not be duplicated")
+	require.Nil(ts.T(), e, "expected same email to not be duplicated", nil)
 
-	e, err = IsDuplicatedEmail(ts.db, "david.calavera@netlify.com", "other-aud")
+	e, err = IsDuplicatedEmail(ts.db, "david.calavera@netlify.com", "other-aud", nil)
 	require.NoError(ts.T(), err)
 	require.Nil(ts.T(), e, "expected same email to not be duplicated")
 }