Repository: supabase/gotrue
Pull request #441 (2022-04-06 12:43:35)

Add configurable hcaptcha timeout
## What kind of change does this PR introduce?

Fix #473

## What is the current behavior?

default timeout to 10 seconds

## What is the new behavior?

add timeout which can be configurable via env var

## Additional context

NA



Patch:
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 3003d492..b196dbe2 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -7,6 +7,48 @@ Docs aren't perfect and so we're here to help. If you're stuck on setup for more
 
 Please help us keep all our projects open and inclusive. Kindly follow our [Code of Conduct](<(CODE_OF_CONDUCT.md)>) to keep the ecosystem healthy and friendly for all.
 
+## Quick Start
+
+GoTrue has a development container setup that makes it easy to get started contributing. This setup only requires that [Docker](https://www.docker.com/get-started) is setup on your system. The development container setup includes a PostgreSQL container with migrations already applied and a container running GoTrue that will perform a hot reload when changes to the source code are detected.
+
+If you would like to run GoTrue locally or learn more about what these containers are doing for you, continue reading the [Setup and Tooling](#setup-and-tooling) section below. Otherwise, you can skip ahead to the [How To Verify that GoTrue is Available](#how-to-verify-that-gotrue-is-available) section to learn about working with and developing GoTrue.
+
+Before using the containers, you will need to make sure an `.env.docker` file exists by making a copy of `example.docker.env` and configuring it for your needs. The set of env vars in `example.docker.env` only contain the necessary env vars for gotrue to start in a docker environment. For the full list of env vars, please refer to `example.env` and copy over the necessary ones into your `.env.docker` file.
+
+The following are some basic commands. A full and up to date list of commands can be found in the project's `Makefile` or by running `make help`.
+
+### Starting the containers
+
+Start the containers as described above in an attached state with log output.
+
+``` bash
+make dev
+```
+
+### Running tests in the containers
+
+Start the containers with a fresh database and run the project's tests.
+
+``` bash
+make docker-test
+```
+
+### Removing the containers
+
+Remove both containers and their volumes. This removes any data associated with the containers.
+
+``` bash
+make docker-clean
+```
+
+### Rebuild the containers
+
+Fully rebuild the containers without using any cached layers.
+
+``` bash
+make docker-build
+```
+
 ## Setup and Tooling
 
 GoTrue -- as the name implies -- is a user registration and authentication API developed in [Go](https://go.dev).
diff --git a/Dockerfile.dev b/Dockerfile.dev
new file mode 100644
index 00000000..de4e0779
--- /dev/null
+++ b/Dockerfile.dev
@@ -0,0 +1,18 @@
+FROM golang:1.17-alpine
+ENV GO111MODULE=on
+ENV CGO_ENABLED=0
+ENV GOOS=linux
+
+RUN apk add --no-cache make git bash
+
+WORKDIR /go/src/github.com/netlify/gotrue
+
+# Pulling dependencies
+COPY ./Makefile ./go.* ./
+
+# Production dependencies
+RUN make deps
+
+# Development dependences
+RUN go get github.com/githubnemo/CompileDaemon
+RUN go install github.com/githubnemo/CompileDaemon
diff --git a/Makefile b/Makefile
index c59f4fb4..9ff09419 100644
--- a/Makefile
+++ b/Makefile
@@ -1,6 +1,7 @@
 .PHONY: all build deps image lint migrate test vet
 CHECK_FILES?=$$(go list ./... | grep -v /vendor/)
 FLAGS?=-ldflags "-X github.com/netlify/gotrue/cmd.Version=`git describe --tags`"
+DEV_DOCKER_COMPOSE:=docker-compose-dev.yml
 
 help: ## Show this help.
 	@awk 'BEGIN {FS = ":.*?## "} /^[a-zA-Z_-]+:.*?## / {sub("\\\\n",sprintf("\n%22c"," "), $$2);printf "\033[36m%-20s\033[0m %s\n", $$1, $$2}' $(MAKEFILE_LIST)
@@ -12,13 +13,10 @@ build: ## Build the binary.
 	GOOS=linux GOARCH=arm64 go build $(FLAGS) -o gotrue-arm64
 
 deps: ## Install dependencies.
-	@go install github.com/gobuffalo/pop/soda@latest	
+	@go install github.com/gobuffalo/pop/soda@latest
 	@go install golang.org/x/lint/golint@latest
 	@go mod download
 
-image: ## Build the Docker image.
-	docker build .
-
 lint: ## Lint the code.
 	golint $(CHECK_FILES)
 
@@ -33,3 +31,25 @@ test: ## Run tests.
 
 vet: # Vet the code
 	go vet $(CHECK_FILES)
+
+dev: ## Run the development containers
+	docker-compose -f $(DEV_DOCKER_COMPOSE) up
+
+down: ## Shutdown the development containers
+	# Start postgres first and apply migrations
+	docker-compose -f $(DEV_DOCKER_COMPOSE) down
+
+docker-test: ## Run the tests using the development containers
+	docker-compose -f $(DEV_DOCKER_COMPOSE) up -d postgres
+	docker-compose -f $(DEV_DOCKER_COMPOSE) run gotrue sh -c "make migrate_test"
+	docker-compose -f $(DEV_DOCKER_COMPOSE) run gotrue sh -c "make test"
+	docker-compose -f $(DEV_DOCKER_COMPOSE) down -v
+
+docker-build: ## Force a full rebuild of the development containers
+	docker-compose -f $(DEV_DOCKER_COMPOSE) build --no-cache
+	docker-compose -f $(DEV_DOCKER_COMPOSE) up -d postgres
+	docker-compose -f $(DEV_DOCKER_COMPOSE) run gotrue sh -c "make migrate_dev"
+	docker-compose -f $(DEV_DOCKER_COMPOSE) down
+
+docker-clean: ## Remove the development containers and volumes
+	docker-compose -f $(DEV_DOCKER_COMPOSE) rm -fsv
diff --git a/README.md b/README.md
index 21897503..e5471f50 100644
--- a/README.md
+++ b/README.md
@@ -16,7 +16,15 @@ Create a `.env` file to store your own custom env vars. See [`example.env`](exam
 go build -ldflags "-X github.com/supabase/gotrue/cmd.Version=`git rev-parse HEAD`"
 GOOS=linux GOARCH=arm64 go build -ldflags "-X github.com/supabase/gotrue/cmd.Version=`git rev-parse HEAD`" -o gotrue-arm64
 ```
-3. Execute the gotrue binary: `./gotrue` (if you're on x86) `./gotrue-arm64` (if you're on arm)
+3. Execute the gotrue binary: `./gotrue`
+
+### If you have docker installed...
+Create a `.env.docker` file to store your own custom env vars. See [`example.docker.env`](example.docker.env)
+
+1. `make build`
+2. `make dev`
+3. `docker ps` should show 2 docker containers (`gotrue_postgresql` and `gotrue_gotrue`)
+4. That's it! Visit the [health checkendpoint](http://localhost:9999/health) to confirm that gotrue is running. 
 
 ## Configuration
 
@@ -35,7 +43,9 @@ 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://foo.example.com,https://*.foo.example.com,https://bar.example.com"`) which are permitted as valid `redirect_to` destinations. Defaults to []. Supports wildcard matching through globbing. e.g. `https://*.foo.example.com` will allow `https://a.foo.example.com` and `https://b.foo.example.com` to be accepted. Globbing is also supported on subdomains. e.g. `https://foo.example.com/*` will allow `https://foo.example.com/page1` and `https://foo.example.com/page2` to be accepted.
+
+For more common glob patterns, check out the [following link](https://pkg.go.dev/github.com/gobwas/glob#Compile).
 
 `OPERATOR_TOKEN` - `string` _Multi-instance mode only_
 
@@ -66,6 +76,15 @@ Rate limit the number of emails sent per hr on the following endpoints: `/signup
 
 Minimum password length, defaults to 6.
 
+`GOTRUE_SECURITY_REFRESH_TOKEN_ROTATION_ENABLED` - `bool`
+
+If refresh token rotation is enabled, gotrue will automatically detect malicious attempts to reuse a revoked refresh token. When a malicious attempt is detected, gotrue immediately revokes all tokens that descended from the offending token.
+
+`GOTRUE_SECURITY_REFRESH_TOKEN_REUSE_INTERVAL` - `string`
+
+This setting is only applicable if `GOTRUE_SECURITY_REFRESH_TOKEN_ROTATION_ENABLED` is enabled. The reuse interval for a refresh token allows for exchanging the refresh token multiple times during the interval to support concurrency or offline issues. During the reuse interval, gotrue will not consider using a revoked token as a malicious attempt and will simply return the child refresh token. 
+
+Only the previous revoked token can be reused. Using an old refresh token way before the current valid refresh token will trigger the reuse detection. 
 ### API
 
 ```properties
@@ -490,7 +509,8 @@ Whether captcha middleware is enabled
 
 for now the only option supported is: `hcaptcha`
 
-`SECURITY_CAPTCHA_SECRET` - `string`
+- `SECURITY_CAPTCHA_SECRET` - `string`
+- `SECURITY_CAPTCHA_TIMEOUT` - `string`
 
 Retrieve from hcaptcha account
 
diff --git a/api/api.go b/api/api.go
index 5a0bbf83..cb98b37f 100644
--- a/api/api.go
+++ b/api/api.go
@@ -124,7 +124,7 @@ func NewAPIWithVersion(ctx context.Context, globalConfig *conf.GlobalConfigurati
 
 		r.With(api.limitHandler(
 			// Allow requests at the specified rate per 5 minutes.
-			tollbooth.NewLimiter(api.config.RateLimitToken/(60*5), &limiter.ExpirableOptions{
+			tollbooth.NewLimiter(api.config.RateLimitTokenRefresh/(60*5), &limiter.ExpirableOptions{
 				DefaultExpirationTTL: time.Hour,
 			}).SetBurst(30),
 		)).Post("/token", api.Token)
diff --git a/api/errors.go b/api/errors.go
index 26dd37f0..335dc4e7 100644
--- a/api/errors.go
+++ b/api/errors.go
@@ -8,6 +8,7 @@ import (
 	"runtime/debug"
 
 	"github.com/netlify/gotrue/conf"
+	"github.com/netlify/gotrue/utilities"
 	"github.com/pkg/errors"
 )
 
@@ -249,6 +250,15 @@ func handleError(err error, w http.ResponseWriter, r *http.Request) {
 		} else {
 			log.WithError(e.Cause()).Info(e.Error())
 		}
+
+		// Provide better error messages for certain user-triggered Postgres errors.
+		if pgErr := utilities.NewPostgresError(e.InternalError); pgErr != nil {
+			if jsonErr := sendJSON(w, pgErr.HttpStatusCode, pgErr); jsonErr != nil {
+				handleError(jsonErr, w, r)
+			}
+			return
+		}
+
 		if jsonErr := sendJSON(w, e.Code, e); jsonErr != nil {
 			handleError(jsonErr, w, r)
 		}
diff --git a/api/external.go b/api/external.go
index b2f2d35a..ad25b711 100644
--- a/api/external.go
+++ b/api/external.go
@@ -16,6 +16,7 @@ import (
 	"github.com/netlify/gotrue/conf"
 	"github.com/netlify/gotrue/models"
 	"github.com/netlify/gotrue/storage"
+	"github.com/netlify/gotrue/utilities"
 	"github.com/sirupsen/logrus"
 )
 
@@ -465,8 +466,18 @@ func getErrorQueryString(err error, errorID string, log logrus.FieldLogger) *url
 	case ErrorCause:
 		return getErrorQueryString(e.Cause(), errorID, log)
 	default:
-		q.Set("error", "server_error")
-		q.Set("error_description", err.Error())
+		error_type, error_description := "server_error", err.Error()
+
+		// Provide better error messages for certain user-triggered Postgres errors.
+		if pgErr := utilities.NewPostgresError(e); pgErr != nil {
+			error_description = pgErr.Message
+			if oauthErrorType, ok := oauthErrorMap[pgErr.HttpStatusCode]; ok {
+				error_type = oauthErrorType
+			}
+		}
+
+		q.Set("error", error_type)
+		q.Set("error_description", error_description)
 	}
 	return &q
 }
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/mail.go b/api/mail.go
index c8ab003c..c31249d2 100644
--- a/api/mail.go
+++ b/api/mail.go
@@ -14,6 +14,7 @@ import (
 	"github.com/netlify/gotrue/storage"
 	"github.com/pkg/errors"
 	"github.com/sethvargo/go-password/password"
+	"github.com/sirupsen/logrus"
 )
 
 var (
@@ -168,12 +169,15 @@ func (a *API) GenerateLink(w http.ResponseWriter, r *http.Request) error {
 }
 
 func sendConfirmation(tx *storage.Connection, u *models.User, mailer mailer.Mailer, maxFrequency time.Duration, referrerURL string) error {
+	var err error
 	if u.ConfirmationSentAt != nil && !u.ConfirmationSentAt.Add(maxFrequency).Before(time.Now()) {
 		return MaxFrequencyLimitError
 	}
-
 	oldToken := u.ConfirmationToken
-	u.ConfirmationToken = crypto.SecureToken()
+	u.ConfirmationToken, err = generateUniqueEmailOtp(tx, confirmationToken)
+	if err != nil {
+		return err
+	}
 	now := time.Now()
 	if err := mailer.ConfirmationMail(u, referrerURL); err != nil {
 		u.ConfirmationToken = oldToken
@@ -184,8 +188,12 @@ func sendConfirmation(tx *storage.Connection, u *models.User, mailer mailer.Mail
 }
 
 func sendInvite(tx *storage.Connection, u *models.User, mailer mailer.Mailer, referrerURL string) error {
+	var err error
 	oldToken := u.ConfirmationToken
-	u.ConfirmationToken = crypto.SecureToken()
+	u.ConfirmationToken, err = generateUniqueEmailOtp(tx, confirmationToken)
+	if err != nil {
+		return err
+	}
 	now := time.Now()
 	if err := mailer.InviteMail(u, referrerURL); err != nil {
 		u.ConfirmationToken = oldToken
@@ -197,12 +205,16 @@ func sendInvite(tx *storage.Connection, u *models.User, mailer mailer.Mailer, re
 }
 
 func (a *API) sendPasswordRecovery(tx *storage.Connection, u *models.User, mailer mailer.Mailer, maxFrequency time.Duration, referrerURL string) error {
+	var err error
 	if u.RecoverySentAt != nil && !u.RecoverySentAt.Add(maxFrequency).Before(time.Now()) {
 		return MaxFrequencyLimitError
 	}
 
 	oldToken := u.RecoveryToken
-	u.RecoveryToken = crypto.SecureToken()
+	u.RecoveryToken, err = generateUniqueEmailOtp(tx, recoveryToken)
+	if err != nil {
+		return err
+	}
 	now := time.Now()
 	if err := mailer.RecoveryMail(u, referrerURL); err != nil {
 		u.RecoveryToken = oldToken
@@ -213,12 +225,16 @@ func (a *API) sendPasswordRecovery(tx *storage.Connection, u *models.User, maile
 }
 
 func (a *API) sendReauthenticationOtp(tx *storage.Connection, u *models.User, mailer mailer.Mailer, maxFrequency time.Duration) error {
+	var err error
 	if u.ReauthenticationSentAt != nil && !u.ReauthenticationSentAt.Add(maxFrequency).Before(time.Now()) {
 		return MaxFrequencyLimitError
 	}
 
 	oldToken := u.ReauthenticationToken
-	u.ReauthenticationToken = crypto.SecureToken()
+	u.ReauthenticationToken, err = generateUniqueEmailOtp(tx, reauthenticationToken)
+	if err != nil {
+		return err
+	}
 	now := time.Now()
 	if err := mailer.ReauthenticateMail(u); err != nil {
 		u.ReauthenticationToken = oldToken
@@ -229,14 +245,17 @@ func (a *API) sendReauthenticationOtp(tx *storage.Connection, u *models.User, ma
 }
 
 func (a *API) sendMagicLink(tx *storage.Connection, u *models.User, mailer mailer.Mailer, maxFrequency time.Duration, referrerURL string) error {
+	var err error
 	// since Magic Link is just a recovery with a different template and behaviour
 	// around new users we will reuse the recovery db timer to prevent potential abuse
 	if u.RecoverySentAt != nil && !u.RecoverySentAt.Add(maxFrequency).Before(time.Now()) {
 		return MaxFrequencyLimitError
 	}
-
 	oldToken := u.RecoveryToken
-	u.RecoveryToken = crypto.SecureToken()
+	u.RecoveryToken, err = generateUniqueEmailOtp(tx, recoveryToken)
+	if err != nil {
+		return err
+	}
 	now := time.Now()
 	if err := mailer.MagicLinkMail(u, referrerURL); err != nil {
 		u.RecoveryToken = oldToken
@@ -248,9 +267,16 @@ 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.Configuration, u *models.User, mailer mailer.Mailer, email string, referrerURL string) error {
-	u.EmailChangeTokenNew = crypto.SecureToken()
+	var err error
+	u.EmailChangeTokenNew, err = generateUniqueEmailOtp(tx, emailChangeTokenNew)
+	if err != nil {
+		return err
+	}
 	if config.Mailer.SecureEmailChangeEnabled && u.GetEmail() != "" {
-		u.EmailChangeTokenCurrent = crypto.SecureToken()
+		u.EmailChangeTokenCurrent, err = generateUniqueEmailOtp(tx, emailChangeTokenCurrent)
+		if err != nil {
+			return err
+		}
 	}
 	u.EmailChange = email
 	u.EmailChangeConfirmStatus = zeroConfirmation
@@ -280,3 +306,30 @@ func (a *API) validateEmail(ctx context.Context, email string) error {
 	}
 	return nil
 }
+
+// generateUniqueEmailOtp returns a unique otp
+func generateUniqueEmailOtp(tx *storage.Connection, tokenType tokenType) (string, error) {
+	maxRetries := 5
+	otpLength := 20
+	var otp string
+	var err error
+	for i := 0; i < maxRetries; i++ {
+		otp, err = crypto.GenerateEmailOtp(otpLength)
+		if err != nil {
+			return "", err
+		}
+		_, err = models.FindUserByTokenAndTokenType(tx, otp, string(tokenType))
+		if err != nil {
+			if models.IsNotFoundError(err) {
+				return otp, nil
+			}
+			return "", err
+		}
+		logrus.Warn("otp generated is not unique, retrying.")
+		err = errors.New("Could not generate a unique email otp")
+	}
+	if err != nil {
+		return "", err
+	}
+	return "", errors.New("Could not generate a unique email otp")
+}
diff --git a/api/middleware.go b/api/middleware.go
index 54e6fdc0..a010e70a 100644
--- a/api/middleware.go
+++ b/api/middleware.go
@@ -234,6 +234,7 @@ func (a *API) verifyCaptcha(w http.ResponseWriter, req *http.Request) (context.C
 	if secret == "" {
 		return nil, internalServerError("server misconfigured")
 	}
+
 	verificationResult, err := security.VerifyRequest(req, secret)
 	if err != nil {
 		logrus.WithField("err", err).Infof("failed to validate result")
diff --git a/api/otp.go b/api/otp.go
index 9f1f54e1..395baa95 100644
--- a/api/otp.go
+++ b/api/otp.go
@@ -31,6 +31,9 @@ func (a *API) Otp(w http.ResponseWriter, r *http.Request) error {
 		CreateUser: true,
 	}
 	body, err := ioutil.ReadAll(r.Body)
+	if err != nil {
+		return err
+	}
 	jsonDecoder := json.NewDecoder(bytes.NewReader(body))
 	if err = jsonDecoder.Decode(params); err != nil {
 		return badRequestError("Could not read verification params: %v", err)
@@ -41,8 +44,10 @@ func (a *API) Otp(w http.ResponseWriter, r *http.Request) error {
 
 	r.Body = ioutil.NopCloser(strings.NewReader(string(body)))
 
-	if !a.shouldCreateUser(r, params) {
+	if ok, err := a.shouldCreateUser(r, params); !ok {
 		return badRequestError("Signups not allowed for otp")
+	} else if err != nil {
+		return err
 	}
 
 	if params.Email != "" {
@@ -132,21 +137,28 @@ func (a *API) SmsOtp(w http.ResponseWriter, r *http.Request) error {
 	return sendJSON(w, http.StatusOK, make(map[string]string))
 }
 
-func (a *API) shouldCreateUser(r *http.Request, params *OtpParams) bool {
+func (a *API) shouldCreateUser(r *http.Request, params *OtpParams) (bool, error) {
 	if !params.CreateUser {
 		ctx := r.Context()
 		instanceID := getInstanceID(ctx)
 		aud := a.requestAud(ctx, r)
 		var err error
 		if params.Email != "" {
+			if err := a.validateEmail(ctx, params.Email); err != nil {
+				return false, err
+			}
 			_, err = models.FindUserByEmailAndAudience(a.db, instanceID, params.Email, aud)
 		} else if params.Phone != "" {
+			params.Phone, err = a.validatePhone(params.Phone)
+			if err != nil {
+				return false, err
+			}
 			_, err = models.FindUserByPhoneAndAudience(a.db, instanceID, params.Phone, aud)
 		}
 
 		if err != nil && models.IsNotFoundError(err) {
-			return false
+			return false, nil
 		}
 	}
-	return true
+	return true, nil
 }
diff --git a/api/phone.go b/api/phone.go
index 98bcf9f8..814244b9 100644
--- a/api/phone.go
+++ b/api/phone.go
@@ -30,7 +30,7 @@ func (a *API) validatePhone(phone string) (string, error) {
 	return phone, nil
 }
 
-// validateE165Format checks if phone number follows the E.164 format
+// validateE164Format checks if phone number follows the E.164 format
 func (a *API) validateE164Format(phone string) bool {
 	// match should never fail as long as regexp is valid
 	matched, _ := regexp.Match(e164Format, []byte(phone))
diff --git a/api/provider/apple.go b/api/provider/apple.go
index 148841af..e04585a4 100644
--- a/api/provider/apple.go
+++ b/api/provider/apple.go
@@ -32,7 +32,6 @@ const (
 // AppleProvider stores the custom config for apple provider
 type AppleProvider struct {
 	*oauth2.Config
-	httpClient  *http.Client
 	UserInfoURL string
 }
 
diff --git a/api/provider/discord.go b/api/provider/discord.go
index d7344eb6..30118143 100644
--- a/api/provider/discord.go
+++ b/api/provider/discord.go
@@ -4,6 +4,7 @@ import (
 	"context"
 	"errors"
 	"fmt"
+	"strconv"
 	"strings"
 
 	"github.com/netlify/gotrue/conf"
@@ -21,7 +22,7 @@ type discordProvider struct {
 
 type discordUser struct {
 	Avatar        string `json:"avatar"`
-	Discriminator int    `json:"discriminator,string"`
+	Discriminator string `json:"discriminator"`
 	Email         string `json:"email"`
 	ID            string `json:"id"`
 	Name          string `json:"username"`
@@ -76,11 +77,15 @@ func (g discordProvider) GetUserData(ctx context.Context, tok *oauth2.Token) (*U
 
 	var avatarURL string
 	extension := "png"
-	// https://discord.com/developers/docs/reference#image-formatting-cdn-endpoints:
-	// In the case of the Default User Avatar endpoint, the value for
-	// user_discriminator in the path should be the user's discriminator modulo 5
 	if u.Avatar == "" {
-		avatarURL = fmt.Sprintf("https://cdn.discordapp.com/embed/avatars/%d.%s", u.Discriminator%5, extension)
+		if intDiscriminator, err := strconv.Atoi(u.Discriminator); err != nil {
+			return nil, err
+		} else {
+			// https://discord.com/developers/docs/reference#image-formatting-cdn-endpoints:
+			// In the case of the Default User Avatar endpoint, the value for
+			// user_discriminator in the path should be the user's discriminator modulo 5
+			avatarURL = fmt.Sprintf("https://cdn.discordapp.com/embed/avatars/%d.%s", intDiscriminator%5, extension)
+		}
 	} else {
 		// https://discord.com/developers/docs/reference#image-formatting:
 		// "In the case of endpoints that support GIFs, the hash will begin with a_
diff --git a/api/provider/notion.go b/api/provider/notion.go
index 877fb671..db8f84d0 100644
--- a/api/provider/notion.go
+++ b/api/provider/notion.go
@@ -77,7 +77,7 @@ func (g notionProvider) GetUserData(ctx context.Context, tok *oauth2.Token) (*Us
 	req.Header.Set("Notion-Version", notionApiVersion)
 	req.Header.Set("Authorization", "Bearer "+tok.AccessToken)
 
-	client := &http.Client{}
+	client := &http.Client{Timeout: defaultTimeout}
 	resp, err := client.Do(req)
 
 	if err != nil {
diff --git a/api/provider/provider.go b/api/provider/provider.go
index d06659e7..426f1875 100644
--- a/api/provider/provider.go
+++ b/api/provider/provider.go
@@ -5,11 +5,27 @@ import (
 	"context"
 	"encoding/json"
 	"io/ioutil"
+	"log"
 	"net/http"
+	"os"
+	"time"
 
 	"golang.org/x/oauth2"
 )
 
+var defaultTimeout time.Duration = time.Second * 10
+
+func init() {
+	timeoutStr := os.Getenv("GOTRUE_INTERNAL_HTTP_TIMEOUT")
+	if timeoutStr != "" {
+		if timeout, err := time.ParseDuration(timeoutStr); err != nil {
+			log.Fatalf("error loading GOTRUE_INTERNAL_HTTP_TIMEOUT: %v", err.Error())
+		} else if timeout != 0 {
+			defaultTimeout = timeout
+		}
+	}
+}
+
 type Claims struct {
 	// Reserved claims
 	Issuer  string  `json:"iss,omitempty"`
@@ -103,6 +119,7 @@ func chooseHost(base, defaultHost string) string {
 
 func makeRequest(ctx context.Context, tok *oauth2.Token, g *oauth2.Config, url string, dst interface{}) error {
 	client := g.Client(ctx, tok)
+	client.Timeout = defaultTimeout
 	res, err := client.Get(url)
 	if err != nil {
 		return err
diff --git a/api/provider/twitch.go b/api/provider/twitch.go
index f4c52d7a..517109c7 100644
--- a/api/provider/twitch.go
+++ b/api/provider/twitch.go
@@ -92,7 +92,7 @@ func (t twitchProvider) GetUserData(ctx context.Context, tok *oauth2.Token) (*Us
 	req.Header.Set("Client-Id", t.Config.ClientID)
 	req.Header.Set("Authorization", "Bearer "+tok.AccessToken)
 
-	client := &http.Client{}
+	client := &http.Client{Timeout: defaultTimeout}
 	resp, err := client.Do(req)
 
 	if err != nil {
diff --git a/api/sms_provider/messagebird.go b/api/sms_provider/messagebird.go
index 88f61df4..4520f289 100644
--- a/api/sms_provider/messagebird.go
+++ b/api/sms_provider/messagebird.go
@@ -64,7 +64,7 @@ func (t *MessagebirdProvider) SendSms(phone string, message string) error {
 		"datacoding": {"unicode"},
 	}
 
-	client := &http.Client{}
+	client := &http.Client{Timeout: defaultTimeout}
 	r, err := http.NewRequest("POST", t.APIPath, strings.NewReader(body.Encode()))
 	if err != nil {
 		return err
diff --git a/api/sms_provider/sms_provider.go b/api/sms_provider/sms_provider.go
index 11b3b3f2..7d535133 100644
--- a/api/sms_provider/sms_provider.go
+++ b/api/sms_provider/sms_provider.go
@@ -2,10 +2,26 @@ package sms_provider
 
 import (
 	"fmt"
+	"log"
+	"os"
+	"time"
 
 	"github.com/netlify/gotrue/conf"
 )
 
+var defaultTimeout time.Duration = time.Second * 10
+
+func init() {
+	timeoutStr := os.Getenv("GOTRUE_INTERNAL_HTTP_TIMEOUT")
+	if timeoutStr != "" {
+		if timeout, err := time.ParseDuration(timeoutStr); err != nil {
+			log.Fatalf("error loading GOTRUE_INTERNAL_HTTP_TIMEOUT: %v", err.Error())
+		} else if timeout != 0 {
+			defaultTimeout = timeout
+		}
+	}
+}
+
 type SmsProvider interface {
 	SendSms(phone, message string) error
 }
diff --git a/api/sms_provider/textlocal.go b/api/sms_provider/textlocal.go
index 09e30f23..ac0f4b16 100644
--- a/api/sms_provider/textlocal.go
+++ b/api/sms_provider/textlocal.go
@@ -52,7 +52,7 @@ func (t *TextlocalProvider) SendSms(phone string, message string) error {
 		"numbers": {phone},
 	}
 
-	client := &http.Client{}
+	client := &http.Client{Timeout: defaultTimeout}
 	r, err := http.NewRequest("POST", t.APIPath, strings.NewReader(body.Encode()))
 	if err != nil {
 		return err
diff --git a/api/sms_provider/twilio.go b/api/sms_provider/twilio.go
index a77d9c10..cfe2ff04 100644
--- a/api/sms_provider/twilio.go
+++ b/api/sms_provider/twilio.go
@@ -62,7 +62,7 @@ func (t *TwilioProvider) SendSms(phone string, message string) error {
 		"Body":    {message},
 	}
 
-	client := &http.Client{}
+	client := &http.Client{Timeout: defaultTimeout}
 	r, err := http.NewRequest("POST", t.APIPath, strings.NewReader(body.Encode()))
 	if err != nil {
 		return err
diff --git a/api/sms_provider/vonage.go b/api/sms_provider/vonage.go
index fdc37525..75ed3577 100644
--- a/api/sms_provider/vonage.go
+++ b/api/sms_provider/vonage.go
@@ -52,7 +52,7 @@ func (t *VonageProvider) SendSms(phone string, message string) error {
 		"api_secret": {t.Config.ApiSecret},
 	}
 
-	client := &http.Client{}
+	client := &http.Client{Timeout: defaultTimeout}
 	r, err := http.NewRequest("POST", t.APIPath, strings.NewReader(body.Encode()))
 	if err != nil {
 		return err
diff --git a/api/token.go b/api/token.go
index a2032182..1ce341be 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"
@@ -58,6 +58,16 @@ type IdTokenGrantParams struct {
 	Issuer   string `json:"issuer"`
 }
 
+type tokenType string
+
+const (
+	confirmationToken       tokenType = "confirmation_token"
+	recoveryToken           tokenType = "recovery_token"
+	emailChangeTokenNew     tokenType = "email_change_token_new"
+	emailChangeTokenCurrent tokenType = "email_change_token_current"
+	reauthenticationToken   tokenType = "reauthentication_token"
+)
+
 const useCookieHeader = "x-use-cookie"
 const useSessionCookie = "session"
 const InvalidLoginMessage = "Invalid login credentials"
@@ -263,41 +273,50 @@ func (a *API) RefreshTokenGrant(ctx context.Context, w http.ResponseWriter, r *h
 		return oauthError("invalid_grant", "Invalid Refresh Token")
 	}
 
-	if !(config.External.Email.Enabled && config.External.Phone.Enabled) {
-		providers, err := models.FindProvidersByUser(a.db, user)
-		if err != nil {
-			return internalServerError(err.Error())
-		}
-		for _, provider := range providers {
-			if provider == "email" && !config.External.Email.Enabled {
-				return badRequestError("Email logins are disabled")
+	var newToken *models.RefreshToken
+	if token.Revoked {
+		a.clearCookieTokens(config, w)
+		err = a.db.Transaction(func(tx *storage.Connection) error {
+			validToken, terr := models.GetValidChildToken(tx, token)
+			if terr != nil {
+				if errors.Is(terr, models.RefreshTokenNotFoundError{}) {
+					// revoked token has no descendants
+					return nil
+				}
+				return terr
 			}
-			if provider == "phone" && !config.External.Phone.Enabled {
-				return badRequestError("Phone logins are disabled")
+			// check if token is the last previous revoked token
+			if validToken.Parent == storage.NullString(token.Token) {
+				refreshTokenReuseWindow := token.UpdatedAt.Add(time.Second * time.Duration(config.Security.RefreshTokenReuseInterval))
+				if time.Now().Before(refreshTokenReuseWindow) {
+					newToken = validToken
+				}
 			}
+			return nil
+		})
+		if err != nil {
+			return internalServerError("Error validating reuse interval").WithInternalError(err)
 		}
-	}
 
-	if token.Revoked {
-		a.clearCookieTokens(config, w)
-		if config.Security.RefreshTokenRotationEnabled {
-			// Revoke all tokens in token family
-			err = a.db.Transaction(func(tx *storage.Connection) error {
-				var terr error
-				if terr = models.RevokeTokenFamily(tx, token); terr != nil {
-					return terr
+		if newToken == nil {
+			if config.Security.RefreshTokenRotationEnabled {
+				// Revoke all tokens in token family
+				err = a.db.Transaction(func(tx *storage.Connection) error {
+					var terr error
+					if terr = models.RevokeTokenFamily(tx, token); terr != nil {
+						return terr
+					}
+					return nil
+				})
+				if err != nil {
+					return internalServerError(err.Error())
 				}
-				return nil
-			})
-			if err != nil {
-				return internalServerError(err.Error())
 			}
+			return oauthError("invalid_grant", "Invalid Refresh Token").WithInternalMessage("Possible abuse attempt: %v", r)
 		}
-		return oauthError("invalid_grant", "Invalid Refresh Token").WithInternalMessage("Possible abuse attempt: %v", r)
 	}
 
 	var tokenString string
-	var newToken *models.RefreshToken
 	var newTokenResponse *AccessTokenResponse
 
 	err = a.db.Transaction(func(tx *storage.Connection) error {
@@ -306,9 +325,11 @@ func (a *API) RefreshTokenGrant(ctx context.Context, w http.ResponseWriter, r *h
 			return terr
 		}
 
-		newToken, terr = models.GrantRefreshTokenSwap(tx, user, token)
-		if terr != nil {
-			return internalServerError(terr.Error())
+		if newToken == nil {
+			newToken, terr = models.GrantRefreshTokenSwap(tx, user, token)
+			if terr != nil {
+				return internalServerError(terr.Error())
+			}
 		}
 
 		tokenString, terr = generateAccessToken(user, time.Second*time.Duration(config.JWT.Exp), config.JWT.Secret)
@@ -348,8 +369,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 +400,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/verify.go b/api/verify.go
index bf1ba333..2fcf817c 100644
--- a/api/verify.go
+++ b/api/verify.go
@@ -7,6 +7,7 @@ import (
 	"net/http"
 	"net/url"
 	"strconv"
+	"strings"
 	"time"
 
 	"github.com/netlify/gotrue/models"
@@ -34,6 +35,11 @@ const (
 	singleConfirmation
 )
 
+const (
+	// v1 uses crypto.SecureToken()
+	v1OtpLength = 22
+)
+
 // Only applicable when SECURE_EMAIL_CHANGE_ENABLED
 const singleConfirmationAccepted = "Confirmation link accepted. Please proceed to confirm link sent to the other email"
 
@@ -77,6 +83,10 @@ func (a *API) verifyGet(w http.ResponseWriter, r *http.Request) error {
 		if params.Token == "" {
 			return badRequestError("Verify requires a token")
 		}
+		if len(params.Token) > v1OtpLength {
+			// token follows the v2 format and includes "-"
+			params.Token = strings.ReplaceAll(params.Token, "-", "")
+		}
 		if params.Type == "" {
 			return badRequestError("Verify requires a verification type")
 		}
@@ -153,6 +163,10 @@ func (a *API) verifyPost(w http.ResponseWriter, r *http.Request) error {
 	if params.Token == "" {
 		return badRequestError("Verify requires a token")
 	}
+	if len(params.Token) > v1OtpLength {
+		// token follows the v2 format and includes "-"
+		params.Token = strings.ReplaceAll(params.Token, "-", "")
+	}
 
 	if params.Type == "" {
 		return badRequestError("Verify requires a verification type")
diff --git a/conf/configuration.go b/conf/configuration.go
index 938d0a22..a9e28ee4 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"
 )
@@ -65,17 +66,17 @@ type GlobalConfiguration struct {
 		RequestIDHeader string `envconfig:"REQUEST_ID_HEADER"`
 		ExternalURL     string `json:"external_url" envconfig:"API_EXTERNAL_URL"`
 	}
-	DB                 DBConfiguration
-	External           ProviderConfiguration
-	Logging            LoggingConfig `envconfig:"LOG"`
-	OperatorToken      string        `split_words:"true" required:"false"`
-	MultiInstanceMode  bool
-	Tracing            TracingConfig
-	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"`
+	DB                    DBConfiguration
+	External              ProviderConfiguration
+	Logging               LoggingConfig `envconfig:"LOG"`
+	OperatorToken         string        `split_words:"true" required:"false"`
+	MultiInstanceMode     bool
+	Tracing               TracingConfig
+	SMTP                  SMTPConfiguration
+	RateLimitHeader       string  `split_words:"true"`
+	RateLimitEmailSent    float64 `split_words:"true" default:"30"`
+	RateLimitVerify       float64 `split_words:"true" default:"30"`
+	RateLimitTokenRefresh float64 `split_words:"true" default:"30"`
 }
 
 // EmailContentConfiguration holds the configuration for emails, both subjects and template URLs.
@@ -180,13 +181,15 @@ type CaptchaConfiguration struct {
 type SecurityConfiguration struct {
 	Captcha                               CaptchaConfiguration `json:"captcha"`
 	RefreshTokenRotationEnabled           bool                 `json:"refresh_token_rotation_enabled" split_words:"true" default:"true"`
+	RefreshTokenReuseInterval             int                  `json:"refresh_token_reuse_interval" split_words:"true"`
 	UpdatePasswordRequireReauthentication bool                 `json:"update_password_require_reauthentication" split_words:"true"`
 }
 
 // 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"`
@@ -206,7 +209,7 @@ type Configuration struct {
 func loadEnvironment(filename string) error {
 	var err error
 	if filename != "" {
-		err = godotenv.Load(filename)
+		err = godotenv.Overload(filename)
 	} else {
 		err = godotenv.Load()
 		// handle if .env file does not exist, this is OK
@@ -341,7 +344,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/crypto/crypto.go b/crypto/crypto.go
index 3c788f95..22df538a 100644
--- a/crypto/crypto.go
+++ b/crypto/crypto.go
@@ -8,7 +8,6 @@ import (
 	"math"
 	"math/big"
 	"strconv"
-	"strings"
 
 	"github.com/pkg/errors"
 )
@@ -19,11 +18,7 @@ func SecureToken() string {
 	if _, err := io.ReadFull(rand.Reader, b); err != nil {
 		panic(err.Error()) // rand should never fail
 	}
-	return removePadding(base64.URLEncoding.EncodeToString(b))
-}
-
-func removePadding(token string) string {
-	return strings.TrimRight(token, "=")
+	return base64.RawURLEncoding.EncodeToString(b)
 }
 
 // GenerateOtp generates a random n digit otp
@@ -37,3 +32,22 @@ func GenerateOtp(digits int) (string, error) {
 	otp := fmt.Sprintf(expr, val.String())
 	return otp, nil
 }
+
+// GenerateOtpFromCharset generates a random n-length otp from a charset
+func GenerateOtpFromCharset(length int, charset string) (string, error) {
+	b := make([]byte, length)
+	for i := range b {
+		val, err := rand.Int(rand.Reader, big.NewInt(int64(len(charset))))
+		if err != nil {
+			return "", errors.WithMessage(err, "Error generating otp from charset")
+		}
+		b[i] = charset[val.Int64()]
+	}
+	return string(b), nil
+}
+
+// GenerateEmailOtp generates a random n-length alphanumeric otp
+func GenerateEmailOtp(length int) (string, error) {
+	const charset = "abcdefghijklmnopqrstuvwxyz"
+	return GenerateOtpFromCharset(length, charset)
+}
diff --git a/docker-compose-dev.yml b/docker-compose-dev.yml
new file mode 100644
index 00000000..831d8eb6
--- /dev/null
+++ b/docker-compose-dev.yml
@@ -0,0 +1,31 @@
+version: "3.9"
+services:
+  gotrue:
+    container_name: gotrue
+    depends_on:
+      - postgres
+    build:
+      context: ./
+      dockerfile: Dockerfile.dev
+    ports:
+      - '9999:9999'
+    environment:
+      - GOTRUE_DB_MIGRATIONS_PATH=/go/src/github.com/netlify/gotrue/migrations
+    volumes:
+      - ./:/go/src/github.com/netlify/gotrue
+    command: CompileDaemon --build="make build" --directory=/go/src/github.com/netlify/gotrue --recursive=true -pattern="(.+\.go|.+\.env)" -exclude=gotrue -exclude=gotrue-arm64 -exclude=.env --command="/go/src/github.com/netlify/gotrue/gotrue -c=.env.docker"
+  postgres:
+    image: postgres:13
+    container_name: postgres
+    ports:
+      - '5432:5432'
+    volumes:
+      - postgres_data:/var/lib/postgresql/data
+      - ${PWD}/hack/init_postgres.sql:/docker-entrypoint-initdb.d/init.sql
+    environment:
+      - POSTGRES_USER=postgres
+      - POSTGRES_PASSWORD=root
+      - POSTGRES_DB=postgres
+
+volumes:
+  postgres_data:
diff --git a/example.docker.env b/example.docker.env
new file mode 100644
index 00000000..89eefe06
--- /dev/null
+++ b/example.docker.env
@@ -0,0 +1,7 @@
+GOTRUE_SITE_URL="http://localhost:3000"
+GOTRUE_JWT_SECRET=""
+GOTRUE_DB_MIGRATIONS_PATH=/go/src/github.com/netlify/gotrue/migrations
+GOTRUE_DB_DRIVER=postgres
+DATABASE_URL=postgres://supabase_auth_admin:root@postgres:5432/postgres
+GOTRUE_API_HOST=0.0.0.0
+PORT=9999
diff --git a/example.env b/example.env
index e38ef11a..e707c114 100644
--- a/example.env
+++ b/example.env
@@ -178,6 +178,7 @@ GOTRUE_SMS_VONAGE_FROM=""
 GOTRUE_SECURITY_CAPTCHA_ENABLED="false"
 GOTRUE_SECURITY_CAPTCHA_PROVIDER="hcaptcha"
 GOTRUE_SECURITY_CAPTCHA_SECRET="0x0000000000000000000000000000000000000000"
+GOTRUE_SECURITY_CAPTCHA_TIMEOUT="10s"
 GOTRUE_SESSION_KEY=""
 
 # SAML config
@@ -190,7 +191,9 @@ GOTRUE_EXTERNAL_SAML_SIGNING_KEY=""
 
 # Additional Security config
 GOTRUE_LOG_LEVEL="debug"
-GOTRUE_REFRESH_TOKEN_ROTATION_ENABLED="false"
+GOTRUE_SECURITY_REFRESH_TOKEN_ROTATION_ENABLED="false"
+GOTRUE_SECURITY_REFRESH_TOKEN_REUSE_INTERVAL="0"
+GOTRUE_SECURITY_UPDATE_PASSWORD_REQUIRE_REAUTHENTICATION="false"
 GOTRUE_OPERATOR_TOKEN="unused-operator-token"
 GOTRUE_RATE_LIMIT_HEADER="X-Forwarded-For"
 GOTRUE_RATE_LIMIT_EMAIL_SENT="100"
diff --git a/go.mod b/go.mod
index e8130727..2746cd13 100644
--- a/go.mod
+++ b/go.mod
@@ -19,11 +19,14 @@ 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
 	github.com/gorilla/sessions v1.1.1
 	github.com/imdario/mergo v0.0.0-20160216103600-3e95a51e0639
+	github.com/jackc/pgconn v1.8.0 // indirect
+	github.com/jackc/pgerrcode v0.0.0-20201024163028-a0d42d470451 // indirect
 	github.com/jackc/pgproto3/v2 v2.0.7 // indirect
 	github.com/jmoiron/sqlx v1.3.1 // indirect
 	github.com/joho/godotenv v1.3.0
@@ -52,7 +55,7 @@ require (
 	golang.org/x/time v0.0.0-20200416051211-89c76fbcd5d1 // indirect
 	gopkg.in/DataDog/dd-trace-go.v1 v1.12.1
 	gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df
-	gopkg.in/yaml.v1 v1.0.0-20140924161607-9f9df34309c0 // indirect
+	gopkg.in/yaml.v3 v3.0.0 // indirect
 )
 
 go 1.13
diff --git a/go.sum b/go.sum
index f2482446..e059f2cb 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=
@@ -286,6 +288,8 @@ github.com/jackc/pgconn v1.5.1-0.20200601181101-fa742c524853/go.mod h1:QeD3lBfpT
 github.com/jackc/pgconn v1.6.0/go.mod h1:yeseQo4xhQbgyJs2c87RAXOH2i624N0Fh1KSPJya7qo=
 github.com/jackc/pgconn v1.8.0 h1:FmjZ0rOyXTr1wfWs45i4a9vjnjWUAGpMuQLD9OSs+lw=
 github.com/jackc/pgconn v1.8.0/go.mod h1:1C2Pb36bGIP9QHGBYCjnyhqu7Rv3sGshaQUvmfGIB/o=
+github.com/jackc/pgerrcode v0.0.0-20201024163028-a0d42d470451 h1:WAvSpGf7MsFuzAtK4Vk7R4EVe+liW4x83r4oWu0WHKw=
+github.com/jackc/pgerrcode v0.0.0-20201024163028-a0d42d470451/go.mod h1:a/s9Lp5W7n/DD0VrVoyJ00FbP2ytTPDVOivvn2bMlds=
 github.com/jackc/pgio v1.0.0 h1:g12B9UwVnzGhueNavwioyEEpAmqMe1E/BN9ES+8ovkE=
 github.com/jackc/pgio v1.0.0/go.mod h1:oP+2QK2wFfUWgr+gxjoBH9KGBb31Eio69xUb0w5bYf8=
 github.com/jackc/pgmock v0.0.0-20190831213851-13a1b77aafa2 h1:JVX6jT/XfzNqIjye4717ITLaNwV9mWbJx0dLCpcRzdA=
@@ -921,6 +925,8 @@ gopkg.in/yaml.v3 v3.0.0-20190924164351-c8b7dadae555/go.mod h1:K4uyk7z7BCEPqu6E+C
 gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
 gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo=
 gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gopkg.in/yaml.v3 v3.0.0 h1:hjy8E9ON/egN1tAYqKb61G10WtihqetD4sz2H+8nIeA=
+gopkg.in/yaml.v3 v3.0.0/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
 honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
 honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
 honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
diff --git a/hack/migrate.sh b/hack/migrate.sh
index 06c55f97..2d1f0e5e 100755
--- a/hack/migrate.sh
+++ b/hack/migrate.sh
@@ -9,7 +9,4 @@ export GOTRUE_DB_DRIVER="postgres"
 export GOTRUE_DB_DATABASE_URL="postgres://supabase_auth_admin:root@localhost:5432/$DB_ENV"
 export GOTRUE_DB_MIGRATIONS_PATH=$DIR/../migrations
 
-echo soda -v
-soda drop -d -e $DB_ENV -c $DATABASE
-soda create -d -e $DB_ENV -c $DATABASE
 go run main.go migrate -c $DIR/test.env
diff --git a/hack/migrate_postgres.sh b/hack/migrate_postgres.sh
deleted file mode 100755
index 59a8e80c..00000000
--- a/hack/migrate_postgres.sh
+++ /dev/null
@@ -1,12 +0,0 @@
-#!/usr/bin/env bash
-
-DB_ENV=$1
-
-DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
-DATABASE="$DIR/database.yml"
-
-export GOTRUE_DB_DRIVER="postgres"
-export GOTRUE_DB_DATABASE_URL="postgres://postgres:root@localhost:5432/$DB_ENV?sslmode=disable"
-export GOTRUE_DB_MIGRATIONS_PATH=$DIR/../migrations
-
-go run main.go migrate -c $DIR/test.env
diff --git a/mailer/template.go b/mailer/template.go
index 3380ced7..f4e82a86 100644
--- a/mailer/template.go
+++ b/mailer/template.go
@@ -2,6 +2,7 @@ package mailer
 
 import (
 	"fmt"
+	"strings"
 
 	"github.com/badoux/checkmail"
 	"github.com/netlify/gotrue/conf"
@@ -79,7 +80,7 @@ func (m *TemplateMailer) InviteMail(user *models.User, referrerURL string) error
 		"SiteURL":         m.Config.SiteURL,
 		"ConfirmationURL": url,
 		"Email":           user.Email,
-		"Token":           user.ConfirmationToken,
+		"Token":           formatEmailOtp(user.ConfirmationToken),
 		"Data":            user.UserMetaData,
 	}
 
@@ -109,7 +110,7 @@ func (m *TemplateMailer) ConfirmationMail(user *models.User, referrerURL string)
 		"SiteURL":         m.Config.SiteURL,
 		"ConfirmationURL": url,
 		"Email":           user.Email,
-		"Token":           user.ConfirmationToken,
+		"Token":           formatEmailOtp(user.ConfirmationToken),
 		"Data":            user.UserMetaData,
 	}
 
@@ -127,7 +128,7 @@ func (m *TemplateMailer) ReauthenticateMail(user *models.User) error {
 	data := map[string]interface{}{
 		"SiteURL": m.Config.SiteURL,
 		"Email":   user.Email,
-		"Token":   user.ReauthenticationToken,
+		"Token":   formatEmailOtp(user.ReauthenticationToken),
 		"Data":    user.UserMetaData,
 	}
 
@@ -151,7 +152,7 @@ func (m *TemplateMailer) EmailChangeMail(user *models.User, referrerURL string)
 	emails := []Email{
 		{
 			Address:  user.EmailChange,
-			Token:    user.EmailChangeTokenNew,
+			Token:    formatEmailOtp(user.EmailChangeTokenNew),
 			Subject:  string(withDefault(m.Config.Mailer.Subjects.EmailChange, "Confirm Email Change")),
 			Template: m.Config.Mailer.Templates.EmailChange,
 		},
@@ -161,7 +162,7 @@ func (m *TemplateMailer) EmailChangeMail(user *models.User, referrerURL string)
 	if m.Config.Mailer.SecureEmailChangeEnabled && currentEmail != "" {
 		emails = append(emails, Email{
 			Address:  currentEmail,
-			Token:    user.EmailChangeTokenCurrent,
+			Token:    formatEmailOtp(user.EmailChangeTokenCurrent),
 			Subject:  string(withDefault(m.Config.Mailer.Subjects.Confirmation, "Confirm Email Address")),
 			Template: m.Config.Mailer.Templates.EmailChange,
 		})
@@ -233,7 +234,7 @@ func (m *TemplateMailer) RecoveryMail(user *models.User, referrerURL string) err
 		"SiteURL":         m.Config.SiteURL,
 		"ConfirmationURL": url,
 		"Email":           user.Email,
-		"Token":           user.RecoveryToken,
+		"Token":           formatEmailOtp(user.RecoveryToken),
 		"Data":            user.UserMetaData,
 	}
 
@@ -263,7 +264,7 @@ func (m *TemplateMailer) MagicLinkMail(user *models.User, referrerURL string) er
 		"SiteURL":         m.Config.SiteURL,
 		"ConfirmationURL": url,
 		"Email":           user.Email,
-		"Token":           user.RecoveryToken,
+		"Token":           formatEmailOtp(user.RecoveryToken),
 		"Data":            user.UserMetaData,
 	}
 
@@ -316,3 +317,17 @@ func (m TemplateMailer) GetEmailActionLink(user *models.User, actionType, referr
 
 	return url, nil
 }
+
+// formatEmailOtp separates the otp into chunks of 5 with "-" as the separator
+func formatEmailOtp(otp string) string {
+	chunkSize := 5
+	var chunks []string
+	for i := 0; i < len(otp); i += chunkSize {
+		if i+chunkSize >= len(otp) {
+			chunks = append(chunks, otp[i:])
+		} else {
+			chunks = append(chunks, otp[i:i+chunkSize])
+		}
+	}
+	return strings.Join(chunks, "-")
+}
diff --git a/migrations/20220429102000_add_unique_idx.up.sql b/migrations/20220429102000_add_unique_idx.up.sql
new file mode 100644
index 00000000..b4280e0c
--- /dev/null
+++ b/migrations/20220429102000_add_unique_idx.up.sql
@@ -0,0 +1,14 @@
+-- add partial unique indices to confirmation_token, recovery_token, email_change_token_current, email_change_token_new, phone_change_token, reauthentication_token
+-- ignores partial unique index creation on fields which contain empty strings, whitespaces or purely numeric otps
+
+DROP INDEX IF EXISTS confirmation_token_idx; 
+DROP INDEX IF EXISTS recovery_token_idx;
+DROP INDEX IF EXISTS email_change_token_current_idx;
+DROP INDEX IF EXISTS email_change_token_new_idx;
+DROP INDEX IF EXISTS reauthentication_token_idx;
+
+CREATE UNIQUE INDEX IF NOT EXISTS confirmation_token_idx ON auth.users USING btree (confirmation_token) WHERE confirmation_token !~ '^[0-9 ]*$';
+CREATE UNIQUE INDEX IF NOT EXISTS recovery_token_idx ON auth.users USING btree (recovery_token) WHERE recovery_token !~ '^[0-9 ]*$';
+CREATE UNIQUE INDEX IF NOT EXISTS email_change_token_current_idx ON auth.users USING btree (email_change_token_current) WHERE email_change_token_current !~ '^[0-9 ]*$';
+CREATE UNIQUE INDEX IF NOT EXISTS email_change_token_new_idx ON auth.users USING btree (email_change_token_new) WHERE email_change_token_new !~ '^[0-9 ]*$';
+CREATE UNIQUE INDEX IF NOT EXISTS reauthentication_token_idx ON auth.users USING btree (reauthentication_token) WHERE reauthentication_token !~ '^[0-9 ]*$';
diff --git a/migrations/20220531120530_add_auth_jwt_function.up.sql b/migrations/20220531120530_add_auth_jwt_function.up.sql
new file mode 100644
index 00000000..1ddc69a2
--- /dev/null
+++ b/migrations/20220531120530_add_auth_jwt_function.up.sql
@@ -0,0 +1,16 @@
+-- add auth.jwt function
+
+comment on function auth.uid() is 'Deprecated. Use auth.jwt() -> ''sub'' instead.';
+comment on function auth.role() is 'Deprecated. Use auth.jwt() -> ''role'' instead.';
+comment on function auth.email() is 'Deprecated. Use auth.jwt() -> ''email'' instead.';
+
+create or replace function auth.jwt()
+returns jsonb
+language sql stable
+as $$
+  select 
+    coalesce(
+        nullif(current_setting('request.jwt.claim', true), ''),
+        nullif(current_setting('request.jwt.claims', true), '')
+    )::jsonb
+$$;
diff --git a/models/audit_log_entry.go b/models/audit_log_entry.go
index 72bf6e97..49755242 100644
--- a/models/audit_log_entry.go
+++ b/models/audit_log_entry.go
@@ -8,6 +8,7 @@ import (
 	"github.com/gofrs/uuid"
 	"github.com/netlify/gotrue/storage"
 	"github.com/pkg/errors"
+	"github.com/sirupsen/logrus"
 )
 
 type AuditAction string
@@ -96,7 +97,12 @@ func NewAuditLogEntry(tx *storage.Connection, instanceID uuid.UUID, actor *User,
 		l.Payload["traits"] = traits
 	}
 
-	return errors.Wrap(tx.Create(&l), "Database error creating audit log entry")
+	if err := tx.Create(&l); err != nil {
+		return errors.Wrap(err, "Database error creating audit log entry")
+	}
+
+	logrus.Infof("{\"actor_id\": %v, \"action\": %v, \"timestamp\": %v, \"log_type\": %v}", actor.ID, action, l.Payload["timestamp"], actionLogTypeMap[action])
+	return nil
 }
 
 func FindAuditLogEntries(tx *storage.Connection, instanceID uuid.UUID, filterColumns []string, filterValue string, pageParams *Pagination) ([]*AuditLogEntry, error) {
diff --git a/models/refresh_token.go b/models/refresh_token.go
index 054806e4..2af42c50 100644
--- a/models/refresh_token.go
+++ b/models/refresh_token.go
@@ -1,6 +1,7 @@
 package models
 
 import (
+	"database/sql"
 	"time"
 
 	"github.com/gobuffalo/pop/v5"
@@ -57,19 +58,33 @@ func GrantRefreshTokenSwap(tx *storage.Connection, user *User, token *RefreshTok
 
 // RevokeTokenFamily revokes all refresh tokens that descended from the provided token.
 func RevokeTokenFamily(tx *storage.Connection, token *RefreshToken) error {
+	tablename := (&pop.Model{Value: RefreshToken{}}).TableName()
 	err := tx.RawQuery(`
 	with recursive token_family as (
-		select id, user_id, token, revoked, parent from refresh_tokens where parent = ?
+		select id, user_id, token, revoked, parent from `+tablename+` where parent = ?
 		union
-		select r.id, r.user_id, r.token, r.revoked, r.parent from `+(&pop.Model{Value: RefreshToken{}}).TableName()+` r inner join token_family t on t.token = r.parent
+		select r.id, r.user_id, r.token, r.revoked, r.parent from `+tablename+` r inner join token_family t on t.token = r.parent
 	)
-	update `+(&pop.Model{Value: RefreshToken{}}).TableName()+` r set revoked = true from token_family where token_family.id = r.id;`, token.Token).Exec()
+	update `+tablename+` r set revoked = true from token_family where token_family.id = r.id;`, token.Token).Exec()
 	if err != nil {
 		return err
 	}
 	return nil
 }
 
+// GetValidChildToken returns the child token of the token provided if the child is not revoked.
+func GetValidChildToken(tx *storage.Connection, token *RefreshToken) (*RefreshToken, error) {
+	refreshToken := &RefreshToken{}
+	err := tx.Q().Where("parent = ? and revoked = false", token.Token).First(refreshToken)
+	if err != nil {
+		if errors.Cause(err) == sql.ErrNoRows {
+			return nil, RefreshTokenNotFoundError{}
+		}
+		return nil, err
+	}
+	return refreshToken, nil
+}
+
 // Logout deletes all refresh tokens for a user.
 func Logout(tx *storage.Connection, instanceID uuid.UUID, id uuid.UUID) error {
 	return tx.RawQuery("DELETE FROM "+(&pop.Model{Value: RefreshToken{}}).TableName()+" WHERE instance_id = ? AND user_id = ?", instanceID, id).Exec()
diff --git a/models/user.go b/models/user.go
index 7af1e4d0..7ede0cf6 100644
--- a/models/user.go
+++ b/models/user.go
@@ -2,6 +2,7 @@ package models
 
 import (
 	"database/sql"
+	"fmt"
 	"strings"
 	"time"
 
@@ -398,6 +399,12 @@ func FindUserByEmailChangeToken(tx *storage.Connection, token string) (*User, er
 	return findUser(tx, "email_change_token_current = ? or email_change_token_new = ?", token, token)
 }
 
+// FindUserByTokenAndTokenType finds a user with the matching token and token type.
+func FindUserByTokenAndTokenType(tx *storage.Connection, token string, tokenType string) (*User, error) {
+	query := fmt.Sprintf("%v = ?", tokenType)
+	return findUser(tx, query, token)
+}
+
 // FindUserWithRefreshToken finds a user from the provided refresh token.
 func FindUserWithRefreshToken(tx *storage.Connection, token string) (*User, *RefreshToken, error) {
 	refreshToken := &RefreshToken{}
diff --git a/security/hcaptcha.go b/security/hcaptcha.go
index 4017f857..f34c7d61 100644
--- a/security/hcaptcha.go
+++ b/security/hcaptcha.go
@@ -5,8 +5,10 @@ import (
 	"encoding/json"
 	"fmt"
 	"io/ioutil"
+	"log"
 	"net/http"
 	"net/url"
+	"os"
 	"strconv"
 	"strings"
 	"time"
@@ -40,8 +42,17 @@ const (
 var Client *http.Client
 
 func init() {
-	// TODO (darora): make timeout configurable
-	Client = &http.Client{Timeout: 10 * time.Second}
+	var defaultTimeout time.Duration = time.Second * 10
+	timeoutStr := os.Getenv("GOTRUE_SECURITY_CAPTCHA_TIMEOUT")
+	if timeoutStr != "" {
+		if timeout, err := time.ParseDuration(timeoutStr); err != nil {
+			log.Fatalf("error loading GOTRUE_SECURITY_CAPTCHA_TIMEOUT: %v", err.Error())
+		} else if timeout != 0 {
+			defaultTimeout = timeout
+		}
+	}
+
+	Client = &http.Client{Timeout: defaultTimeout}
 }
 
 func VerifyRequest(r *http.Request, secretKey string) (VerificationResult, error) {
diff --git a/storage/dial.go b/storage/dial.go
index 57619819..569063fd 100644
--- a/storage/dial.go
+++ b/storage/dial.go
@@ -73,6 +73,10 @@ func getExcludedColumns(model interface{}, includeColumns ...string) ([]string,
 
 	xcols := make([]string, len(cols.Cols))
 	for n := range cols.Cols {
+		// gobuffalo updates the updated_at column automatically
+		if n == "updated_at" {
+			continue
+		}
 		xcols = append(xcols, n)
 	}
 	return xcols, nil
diff --git a/utilities/postgres.go b/utilities/postgres.go
new file mode 100644
index 00000000..08a57b6a
--- /dev/null
+++ b/utilities/postgres.go
@@ -0,0 +1,72 @@
+package utilities
+
+import (
+	"errors"
+	"strconv"
+	"strings"
+
+	"github.com/jackc/pgconn"
+	"github.com/jackc/pgerrcode"
+)
+
+// PostgresError is a custom error struct for marshalling Postgres errors to JSON.
+type PostgresError struct {
+	Code           string `json:"code"`
+	HttpStatusCode int    `json:"-"`
+	Message        string `json:"message"`
+	Hint           string `json:"hint,omitempty"`
+	Detail         string `json:"detail,omitempty"`
+}
+
+// NewPostgresError returns a new PostgresError if the error was from a publicly
+// accessible Postgres error.
+func NewPostgresError(err error) *PostgresError {
+	var pgErr *pgconn.PgError
+	if errors.As(err, &pgErr) && isPubliclyAccessiblePostgresError(pgErr.Code) {
+		return &PostgresError{
+			Code:           pgErr.Code,
+			HttpStatusCode: getHttpStatusCodeFromPostgresErrorCode(pgErr.Code),
+			Message:        pgErr.Message,
+			Detail:         pgErr.Detail,
+			Hint:           pgErr.Hint,
+		}
+	}
+
+	return nil
+}
+
+// isPubliclyAccessiblePostgresError checks if the Postgres error should be
+// made accessible.
+func isPubliclyAccessiblePostgresError(code string) bool {
+	if len(code) != 5 {
+		return false
+	}
+
+	// default response
+	return getHttpStatusCodeFromPostgresErrorCode(code) != 0
+}
+
+// getHttpStatusCodeFromPostgresErrorCode maps a Postgres error code to a HTTP
+// status code. Returns 0 if the code doesn't map to a given postgres error code.
+func getHttpStatusCodeFromPostgresErrorCode(code string) int {
+	if code == pgerrcode.RaiseException ||
+		code == pgerrcode.IntegrityConstraintViolation ||
+		code == pgerrcode.RestrictViolation ||
+		code == pgerrcode.NotNullViolation ||
+		code == pgerrcode.ForeignKeyViolation ||
+		code == pgerrcode.UniqueViolation ||
+		code == pgerrcode.CheckViolation ||
+		code == pgerrcode.ExclusionViolation {
+		return 500
+	}
+
+	// Use custom HTTP status code if Postgres error was triggered with `PTXXX`
+	// code. This is consistent with PostgREST's behaviour as well.
+	if strings.HasPrefix(code, "PT") {
+		if httpStatusCode, err := strconv.ParseInt(code[2:], 10, 0); err == nil {
+			return int(httpStatusCode)
+		}
+	}
+
+	return 0
+}


Test patch:
diff --git a/api/external_discord_test.go b/api/external_discord_test.go
index 057cd9c5..916f8855 100644
--- a/api/external_discord_test.go
+++ b/api/external_discord_test.go
@@ -10,7 +10,7 @@ import (
 )
 
 const (
-	discordUser           string = `{"id":"discordTestId","avatar":"abc","email":"discord@example.com","username":"Discord Test","verified":true}}`
+	discordUser           string = `{"id":"discordTestId","avatar":"abc","email":"discord@example.com","username":"Discord Test","verified":true,"discriminator":"0001"}}`
 	discordUserWrongEmail string = `{"id":"discordTestId","avatar":"abc","email":"other@example.com","username":"Discord Test","verified":true}}`
 	discordUserNoEmail    string = `{"id":"discordTestId","avatar":"abc","username":"Discord Test","verified":true}}`
 )
diff --git a/api/token_test.go b/api/token_test.go
index eaa5dcb5..ace039f9 100644
--- a/api/token_test.go
+++ b/api/token_test.go
@@ -57,7 +57,7 @@ func (ts *TokenTestSuite) SetupTest() {
 	require.NoError(ts.T(), err, "Error creating refresh token")
 }
 
-func (ts *TokenTestSuite) TestRateLimitToken() {
+func (ts *TokenTestSuite) TestRateLimitTokenRefresh() {
 	var buffer bytes.Buffer
 	req := httptest.NewRequest(http.MethodPost, "http://localhost/token", &buffer)
 	req.Header.Set("Content-Type", "application/json")
@@ -150,6 +150,95 @@ func (ts *TokenTestSuite) TestTokenRefreshTokenGrantFailure() {
 	assert.Equal(ts.T(), http.StatusBadRequest, w.Code)
 }
 
+func (ts *TokenTestSuite) TestTokenRefreshTokenRotation() {
+	u, err := models.NewUser(ts.instanceID, "foo@example.com", "password", ts.Config.JWT.Aud, nil)
+	require.NoError(ts.T(), err, "Error creating test user model")
+	t := time.Now()
+	u.EmailConfirmedAt = &t
+	require.NoError(ts.T(), ts.API.db.Create(u), "Error saving foo user")
+	first, err := models.GrantAuthenticatedUser(ts.API.db, u)
+	require.NoError(ts.T(), err)
+	second, err := models.GrantRefreshTokenSwap(ts.API.db, u, first)
+	require.NoError(ts.T(), err)
+	third, err := models.GrantRefreshTokenSwap(ts.API.db, u, second)
+	require.NoError(ts.T(), err)
+
+	cases := []struct {
+		desc                        string
+		refreshTokenRotationEnabled bool
+		reuseInterval               int
+		refreshToken                string
+		expectedCode                int
+		expectedBody                map[string]interface{}
+	}{
+		{
+			"Valid refresh within reuse interval",
+			true,
+			30,
+			second.Token,
+			http.StatusOK,
+			map[string]interface{}{
+				"refresh_token": third.Token,
+			},
+		},
+		{
+			"Invalid refresh, first token is not the previous revoked token",
+			true,
+			0,
+			first.Token,
+			http.StatusBadRequest,
+			map[string]interface{}{
+				"error":             "invalid_grant",
+				"error_description": "Invalid Refresh Token",
+			},
+		},
+		{
+			"Invalid refresh, revoked third token",
+			true,
+			0,
+			second.Token,
+			http.StatusBadRequest,
+			map[string]interface{}{
+				"error":             "invalid_grant",
+				"error_description": "Invalid Refresh Token",
+			},
+		},
+		{
+			"Invalid refresh, third token revoked by previous case",
+			true,
+			30,
+			third.Token,
+			http.StatusBadRequest,
+			map[string]interface{}{
+				"error":             "invalid_grant",
+				"error_description": "Invalid Refresh Token",
+			},
+		},
+	}
+
+	for _, c := range cases {
+		ts.Run(c.desc, func() {
+			ts.Config.Security.RefreshTokenRotationEnabled = c.refreshTokenRotationEnabled
+			ts.Config.Security.RefreshTokenReuseInterval = c.reuseInterval
+			var buffer bytes.Buffer
+			require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]interface{}{
+				"refresh_token": c.refreshToken,
+			}))
+			req := httptest.NewRequest(http.MethodPost, "http://localhost/token?grant_type=refresh_token", &buffer)
+			req.Header.Set("Content-Type", "application/json")
+			w := httptest.NewRecorder()
+			ts.API.handler.ServeHTTP(w, req)
+			assert.Equal(ts.T(), c.expectedCode, w.Code)
+
+			data := make(map[string]interface{})
+			require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&data))
+			for k, v := range c.expectedBody {
+				require.Equal(ts.T(), v, data[k])
+			}
+		})
+	}
+}
+
 func (ts *TokenTestSuite) createBannedUser() *models.User {
 	u, err := models.NewUser(ts.instanceID, "banned@example.com", "password", ts.Config.JWT.Aud, nil)
 	require.NoError(ts.T(), err, "Error creating test user model")
diff --git a/api/verify_test.go b/api/verify_test.go
index b8104e1a..896a0f1b 100644
--- a/api/verify_test.go
+++ b/api/verify_test.go
@@ -440,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 {
@@ -448,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)
diff --git a/hack/test.env b/hack/test.env
index d0137c9c..b1dcd884 100644
--- a/hack/test.env
+++ b/hack/test.env
@@ -94,3 +94,4 @@ GOTRUE_TRACING_TAGS="env:test"
 GOTRUE_SECURITY_CAPTCHA_ENABLED="false"
 GOTRUE_SECURITY_CAPTCHA_PROVIDER="hcaptcha"
 GOTRUE_SECURITY_CAPTCHA_SECRET="0x0000000000000000000000000000000000000000"
+GOTRUE_SECURITY_CAPTCHA_TIMEOUT="10s"