Repository: supabase/gotrue
Pull request #430 (2022-03-27 22:37:23)

BUG FIX: Only require nonce in id_token when also passed in body
## Context
Receiving a nonce is optional in the open id spec. Google has 2 apis, the older one does not return a nonce while the newer one does.
There's a risk of replay attack if there's no nonce, but that's why my PR makes passing the nonce optional ONLY if it's missing from the returned jwt

## What kind of change does this PR introduce?

Fix(?) When using native auth on some platforms like Flutter's sign in with google, the idtoken that is returned does not contain a nonce.

## What is the current behavior?

The current behavior requires a nonce inside the id_token as well as a nonce passed.

## What is the new behavior?

If there is a nonce in the id_token, then a nonce is required in the body and vice versa.


Fixes and adds support for
https://github.com/supabase/gotrue/issues/412
https://github.com/supabase-community/gotrue-dart/issues/27
https://github.com/supabase-community/supabase-flutter/issues/5


Patch:
diff --git a/README.md b/README.md
index 21897503..322b8ef5 100644
--- a/README.md
+++ b/README.md
@@ -35,7 +35,7 @@ The base URL your site is located at. Currently used in combination with other s
 
 `URI_ALLOW_LIST` - `string`
 
-A comma separated list of URIs (e.g. "https://supabase.io/welcome,io.supabase.gotruedemo://logincallback") which are permitted as valid `redirect_to` destinations, in addition to SITE_URL. Defaults to [].
+A comma separated list of URIs (e.g. "https://supabase.io/welcome,io.supabase.gotruedemo://logincallback") which are permitted as valid `redirect_to` destinations, in addition to SITE_URL. Defaults to []. Supports wildcard matching trough globbing. e.g. add `*.mydomain.com/welcome` -> `x.mydomain.com/welcome` and `y.mydomain.com/welcome` would be accepted.
 
 `OPERATOR_TOKEN` - `string` _Multi-instance mode only_
 
diff --git a/api/api.go b/api/api.go
index ebe1f6b1..5a0bbf83 100644
--- a/api/api.go
+++ b/api/api.go
@@ -123,15 +123,15 @@ func NewAPIWithVersion(ctx context.Context, globalConfig *conf.GlobalConfigurati
 		r.With(sharedLimiter).With(api.verifyCaptcha).Post("/otp", api.Otp)
 
 		r.With(api.limitHandler(
-			// Allow requests at a rate of 30 per 5 minutes.
-			tollbooth.NewLimiter(30.0/(60*5), &limiter.ExpirableOptions{
+			// Allow requests at the specified rate per 5 minutes.
+			tollbooth.NewLimiter(api.config.RateLimitToken/(60*5), &limiter.ExpirableOptions{
 				DefaultExpirationTTL: time.Hour,
 			}).SetBurst(30),
 		)).Post("/token", api.Token)
 
 		r.With(api.limitHandler(
-			// Allow requests at a rate of 30 per 5 minutes.
-			tollbooth.NewLimiter(30.0/(60*5), &limiter.ExpirableOptions{
+			// Allow requests at the specified rate per 5 minutes.
+			tollbooth.NewLimiter(api.config.RateLimitVerify/(60*5), &limiter.ExpirableOptions{
 				DefaultExpirationTTL: time.Hour,
 			}).SetBurst(30),
 		)).Route("/verify", func(r *router) {
diff --git a/api/errors.go b/api/errors.go
index be77a7ee..26dd37f0 100644
--- a/api/errors.go
+++ b/api/errors.go
@@ -101,7 +101,7 @@ func acceptedTokenError(fmtString string, args ...interface{}) *HTTPError {
 }
 
 func expiredTokenError(fmtString string, args ...interface{}) *HTTPError {
-	return httpError(http.StatusGone, fmtString, args...)
+	return httpError(http.StatusUnauthorized, fmtString, args...)
 }
 
 func unauthorizedError(fmtString string, args ...interface{}) *HTTPError {
diff --git a/api/helpers.go b/api/helpers.go
index c8cfc2f3..d6262871 100644
--- a/api/helpers.go
+++ b/api/helpers.go
@@ -8,6 +8,7 @@ import (
 	"net/http"
 	"net/http/httptrace"
 	"net/url"
+	"strings"
 
 	"github.com/gofrs/uuid"
 	"github.com/netlify/gotrue/conf"
@@ -124,8 +125,13 @@ func isRedirectURLValid(config *conf.Configuration, redirectURL string) bool {
 	}
 
 	// For case when user came from mobile app or other permitted resource - redirect back
-	for _, uri := range config.URIAllowList {
-		if redirectURL == uri {
+	for uri, g := range config.URIAllowListMap {
+		// Only allow wildcard matching if url scheme is http(s)
+		if strings.HasPrefix(uri, "http") || strings.HasPrefix(uri, "https") {
+			if g.Match(redirectURL) {
+				return true
+			}
+		} else if redirectURL == uri {
 			return true
 		}
 	}
diff --git a/api/phone.go b/api/phone.go
index aa6833f1..98bcf9f8 100644
--- a/api/phone.go
+++ b/api/phone.go
@@ -48,21 +48,22 @@ func (a *API) sendPhoneConfirmation(ctx context.Context, tx *storage.Connection,
 
 	var token *string
 	var sentAt *time.Time
-	var tokenDbField, sentAtDbField string
 
+	includeFields := []string{}
 	switch otpType {
 	case phoneChangeVerification:
 		token = &user.PhoneChangeToken
 		sentAt = user.PhoneChangeSentAt
-		tokenDbField, sentAtDbField = "phone_change_token", "phone_change_sent_at"
+		user.PhoneChange = phone
+		includeFields = append(includeFields, "phone_change", "phone_change_token", "phone_change_sent_at")
 	case phoneConfirmationOtp:
 		token = &user.ConfirmationToken
 		sentAt = user.ConfirmationSentAt
-		tokenDbField, sentAtDbField = "confirmation_token", "confirmation_sent_at"
+		includeFields = append(includeFields, "confirmation_token", "confirmation_sent_at")
 	case phoneReauthenticationOtp:
 		token = &user.ReauthenticationToken
 		sentAt = user.ReauthenticationSentAt
-		tokenDbField, sentAtDbField = "reauthentication_token", "reauthentication_sent_at"
+		includeFields = append(includeFields, "reauthentication_token", "reauthentication_sent_at")
 	default:
 		return internalServerError("invalid otp type")
 	}
@@ -101,5 +102,5 @@ func (a *API) sendPhoneConfirmation(ctx context.Context, tx *storage.Connection,
 		user.ReauthenticationSentAt = &now
 	}
 
-	return errors.Wrap(tx.UpdateOnly(user, tokenDbField, sentAtDbField), "Database error updating user for confirmation")
+	return errors.Wrap(tx.UpdateOnly(user, includeFields...), "Database error updating user for confirmation")
 }
diff --git a/api/reauthenticate.go b/api/reauthenticate.go
index 9310e827..3d8743c9 100644
--- a/api/reauthenticate.go
+++ b/api/reauthenticate.go
@@ -3,7 +3,6 @@ package api
 import (
 	"errors"
 	"net/http"
-	"time"
 
 	"github.com/gofrs/uuid"
 	"github.com/netlify/gotrue/api/sms_provider"
@@ -12,6 +11,8 @@ import (
 	"github.com/netlify/gotrue/storage"
 )
 
+const InvalidNonceMessage = "Nonce has expired or is invalid"
+
 // Reauthenticate sends a reauthentication otp to either the user's email or phone
 func (a *API) Reauthenticate(w http.ResponseWriter, r *http.Request) error {
 	ctx := r.Context()
@@ -59,7 +60,7 @@ func (a *API) Reauthenticate(w http.ResponseWriter, r *http.Request) error {
 			if terr != nil {
 				return badRequestError("Error sending sms: %v", terr)
 			}
-			return a.sendPhoneConfirmation(ctx, tx, user, phone, recoveryVerification, smsProvider)
+			return a.sendPhoneConfirmation(ctx, tx, user, phone, phoneReauthenticationOtp, smsProvider)
 		}
 		return nil
 	})
@@ -67,7 +68,7 @@ func (a *API) Reauthenticate(w http.ResponseWriter, r *http.Request) error {
 		if errors.Is(err, MaxFrequencyLimitError) {
 			return tooManyRequestsError("For security purposes, you can only request this once every 60 seconds")
 		}
-		return internalServerError("Reauthentication failed.").WithInternalError(err)
+		return err
 	}
 
 	return sendJSON(w, http.StatusOK, make(map[string]string))
@@ -76,20 +77,18 @@ func (a *API) Reauthenticate(w http.ResponseWriter, r *http.Request) error {
 // verifyReauthentication checks if the nonce provided is valid
 func (a *API) verifyReauthentication(nonce string, tx *storage.Connection, config *conf.Configuration, user *models.User) error {
 	if user.ReauthenticationToken == "" || user.ReauthenticationSentAt == nil {
-		return unauthorizedError("Requires reauthentication")
+		return badRequestError(InvalidNonceMessage)
 	}
 	var isValid bool
 	if user.GetEmail() != "" {
-		mailerOtpExpiresAt := time.Second * time.Duration(config.Mailer.OtpExp)
-		isValid = isOtpValid(nonce, user.ReauthenticationToken, user.ReauthenticationSentAt.Add(mailerOtpExpiresAt))
+		isValid = isOtpValid(nonce, user.ReauthenticationToken, user.ReauthenticationSentAt, config.Mailer.OtpExp)
 	} else if user.GetPhone() != "" {
-		smsOtpExpiresAt := time.Second * time.Duration(config.Sms.OtpExp)
-		isValid = isOtpValid(nonce, user.ReauthenticationToken, user.ReauthenticationSentAt.Add(smsOtpExpiresAt))
+		isValid = isOtpValid(nonce, user.ReauthenticationToken, user.ReauthenticationSentAt, config.Sms.OtpExp)
 	} else {
 		return unprocessableEntityError("Reauthentication requires an email or a phone number")
 	}
 	if !isValid {
-		return badRequestError("Nonce has expired or is invalid")
+		return badRequestError(InvalidNonceMessage)
 	}
 	if err := user.ConfirmReauthentication(tx); err != nil {
 		return internalServerError("Error during reauthentication").WithInternalError(err)
diff --git a/api/recover.go b/api/recover.go
index 4122f467..1701f5b2 100644
--- a/api/recover.go
+++ b/api/recover.go
@@ -5,7 +5,6 @@ import (
 	"errors"
 	"net/http"
 
-	"github.com/netlify/gotrue/api/sms_provider"
 	"github.com/netlify/gotrue/models"
 	"github.com/netlify/gotrue/storage"
 )
@@ -13,7 +12,6 @@ import (
 // RecoverParams holds the parameters for a password recovery request
 type RecoverParams struct {
 	Email string `json:"email"`
-	Phone string `json:"phone"`
 }
 
 // Recover sends a recovery email
@@ -28,25 +26,17 @@ func (a *API) Recover(w http.ResponseWriter, r *http.Request) error {
 		return badRequestError("Could not read verification params: %v", err)
 	}
 
-	if params.Email == "" && params.Phone == "" {
-		return unprocessableEntityError("Password recovery requires an email or a phone number")
+	if params.Email == "" {
+		return unprocessableEntityError("Password recovery requires an email")
 	}
 
 	var user *models.User
 	aud := a.requestAud(ctx, r)
 	recoverErrorMessage := "If a user exists, you will receive an email with instructions on how to reset your password."
-	if params.Email != "" {
-		if err := a.validateEmail(ctx, params.Email); err != nil {
-			return err
-		}
-		user, err = models.FindUserByEmailAndAudience(a.db, instanceID, params.Email, aud)
-	} else if params.Phone != "" {
-		params.Phone, err = a.validatePhone(params.Phone)
-		if err != nil {
-			return err
-		}
-		user, err = models.FindUserByPhoneAndAudience(a.db, instanceID, params.Phone, aud)
+	if err := a.validateEmail(ctx, params.Email); err != nil {
+		return err
 	}
+	user, err = models.FindUserByEmailAndAudience(a.db, instanceID, params.Email, aud)
 
 	if err != nil {
 		if models.IsNotFoundError(err) {
@@ -59,18 +49,9 @@ func (a *API) Recover(w http.ResponseWriter, r *http.Request) error {
 		if terr := models.NewAuditLogEntry(tx, instanceID, user, models.UserRecoveryRequestedAction, nil); terr != nil {
 			return terr
 		}
-		if params.Email != "" {
-			mailer := a.Mailer(ctx)
-			referrer := a.getReferrer(r)
-			return a.sendPasswordRecovery(tx, user, mailer, config.SMTP.MaxFrequency, referrer)
-		} else if params.Phone != "" {
-			smsProvider, terr := sms_provider.GetSmsProvider(*config)
-			if terr != nil {
-				return badRequestError("Error sending sms: %v", terr)
-			}
-			return a.sendPhoneConfirmation(ctx, tx, user, params.Phone, recoveryVerification, smsProvider)
-		}
-		return nil
+		mailer := a.Mailer(ctx)
+		referrer := a.getReferrer(r)
+		return a.sendPasswordRecovery(tx, user, mailer, config.SMTP.MaxFrequency, referrer)
 	})
 	if err != nil {
 		if errors.Is(err, MaxFrequencyLimitError) {
diff --git a/api/token.go b/api/token.go
index a2032182..16f0d396 100644
--- a/api/token.go
+++ b/api/token.go
@@ -11,7 +11,7 @@ import (
 	"time"
 
 	"github.com/coreos/go-oidc/v3/oidc"
-	jwt "github.com/golang-jwt/jwt"
+	"github.com/golang-jwt/jwt"
 	"github.com/netlify/gotrue/conf"
 	"github.com/netlify/gotrue/metering"
 	"github.com/netlify/gotrue/models"
@@ -348,8 +348,8 @@ func (a *API) IdTokenGrant(ctx context.Context, w http.ResponseWriter, r *http.R
 		return badRequestError("Could not read id token grant params: %v", err)
 	}
 
-	if params.IdToken == "" || params.Nonce == "" {
-		return oauthError("invalid request", "id_token and nonce required")
+	if params.IdToken == "" {
+		return oauthError("invalid request", "id_token required")
 	}
 
 	if params.Provider == "" && (params.ClientID == "" || params.Issuer == "") {
@@ -379,14 +379,17 @@ func (a *API) IdTokenGrant(ctx context.Context, w http.ResponseWriter, r *http.R
 		return err
 	}
 
-	// verify nonce to mitigate replay attacks
 	hashedNonce, ok := claims["nonce"]
-	if !ok {
-		return oauthError("invalid request", "missing nonce in id_token")
+	if (!ok && params.Nonce != "") || (ok && params.Nonce == "") {
+		return oauthError("invalid request", "Passed nonce and nonce in id_token should either both exist or not.")
 	}
-	hash := fmt.Sprintf("%x", sha256.Sum256([]byte(params.Nonce)))
-	if hash != hashedNonce.(string) {
-		return oauthError("invalid nonce", "").WithInternalMessage("Possible abuse attempt: %v", r)
+
+	if ok && params.Nonce != "" {
+		// verify nonce to mitigate replay attacks
+		hash := fmt.Sprintf("%x", sha256.Sum256([]byte(params.Nonce)))
+		if hash != hashedNonce.(string) {
+			return oauthError("invalid nonce", "").WithInternalMessage("Possible abuse attempt: %v", r)
+		}
 	}
 
 	sub, ok := claims["sub"].(string)
diff --git a/api/user.go b/api/user.go
index 1385acb1..389414b4 100644
--- a/api/user.go
+++ b/api/user.go
@@ -137,7 +137,7 @@ func (a *API) UserUpdate(w http.ResponseWriter, r *http.Request) error {
 			}
 		}
 
-		if params.Phone != "" {
+		if params.Phone != "" && params.Phone != user.GetPhone() {
 			params.Phone, err = a.validatePhone(params.Phone)
 			if err != nil {
 				return err
diff --git a/api/verify.go b/api/verify.go
index 844ef85b..bf1ba333 100644
--- a/api/verify.go
+++ b/api/verify.go
@@ -34,11 +34,13 @@ const (
 	singleConfirmation
 )
 
+// Only applicable when SECURE_EMAIL_CHANGE_ENABLED
+const singleConfirmationAccepted = "Confirmation link accepted. Please proceed to confirm link sent to the other email"
+
 // VerifyParams are the parameters the Verify endpoint accepts
 type VerifyParams struct {
 	Type       string `json:"type"`
 	Token      string `json:"token"`
-	Password   string `json:"password"`
 	Email      string `json:"email"`
 	Phone      string `json:"phone"`
 	RedirectTo string `json:"redirect_to"`
@@ -46,31 +48,23 @@ type VerifyParams struct {
 
 // Verify exchanges a confirmation or recovery token to a refresh token
 func (a *API) Verify(w http.ResponseWriter, r *http.Request) error {
-	ctx := r.Context()
-	config := a.getConfig(ctx)
-
-	params := &VerifyParams{}
-
 	switch r.Method {
-	// GET only supports signup type
-	case "GET":
-		params.Token = r.FormValue("token")
-		params.Password = ""
-		params.Type = r.FormValue("type")
-		params.RedirectTo = a.getRedirectURLOrReferrer(r, r.FormValue("redirect_to"))
-	case "POST":
-		jsonDecoder := json.NewDecoder(r.Body)
-		if err := jsonDecoder.Decode(params); err != nil {
-			return badRequestError("Could not read verification params: %v", err)
-		}
-		params.RedirectTo = a.getRedirectURLOrReferrer(r, params.RedirectTo)
+	case http.MethodGet:
+		return a.verifyGet(w, r)
+	case http.MethodPost:
+		return a.verifyPost(w, r)
 	default:
-		unprocessableEntityError("Only GET and POST methods are supported.")
+		return unprocessableEntityError("Only GET and POST methods are supported.")
 	}
+}
 
-	if params.Token == "" {
-		return unprocessableEntityError("Verify requires a token")
-	}
+func (a *API) verifyGet(w http.ResponseWriter, r *http.Request) error {
+	ctx := r.Context()
+	config := a.getConfig(ctx)
+	params := &VerifyParams{}
+	params.Token = r.FormValue("token")
+	params.Type = r.FormValue("type")
+	params.RedirectTo = a.getRedirectURLOrReferrer(r, r.FormValue("redirect_to"))
 
 	var (
 		user  *models.User
@@ -80,6 +74,12 @@ func (a *API) Verify(w http.ResponseWriter, r *http.Request) error {
 
 	err = a.db.Transaction(func(tx *storage.Connection) error {
 		var terr error
+		if params.Token == "" {
+			return badRequestError("Verify requires a token")
+		}
+		if params.Type == "" {
+			return badRequestError("Verify requires a verification type")
+		}
 		aud := a.requestAud(ctx, r)
 		user, terr = a.verifyUserAndToken(ctx, tx, params, aud)
 		if terr != nil {
@@ -95,14 +95,12 @@ func (a *API) Verify(w http.ResponseWriter, r *http.Request) error {
 			user, terr = a.emailChangeVerify(ctx, tx, params, user)
 			if user == nil && terr == nil {
 				// when double confirmation is required
-				rurl := a.prepRedirectURL("Confirmation link accepted. Please proceed to confirm link sent to the other email", params.RedirectTo)
+				rurl := a.prepRedirectURL(singleConfirmationAccepted, params.RedirectTo)
 				http.Redirect(w, r, rurl, http.StatusSeeOther)
 				return nil
 			}
-		case smsVerification, phoneChangeVerification:
-			user, terr = a.smsVerify(ctx, tx, user, params.Type)
 		default:
-			return unprocessableEntityError("Verify requires a verification type")
+			return unprocessableEntityError("Unsupported verification type")
 		}
 
 		if terr != nil {
@@ -121,41 +119,99 @@ func (a *API) Verify(w http.ResponseWriter, r *http.Request) error {
 	})
 
 	if err != nil {
-		if r.Method == http.MethodPost {
-			// do not redirect POST requests
-			return err
-		}
 		var herr *HTTPError
 		if errors.As(err, &herr) {
-			if errors.Is(herr.InternalError, redirectWithQueryError) {
-				rurl := a.prepErrorRedirectURL(herr, r, params.RedirectTo)
-				http.Redirect(w, r, rurl, http.StatusSeeOther)
-				return nil
-			}
+			rurl := a.prepErrorRedirectURL(herr, r, params.RedirectTo)
+			http.Redirect(w, r, rurl, http.StatusSeeOther)
+			return nil
 		}
 	}
 
-	// GET requests should return to the app site after confirmation
-	switch r.Method {
-	case "GET":
-		rurl := params.RedirectTo
-		if token != nil {
-			q := url.Values{}
-			q.Set("access_token", token.Token)
-			q.Set("token_type", token.TokenType)
-			q.Set("expires_in", strconv.Itoa(token.ExpiresIn))
-			q.Set("refresh_token", token.RefreshToken)
-			q.Set("type", params.Type)
-			rurl += "#" + q.Encode()
-		}
-		http.Redirect(w, r, rurl, http.StatusSeeOther)
-	case "POST":
-		return sendJSON(w, http.StatusOK, token)
+	rurl := params.RedirectTo
+	if token != nil {
+		q := url.Values{}
+		q.Set("access_token", token.Token)
+		q.Set("token_type", token.TokenType)
+		q.Set("expires_in", strconv.Itoa(token.ExpiresIn))
+		q.Set("refresh_token", token.RefreshToken)
+		q.Set("type", params.Type)
+		rurl += "#" + q.Encode()
 	}
-
+	http.Redirect(w, r, rurl, http.StatusSeeOther)
 	return nil
 }
 
+func (a *API) verifyPost(w http.ResponseWriter, r *http.Request) error {
+	ctx := r.Context()
+	config := a.getConfig(ctx)
+	params := &VerifyParams{}
+	jsonDecoder := json.NewDecoder(r.Body)
+	if err := jsonDecoder.Decode(params); err != nil {
+		return badRequestError("Could not read verification params: %v", err)
+	}
+
+	if params.Token == "" {
+		return badRequestError("Verify requires a token")
+	}
+
+	if params.Type == "" {
+		return badRequestError("Verify requires a verification type")
+	}
+
+	var (
+		user  *models.User
+		err   error
+		token *AccessTokenResponse
+	)
+
+	err = a.db.Transaction(func(tx *storage.Connection) error {
+		var terr error
+		aud := a.requestAud(ctx, r)
+		user, terr = a.verifyUserAndToken(ctx, tx, params, aud)
+		if terr != nil {
+			return terr
+		}
+
+		switch params.Type {
+		case signupVerification, inviteVerification:
+			user, terr = a.signupVerify(ctx, tx, user)
+		case recoveryVerification, magicLinkVerification:
+			user, terr = a.recoverVerify(ctx, tx, user)
+		case emailChangeVerification:
+			user, terr = a.emailChangeVerify(ctx, tx, params, user)
+			if user == nil && terr == nil {
+				return sendJSON(w, http.StatusOK, map[string]string{
+					"msg":  singleConfirmationAccepted,
+					"code": strconv.Itoa(http.StatusOK),
+				})
+			}
+		case smsVerification, phoneChangeVerification:
+			user, terr = a.smsVerify(ctx, tx, user, params.Type)
+		default:
+			return unprocessableEntityError("Unsupported verification type")
+		}
+
+		if terr != nil {
+			return terr
+		}
+
+		token, terr = a.issueRefreshToken(ctx, tx, user)
+		if terr != nil {
+			return terr
+		}
+
+		if terr = a.setCookieTokens(config, token, false, w); terr != nil {
+			return internalServerError("Failed to set JWT cookie. %s", terr)
+		}
+		return nil
+	})
+
+	if err != nil {
+		return err
+	}
+	return sendJSON(w, http.StatusOK, token)
+}
+
 func (a *API) signupVerify(ctx context.Context, conn *storage.Connection, user *models.User) (*models.User, error) {
 	instanceID := getInstanceID(ctx)
 	config := a.getConfig(ctx)
@@ -346,6 +402,8 @@ func (a *API) verifyUserAndToken(ctx context.Context, conn *storage.Connection,
 			user, err = models.FindUserByRecoveryToken(conn, params.Token)
 		case emailChangeVerification:
 			user, err = models.FindUserByEmailChangeToken(conn, params.Token)
+		default:
+			return nil, badRequestError("Invalid email verification type")
 		}
 	} else if isPhoneOtpVerification(params) {
 		if params.Phone == "" {
@@ -355,12 +413,24 @@ func (a *API) verifyUserAndToken(ctx context.Context, conn *storage.Connection,
 		if err != nil {
 			return nil, err
 		}
-		user, err = models.FindUserByPhoneAndAudience(conn, instanceID, params.Phone, aud)
+		switch params.Type {
+		case phoneChangeVerification:
+			user, err = models.FindUserByPhoneChangeAndAudience(conn, instanceID, params.Phone, aud)
+		case smsVerification:
+			user, err = models.FindUserByPhoneAndAudience(conn, instanceID, params.Phone, aud)
+		default:
+			return nil, badRequestError("Invalid sms verification type")
+		}
 	} else if isEmailOtpVerification(params) {
 		if err := a.validateEmail(ctx, params.Email); err != nil {
 			return nil, unprocessableEntityError("Invalid email format").WithInternalError(err)
 		}
-		user, err = models.FindUserByEmailAndAudience(conn, instanceID, params.Email, aud)
+		switch params.Type {
+		case emailChangeVerification:
+			user, err = models.FindUserForEmailChange(conn, instanceID, params.Email, params.Token, aud, config.Mailer.SecureEmailChangeEnabled)
+		default:
+			user, err = models.FindUserByEmailAndAudience(conn, instanceID, params.Email, aud)
+		}
 	} else {
 		return nil, badRequestError("Only an email address or phone number should be provided on verify, not both.")
 	}
@@ -377,25 +447,17 @@ func (a *API) verifyUserAndToken(ctx context.Context, conn *storage.Connection,
 	}
 
 	var isValid bool
-	mailerOtpExpiresAt := time.Second * time.Duration(config.Mailer.OtpExp)
-	smsOtpExpiresAt := time.Second * time.Duration(config.Sms.OtpExp)
 	switch params.Type {
 	case signupVerification, inviteVerification:
-		isValid = isOtpValid(params.Token, user.ConfirmationToken, user.ConfirmationSentAt.Add(mailerOtpExpiresAt))
+		isValid = isOtpValid(params.Token, user.ConfirmationToken, user.ConfirmationSentAt, config.Mailer.OtpExp)
 	case recoveryVerification, magicLinkVerification:
-		isValid = isOtpValid(params.Token, user.RecoveryToken, user.RecoverySentAt.Add(mailerOtpExpiresAt))
+		isValid = isOtpValid(params.Token, user.RecoveryToken, user.RecoverySentAt, config.Mailer.OtpExp)
 	case emailChangeVerification:
-		expiresAt := user.EmailChangeSentAt.Add(mailerOtpExpiresAt)
-		isValid = isOtpValid(params.Token, user.EmailChangeTokenCurrent, expiresAt) || isOtpValid(params.Token, user.EmailChangeTokenNew, expiresAt)
-		if !isValid {
-			// reset email confirmation status
-			user.EmailChangeConfirmStatus = zeroConfirmation
-			err = conn.UpdateOnly(user, "email_change_confirm_status")
-		}
+		isValid = isOtpValid(params.Token, user.EmailChangeTokenCurrent, user.EmailChangeSentAt, config.Mailer.OtpExp) || isOtpValid(params.Token, user.EmailChangeTokenNew, user.EmailChangeSentAt, config.Mailer.OtpExp)
 	case phoneChangeVerification:
-		isValid = isOtpValid(params.Token, user.PhoneChangeToken, user.PhoneChangeSentAt.Add(smsOtpExpiresAt))
+		isValid = isOtpValid(params.Token, user.PhoneChangeToken, user.PhoneChangeSentAt, config.Sms.OtpExp)
 	case smsVerification:
-		isValid = isOtpValid(params.Token, user.ConfirmationToken, user.ConfirmationSentAt.Add(smsOtpExpiresAt))
+		isValid = isOtpValid(params.Token, user.ConfirmationToken, user.ConfirmationSentAt, config.Sms.OtpExp)
 	}
 
 	if !isValid || err != nil {
@@ -405,7 +467,11 @@ func (a *API) verifyUserAndToken(ctx context.Context, conn *storage.Connection,
 }
 
 // isOtpValid checks the actual otp sent against the expected otp and ensures that it's within the valid window
-func isOtpValid(actual, expected string, expiresAt time.Time) bool {
+func isOtpValid(actual, expected string, sentAt *time.Time, otpExp uint) bool {
+	if expected == "" || sentAt == nil {
+		return false
+	}
+	expiresAt := sentAt.Add(time.Second * time.Duration(otpExp))
 	return time.Now().Before(expiresAt) && (actual == expected)
 }
 
diff --git a/conf/configuration.go b/conf/configuration.go
index 23aacf32..c99e840e 100644
--- a/conf/configuration.go
+++ b/conf/configuration.go
@@ -7,6 +7,7 @@ import (
 	"os"
 	"time"
 
+	"github.com/gobwas/glob"
 	"github.com/joho/godotenv"
 	"github.com/kelseyhightower/envconfig"
 )
@@ -74,6 +75,8 @@ type GlobalConfiguration struct {
 	SMTP               SMTPConfiguration
 	RateLimitHeader    string  `split_words:"true"`
 	RateLimitEmailSent float64 `split_words:"true" default:"30"`
+	RateLimitVerify    float64 `split_words:"true" default:"30"`
+	RateLimitToken     float64 `split_words:"true" default:"30"`
 }
 
 // EmailContentConfiguration holds the configuration for emails, both subjects and template URLs.
@@ -183,8 +186,9 @@ type SecurityConfiguration struct {
 
 // Configuration holds all the per-instance configuration.
 type Configuration struct {
-	SiteURL           string                   `json:"site_url" split_words:"true" required:"true"`
-	URIAllowList      []string                 `json:"uri_allow_list" split_words:"true"`
+	SiteURL           string   `json:"site_url" split_words:"true" required:"true"`
+	URIAllowList      []string `json:"uri_allow_list" split_words:"true"`
+	URIAllowListMap   map[string]glob.Glob
 	PasswordMinLength int                      `json:"password_min_length" split_words:"true"`
 	JWT               JWTConfiguration         `json:"jwt"`
 	SMTP              SMTPConfiguration        `json:"smtp"`
@@ -339,7 +343,13 @@ func (config *Configuration) ApplyDefaults() {
 	if config.URIAllowList == nil {
 		config.URIAllowList = []string{}
 	}
-
+	if config.URIAllowList != nil {
+		config.URIAllowListMap = make(map[string]glob.Glob)
+		for _, uri := range config.URIAllowList {
+			g := glob.MustCompile(uri, '.', '/')
+			config.URIAllowListMap[uri] = g
+		}
+	}
 	if config.PasswordMinLength < defaultMinPasswordLength {
 		config.PasswordMinLength = defaultMinPasswordLength
 	}
diff --git a/go.mod b/go.mod
index e8130727..c4c055d1 100644
--- a/go.mod
+++ b/go.mod
@@ -19,6 +19,7 @@ require (
 	github.com/gobuffalo/plush/v4 v4.1.0 // indirect
 	github.com/gobuffalo/pop/v5 v5.3.3
 	github.com/gobuffalo/validate/v3 v3.3.0 // indirect
+	github.com/gobwas/glob v0.2.3 // indirect
 	github.com/gofrs/uuid v4.0.0+incompatible
 	github.com/golang-jwt/jwt v3.2.1+incompatible
 	github.com/gorilla/securecookie v1.1.1
diff --git a/go.sum b/go.sum
index f2482446..9e930f3d 100644
--- a/go.sum
+++ b/go.sum
@@ -169,6 +169,8 @@ github.com/gobuffalo/validate/v3 v3.0.0/go.mod h1:HFpjq+AIiA2RHoQnQVTFKF/ZpUPXwy
 github.com/gobuffalo/validate/v3 v3.1.0/go.mod h1:HFpjq+AIiA2RHoQnQVTFKF/ZpUPXwyw82LgyDPxQ9r0=
 github.com/gobuffalo/validate/v3 v3.3.0 h1:j++FFx9gtjTmIQeI9xlaIDZ0nV4x8YQZz4RJAlZNUxg=
 github.com/gobuffalo/validate/v3 v3.3.0/go.mod h1:HFpjq+AIiA2RHoQnQVTFKF/ZpUPXwyw82LgyDPxQ9r0=
+github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y=
+github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8=
 github.com/gofrs/uuid v3.2.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
 github.com/gofrs/uuid v4.0.0+incompatible h1:1SD/1F5pU8p29ybwgQSwpQk+mwdRrXCYuPhW6m+TnJw=
 github.com/gofrs/uuid v4.0.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
diff --git a/mailer/template.go b/mailer/template.go
index 64673edb..3380ced7 100644
--- a/mailer/template.go
+++ b/mailer/template.go
@@ -153,7 +153,7 @@ func (m *TemplateMailer) EmailChangeMail(user *models.User, referrerURL string)
 			Address:  user.EmailChange,
 			Token:    user.EmailChangeTokenNew,
 			Subject:  string(withDefault(m.Config.Mailer.Subjects.EmailChange, "Confirm Email Change")),
-			Template: m.Config.Mailer.Templates.Confirmation,
+			Template: m.Config.Mailer.Templates.EmailChange,
 		},
 	}
 
diff --git a/models/user.go b/models/user.go
index 09de86ec..7af1e4d0 100644
--- a/models/user.go
+++ b/models/user.go
@@ -444,9 +444,39 @@ func FindUsersInAudience(tx *storage.Connection, instanceID uuid.UUID, aud strin
 	return users, err
 }
 
-// FindUserWithPhoneAndPhoneChangeToken finds a user with the matching phone and phone change token
-func FindUserWithPhoneAndPhoneChangeToken(tx *storage.Connection, phone, token string) (*User, error) {
-	return findUser(tx, "phone = ? and phone_change_token = ?", phone, token)
+// FindUserByEmailChangeCurrentAndAudience finds a user with the matching email change and audience.
+func FindUserByEmailChangeCurrentAndAudience(tx *storage.Connection, instanceID uuid.UUID, email, token, aud string) (*User, error) {
+	return findUser(
+		tx,
+		"instance_id = ? and LOWER(email) = ? and email_change_token_current = ? and aud = ?",
+		instanceID, strings.ToLower(email), token, aud,
+	)
+}
+
+// FindUserByEmailChangeNewAndAudience finds a user with the matching email change and audience.
+func FindUserByEmailChangeNewAndAudience(tx *storage.Connection, instanceID uuid.UUID, email, token, aud string) (*User, error) {
+	return findUser(
+		tx,
+		"instance_id = ? and LOWER(email_change) = ? and email_change_token_new = ? and aud = ?",
+		instanceID, strings.ToLower(email), token, aud,
+	)
+}
+
+// FindUserForEmailChange finds a user requesting for an email change
+func FindUserForEmailChange(tx *storage.Connection, instanceID uuid.UUID, email, token, aud string, secureEmailChangeEnabled bool) (*User, error) {
+	if secureEmailChangeEnabled {
+		if user, err := FindUserByEmailChangeCurrentAndAudience(tx, instanceID, email, token, aud); err == nil {
+			return user, err
+		} else if !IsNotFoundError(err) {
+			return nil, err
+		}
+	}
+	return FindUserByEmailChangeNewAndAudience(tx, instanceID, email, token, aud)
+}
+
+// FindUserByPhoneChangeAndAudience finds a user with the matching phone change and audience.
+func FindUserByPhoneChangeAndAudience(tx *storage.Connection, instanceID uuid.UUID, phone, aud string) (*User, error) {
+	return findUser(tx, "instance_id = ? and phone_change = ? and aud = ?", instanceID, phone, aud)
 }
 
 // IsDuplicatedEmail returns whether a user exists with a matching email and audience.


Test patch:
diff --git a/api/phone_test.go b/api/phone_test.go
index 9eda8027..878e6256 100644
--- a/api/phone_test.go
+++ b/api/phone_test.go
@@ -1,8 +1,15 @@
 package api
 
 import (
+	"bytes"
 	"context"
+	"encoding/json"
+	"fmt"
+	"net/http"
+	"net/http/httptest"
+	"strings"
 	"testing"
+	"time"
 
 	"github.com/gofrs/uuid"
 	"github.com/netlify/gotrue/conf"
@@ -117,3 +124,103 @@ func (ts *PhoneTestSuite) TestSendPhoneConfirmation() {
 		})
 	}
 }
+
+func (ts *PhoneTestSuite) TestMissingSmsProviderConfig() {
+	u, err := models.FindUserByPhoneAndAudience(ts.API.db, ts.instanceID, "123456789", ts.Config.JWT.Aud)
+	require.NoError(ts.T(), err)
+	now := time.Now()
+	u.PhoneConfirmedAt = &now
+	require.NoError(ts.T(), ts.API.db.Update(u), "Error updating new test user")
+
+	token, err := generateAccessToken(u, time.Second*time.Duration(ts.Config.JWT.Exp), ts.Config.JWT.Secret)
+	require.NoError(ts.T(), err)
+
+	cases := []struct {
+		desc     string
+		endpoint string
+		method   string
+		header   string
+		body     map[string]string
+		expected map[string]interface{}
+	}{
+		{
+			"Signup",
+			"/signup",
+			http.MethodPost,
+			"",
+			map[string]string{
+				"phone":    "1234567890",
+				"password": "testpassword",
+			},
+			map[string]interface{}{
+				"code":    http.StatusBadRequest,
+				"message": "Error sending confirmation sms:",
+			},
+		},
+		{
+			"Sms OTP",
+			"/otp",
+			http.MethodPost,
+			"",
+			map[string]string{
+				"phone": "123456789",
+			},
+			map[string]interface{}{
+				"code":    http.StatusBadRequest,
+				"message": "Error sending sms:",
+			},
+		},
+		{
+			"Phone change",
+			"/user",
+			http.MethodPut,
+			token,
+			map[string]string{
+				"phone": "111111111",
+			},
+			map[string]interface{}{
+				"code":    http.StatusBadRequest,
+				"message": "Error sending sms:",
+			},
+		},
+		{
+			"Reauthenticate",
+			"/reauthenticate",
+			http.MethodGet,
+			"",
+			nil,
+			map[string]interface{}{
+				"code":    http.StatusBadRequest,
+				"message": "Error sending sms:",
+			},
+		},
+	}
+
+	smsProviders := []string{"twilio", "messagebird", "textlocal", "vonage"}
+	ts.Config.External.Phone.Enabled = true
+	ts.Config.Sms.Twilio.AccountSid = ""
+	ts.Config.Sms.Messagebird.AccessKey = ""
+	ts.Config.Sms.Textlocal.ApiKey = ""
+	ts.Config.Sms.Vonage.ApiKey = ""
+	for _, c := range cases {
+		for _, provider := range smsProviders {
+			ts.Config.Sms.Provider = provider
+			desc := fmt.Sprintf("[%v] %v", provider, c.desc)
+			ts.Run(desc, func() {
+				var buffer bytes.Buffer
+				require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(c.body))
+
+				req := httptest.NewRequest(c.method, "http://localhost"+c.endpoint, &buffer)
+				req.Header.Set("Content-Type", "application/json")
+				req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token))
+
+				w := httptest.NewRecorder()
+				ts.API.handler.ServeHTTP(w, req)
+				require.Equal(ts.T(), c.expected["code"], w.Code)
+
+				body := w.Body.String()
+				require.True(ts.T(), strings.Contains(body, c.expected["message"].(string)))
+			})
+		}
+	}
+}
diff --git a/api/user_test.go b/api/user_test.go
index 585799c7..8acc6b6a 100644
--- a/api/user_test.go
+++ b/api/user_test.go
@@ -138,6 +138,11 @@ func (ts *UserTestSuite) TestUserUpdatePhoneAutoconfirmEnabled() {
 	u, err := models.FindUserByEmailAndAudience(ts.API.db, ts.instanceID, "test@example.com", ts.Config.JWT.Aud)
 	require.NoError(ts.T(), err)
 
+	existingUser, err := models.NewUser(ts.instanceID, "", "", ts.Config.JWT.Aud, nil)
+	existingUser.Phone = "22222222"
+	require.NoError(ts.T(), err)
+	require.NoError(ts.T(), ts.API.db.Create(existingUser))
+
 	cases := []struct {
 		desc         string
 		userData     map[string]string
@@ -148,6 +153,13 @@ func (ts *UserTestSuite) TestUserUpdatePhoneAutoconfirmEnabled() {
 			map[string]string{
 				"phone": "123456789",
 			},
+			http.StatusOK,
+		},
+		{
+			"New phone number exists already",
+			map[string]string{
+				"phone": "22222222",
+			},
 			http.StatusUnprocessableEntity,
 		},
 		{
@@ -224,7 +236,7 @@ func (ts *UserTestSuite) TestUserUpdatePassword() {
 			"newpassword123",
 			"123456",
 			true,
-			expected{code: http.StatusUnauthorized, isAuthenticated: false},
+			expected{code: http.StatusBadRequest, isAuthenticated: false},
 		},
 	}
 
@@ -244,7 +256,7 @@ func (ts *UserTestSuite) TestUserUpdatePassword() {
 			// Setup response recorder
 			w := httptest.NewRecorder()
 			ts.API.handler.ServeHTTP(w, req)
-			require.Equal(ts.T(), w.Code, c.expected.code)
+			require.Equal(ts.T(), c.expected.code, w.Code)
 
 			// Request body
 			u, err = models.FindUserByEmailAndAudience(ts.API.db, ts.instanceID, "test@example.com", ts.Config.JWT.Aud)
diff --git a/api/verify_test.go b/api/verify_test.go
index 597fbd3b..896a0f1b 100644
--- a/api/verify_test.go
+++ b/api/verify_test.go
@@ -141,7 +141,11 @@ func (ts *VerifyTestSuite) TestVerifySecureEmailChange() {
 
 	w = httptest.NewRecorder()
 	ts.API.handler.ServeHTTP(w, req)
-	assert.Equal(ts.T(), http.StatusSeeOther, w.Code)
+
+	data := make(map[string]interface{})
+	require.Equal(ts.T(), http.StatusOK, w.Code)
+	require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&data))
+	require.Equal(ts.T(), singleConfirmationAccepted, data["msg"])
 
 	u, err = models.FindUserByEmailAndAudience(ts.API.db, ts.instanceID, "test@example.com", ts.Config.JWT.Aud)
 	require.NoError(ts.T(), err)
@@ -158,12 +162,18 @@ func (ts *VerifyTestSuite) TestVerifySecureEmailChange() {
 
 	w = httptest.NewRecorder()
 	ts.API.handler.ServeHTTP(w, req)
-	assert.Equal(ts.T(), http.StatusOK, w.Code)
+	data = make(map[string]interface{})
+	require.Equal(ts.T(), http.StatusOK, w.Code)
+	require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&data))
+	require.NotNil(ts.T(), data["access_token"])
+	require.NotNil(ts.T(), data["expires_in"])
+	require.NotNil(ts.T(), data["refresh_token"])
+	require.NotNil(ts.T(), data["user"])
 
 	// user's email should've been updated to new@example.com
 	u, err = models.FindUserByEmailAndAudience(ts.API.db, ts.instanceID, "new@example.com", ts.Config.JWT.Aud)
 	require.NoError(ts.T(), err)
-	assert.Equal(ts.T(), zeroConfirmation, u.EmailChangeConfirmStatus)
+	require.Equal(ts.T(), zeroConfirmation, u.EmailChangeConfirmStatus)
 }
 
 func (ts *VerifyTestSuite) TestExpiredConfirmationToken() {
@@ -184,18 +194,25 @@ func (ts *VerifyTestSuite) TestExpiredConfirmationToken() {
 	ts.API.handler.ServeHTTP(w, req)
 	assert.Equal(ts.T(), http.StatusSeeOther, w.Code)
 
-	url, err := w.Result().Location()
+	rurl, err := url.Parse(w.Header().Get("Location"))
+	require.NoError(ts.T(), err, "redirect url parse failed")
+
+	f, err := url.ParseQuery(rurl.Fragment)
 	require.NoError(ts.T(), err)
-	assert.Equal(ts.T(), "error_code=410&error_description=Token+has+expired+or+is+invalid", url.Fragment)
+	fmt.Println(f)
+	assert.Equal(ts.T(), "401", f.Get("error_code"))
+	assert.Equal(ts.T(), "Token has expired or is invalid", f.Get("error_description"))
+	assert.Equal(ts.T(), "unauthorized_client", f.Get("error"))
 }
 
 func (ts *VerifyTestSuite) TestInvalidOtp() {
 	u, err := models.FindUserByPhoneAndAudience(ts.API.db, ts.instanceID, "12345678", ts.Config.JWT.Aud)
 	require.NoError(ts.T(), err)
-	u.ConfirmationToken = "123456"
-	u.PhoneChangeToken = "123456"
 	sentTime := time.Now().Add(-48 * time.Hour)
+	u.ConfirmationToken = "123456"
 	u.ConfirmationSentAt = &sentTime
+	u.PhoneChange = "22222222"
+	u.PhoneChangeToken = "123456"
 	u.PhoneChangeSentAt = &sentTime
 	require.NoError(ts.T(), ts.API.db.Update(u))
 
@@ -205,7 +222,7 @@ func (ts *VerifyTestSuite) TestInvalidOtp() {
 	}
 
 	expectedResponse := ResponseBody{
-		Code: http.StatusGone,
+		Code: http.StatusUnauthorized,
 		Msg:  "Token has expired or is invalid",
 	}
 
@@ -241,7 +258,7 @@ func (ts *VerifyTestSuite) TestInvalidOtp() {
 			body: map[string]interface{}{
 				"type":  phoneChangeVerification,
 				"token": "invalid_otp",
-				"phone": u.GetPhone(),
+				"phone": u.PhoneChange,
 			},
 			expected: expectedResponse,
 		},
@@ -423,6 +440,69 @@ func (ts *VerifyTestSuite) TestVerifySignupWithredirectURLContainedPath() {
 			requestredirectURL:  "http://localhost:3000/docs",
 			expectedredirectURL: "https://someapp-something.codemagic.app/#/",
 		},
+		{
+			desc:                "same wildcard site url and redirect url in allow list",
+			siteURL:             "http://sub.test.dev:3000/#/",
+			uriAllowList:        []string{"http://*.test.dev:3000"},
+			requestredirectURL:  "http://sub.test.dev:3000/#/",
+			expectedredirectURL: "http://sub.test.dev:3000/#/",
+		},
+		{
+			desc:                "different wildcard site url and redirect url in allow list",
+			siteURL:             "http://sub.test.dev/#/",
+			uriAllowList:        []string{"http://*.other.dev:3000"},
+			requestredirectURL:  "http://sub.other.dev:3000",
+			expectedredirectURL: "http://sub.other.dev:3000",
+		},
+		{
+			desc:                "different wildcard site url and redirect url not in allow list",
+			siteURL:             "http://test.dev:3000/#/",
+			uriAllowList:        []string{"http://*.allowed.dev:3000"},
+			requestredirectURL:  "http://sub.test.dev:3000/#/",
+			expectedredirectURL: "http://test.dev:3000/#/",
+		},
+		{
+			desc:                "exact mobile deep link redirect url in allow list",
+			siteURL:             "http://test.dev:3000/#/",
+			uriAllowList:        []string{"twitter://timeline"},
+			requestredirectURL:  "twitter://timeline",
+			expectedredirectURL: "twitter://timeline",
+		},
+		{
+			desc:                "wildcard mobile deep link redirect url in allow list",
+			siteURL:             "http://test.dev:3000/#/",
+			uriAllowList:        []string{"com.mobile.*"},
+			requestredirectURL:  "com.mobile.app",
+			expectedredirectURL: "http://test.dev:3000/#/",
+		},
+		{
+			desc:                "redirect respects . separator",
+			siteURL:             "http://localhost:3000",
+			uriAllowList:        []string{"http://*.*.dev:3000"},
+			requestredirectURL:  "http://foo.bar.dev:3000",
+			expectedredirectURL: "http://foo.bar.dev:3000",
+		},
+		{
+			desc:                "redirect does not respect . separator",
+			siteURL:             "http://localhost:3000",
+			uriAllowList:        []string{"http://*.dev:3000"},
+			requestredirectURL:  "http://foo.bar.dev:3000",
+			expectedredirectURL: "http://localhost:3000",
+		},
+		{
+			desc:                "redirect respects / separator in url subdirectory",
+			siteURL:             "http://localhost:3000",
+			uriAllowList:        []string{"http://test.dev:3000/*/*"},
+			requestredirectURL:  "http://test.dev:3000/bar/foo",
+			expectedredirectURL: "http://test.dev:3000/bar/foo",
+		},
+		{
+			desc:                "redirect does not respect / separator in url subdirectory",
+			siteURL:             "http://localhost:3000",
+			uriAllowList:        []string{"http://test.dev:3000/*"},
+			requestredirectURL:  "http://test.dev:3000/bar/foo",
+			expectedredirectURL: "http://localhost:3000",
+		},
 	}
 
 	for _, tC := range testCases {
@@ -431,6 +511,7 @@ func (ts *VerifyTestSuite) TestVerifySignupWithredirectURLContainedPath() {
 			ts.Config.SiteURL = tC.siteURL
 			redirectURL := tC.requestredirectURL
 			ts.Config.URIAllowList = tC.uriAllowList
+			ts.Config.ApplyDefaults()
 
 			// set verify token to user as it actual do in magic link method
 			u, err := models.FindUserByEmailAndAudience(ts.API.db, ts.instanceID, "test@example.com", ts.Config.JWT.Aud)
@@ -490,14 +571,6 @@ func (ts *VerifyTestSuite) TestVerifyBannedUser() {
 				Token: u.ConfirmationToken,
 			},
 		},
-		{
-			"Verify banned phone user on sms",
-			&VerifyParams{
-				Type:  "sms",
-				Token: u.ConfirmationToken,
-				Phone: u.GetPhone(),
-			},
-		},
 		{
 			"Verify banned user on recover",
 			&VerifyParams{
@@ -526,16 +599,20 @@ func (ts *VerifyTestSuite) TestVerifyBannedUser() {
 			var buffer bytes.Buffer
 			require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(c.payload))
 
-			req := httptest.NewRequest(http.MethodPost, "http://localhost/verify", &buffer)
+			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.StatusUnauthorized, w.Code)
+			assert.Equal(ts.T(), http.StatusSeeOther, w.Code)
 
-			b, err := ioutil.ReadAll(w.Body)
+			rurl, err := url.Parse(w.Header().Get("Location"))
+			require.NoError(ts.T(), err, "redirect url parse failed")
+
+			f, err := url.ParseQuery(rurl.Fragment)
 			require.NoError(ts.T(), err)
-			assert.Equal(ts.T(), "{\"code\":401,\"msg\":\"Error confirming user\"}", string(b))
+			assert.Equal(ts.T(), "401", f.Get("error_code"))
 		})
 	}
 }
@@ -544,6 +621,9 @@ func (ts *VerifyTestSuite) TestVerifyValidOtp() {
 	u, err := models.FindUserByEmailAndAudience(ts.API.db, ts.instanceID, "test@example.com", ts.Config.JWT.Aud)
 	require.NoError(ts.T(), err)
 
+	u.PhoneChange = "1234567890"
+	require.NoError(ts.T(), ts.API.db.Update(u))
+
 	type expected struct {
 		code int
 	}
@@ -596,9 +676,7 @@ func (ts *VerifyTestSuite) TestVerifyValidOtp() {
 				"token": "123456",
 				"email": u.GetEmail(),
 			},
-			expected: expected{
-				code: http.StatusSeeOther,
-			},
+			expected: expectedResponse,
 		},
 		{
 			desc:     "Valid Phone Change OTP",
@@ -606,7 +684,7 @@ func (ts *VerifyTestSuite) TestVerifyValidOtp() {
 			body: map[string]interface{}{
 				"type":  phoneChangeVerification,
 				"token": "123456",
-				"phone": "12345678",
+				"phone": u.PhoneChange,
 			},
 			expected: expectedResponse,
 		},
diff --git a/hack/test.env b/hack/test.env
index 80bab410..d0137c9c 100644
--- a/hack/test.env
+++ b/hack/test.env
@@ -86,6 +86,7 @@ GOTRUE_EXTERNAL_SAML_ENABLED=true
 GOTRUE_EXTERNAL_SAML_METADATA_URL=
 GOTRUE_EXTERNAL_SAML_API_BASE=http://localhost
 GOTRUE_EXTERNAL_SAML_NAME=TestSamlName
+GOTRUE_RATE_LIMIT_VERIFY="1000"
 GOTRUE_TRACING_ENABLED=false
 GOTRUE_TRACING_HOST=127.0.0.1
 GOTRUE_TRACING_PORT=8126