Repository: supabase/gotrue
Pull request #689 (2022-09-16 14:28:50)

Fix mobile/native Apple login in the `id_token` grant flow for hosted instances
Every other supported provider used provided client ID that could be modified and set via the Supabase studio/dashboard but the Apple login was the only one that used a separate `config.External.IosBundleId` that comes from a config file and can't be set in the dashboard.

To fix this, we first try and read the value provided by the config and then try to fallback onto using client IDs, just like it's done for the remaining providers.

cc @kangmingtay who implemented the initial `id_token` grant flow

This should address the error seen by @k0shk0sh in https://github.com/supabase/gotrue/issues/412#issuecomment-1114193213 (happened to me, this PR is an attempt to fix that).

## What kind of change does this PR introduce?

Fixes a bug/omission introduced in https://github.com/supabase/gotrue/pull/189

## What is the current behavior?

The user can use the `id_token` grant login using native Apple sign in only if there exists a configuration file with specified `ios_bundle_id` value, which is not exposed in the online/self-hosted Supabase dashboard: https://github.com/supabase/gotrue/blob/de6cd793280c43dc580afea7877f1430aba964eb/conf/configuration.go#L136

## What is the new behavior?

If the explicit key is missing (by default), then we fall back to using the client ID (Apple Services ID) specified in `/project/[ref]/auth/settings`.

## Additional context

As outlined in https://github.com/supabase/gotrue/pull/189#issuecomment-936117397, it's possible to link App IDs to Service IDs, so I believe one can use a single Service ID (specified in the dashboard) for both native and web auth flows. This means, that this should not break the existing auth flow and one can configure it so that both are supported using a single Service ID.

Without this change, for a app-only flow, users have to configure a new Service ID for a dummy web auth and use the browser-based login flow.



Patch:
diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml
new file mode 100644
index 00000000..18bf13d4
--- /dev/null
+++ b/.github/workflows/docs.yml
@@ -0,0 +1,27 @@
+name: Docs
+
+on:
+  workflow_dispatch:
+
+  workflow_run:
+    workflows: [Test]
+    types: [completed]
+    branches:
+      - master
+
+jobs:
+  docs:
+    name: Generate Swagger docs
+    runs-on: ubuntu-20.04
+    steps:
+      - name: Generate Swagger UI
+        uses: Legion2/swagger-ui-action@v1
+        with:
+          output: swagger-ui
+          spec-file: swagger.json
+
+      - name: Deploy to GitHub Pages
+        uses: peaceiris/actions-gh-pages@v3
+        with:
+          github_token: ${{ secrets.GITHUB_TOKEN }}
+          publish_dir: swagger-ui
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 20de0f61..ff3464a3 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -8,7 +8,7 @@ on:
     types: [completed]
     branches:
       - master
-      - mfa_v1
+      - mfa
       - zerosessionidfix
 
 jobs:
diff --git a/Makefile b/Makefile
index 083372e2..a251c2ae 100644
--- a/Makefile
+++ b/Makefile
@@ -1,6 +1,6 @@
 .PHONY: all build deps dev-deps image migrate test vet sec format unused
 CHECK_FILES?=./...
-FLAGS?=-ldflags "-X github.com/netlify/gotrue/utilities.Version=`git describe --tags`"
+FLAGS?=-ldflags "-X github.com/netlify/gotrue/utilities.Version=`git describe --tags`" -buildvcs=false
 DEV_DOCKER_COMPOSE:=docker-compose-dev.yml
 
 help: ## Show this help.
@@ -9,8 +9,8 @@ help: ## Show this help.
 all: vet sec static build ## Run the tests and build the binary.
 
 build: deps ## Build the binary.
-	go build $(FLAGS)
-	GOOS=linux GOARCH=arm64 go build $(FLAGS) -o gotrue-arm64
+	CGO_ENABLED=0 go build $(FLAGS)
+	CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build $(FLAGS) -o gotrue-arm64
 
 dev-deps: ## Install developer dependencies
 	@go install github.com/gobuffalo/pop/soda@latest
diff --git a/README.md b/README.md
index 7b973921..2da170b0 100644
--- a/README.md
+++ b/README.md
@@ -288,37 +288,108 @@ Controls what log levels are output. Choose from `panic`, `fatal`, `error`, `war
 
 If you wish logs to be written to a file, set `log_file` to a valid file path.
 
-### Opentracing
+### Observability
 
-Currently, only the Datadog tracer is supported.
+GoTrue has basic observability built in. It is able to export
+[OpenTelemetry](https://opentelemetry.io) metrics and traces to a collector.
 
-```properties
-GOTRUE_TRACING_ENABLED=true
-GOTRUE_TRACING_HOST=127.0.0.1
-GOTRUE_TRACING_PORT=8126
-GOTRUE_TRACING_TAGS="tag1:value1,tag2:value2"
-GOTRUE_SERVICE_NAME="gotrue"
+#### Tracing
+
+To enable tracing configure these variables:
+
+`GOTRUE_TRACING_ENABLED` - `boolean`
+
+`GOTRUE_TRACING_EXPORTER` - `string` only `opentracing` (deprecated) and
+`opentelemetry` supported
+
+Make sure you also configure the [OpenTelemetry
+Exporter](https://opentelemetry.io/docs/reference/specification/protocol/exporter/)
+configuration for your collector or service.
+
+For example, if you use
+[Honeycomb.io](https://docs.honeycomb.io/getting-data-in/opentelemetry/go-distro/#using-opentelemetry-without-the-honeycomb-distribution)
+you should set these standard OpenTelemetry OTLP variables:
+
+```
+OTEL_SERVICE_NAME=gotrue
+OTEL_EXPORTER_OTLP_PROTOCOL=grpc
+OTEL_EXPORTER_OTLP_ENDPOINT=https://api.honeycomb.io:443
+OTEL_EXPORTER_OTLP_HEADERS="x-honeycomb-team=<API-KEY>,x-honeycomb-dataset=gotrue"
 ```
 
-`TRACING_ENABLED` - `bool`
+#### Metrics
 
-Whether tracing is enabled or not. Defaults to `false`.
+To enable metrics configure these variables:
 
-`TRACING_HOST` - `bool`
+`GOTRUE_METRICS_ENABLED` - `boolean`
 
-The tracing destination.
+`GOTRUE_METRICS_EXPORTER` - `string` only `opentelemetry` and `prometheus`
+supported
 
-`TRACING_PORT` - `bool`
+Make sure you also configure the [OpenTelemetry
+Exporter](https://opentelemetry.io/docs/reference/specification/protocol/exporter/)
+configuration for your collector or service.
 
-The port for the tracing host.
+If you use the `prometheus` exporter, the server host and port can be
+configured using these standard OpenTelemetry variables:
 
-`TRACING_TAGS` - `string`
+`OTEL_EXPORTER_PROMETHEUS_HOST` - IP address, default `0.0.0.0`
+
+`OTEL_EXPORTER_PROMETHEUS_PORT` - port number, default `9100`
+
+The metrics are exported on the `/` path on the server.
+
+If you use the `opentelemetry` exporter, the metrics are pushed to the
+collector.
+
+For example, if you use
+[Honeycomb.io](https://docs.honeycomb.io/getting-data-in/opentelemetry/go-distro/#using-opentelemetry-without-the-honeycomb-distribution)
+you should set these standard OpenTelemetry OTLP variables:
+
+```
+OTEL_SERVICE_NAME=gotrue
+OTEL_EXPORTER_OTLP_PROTOCOL=grpc
+OTEL_EXPORTER_OTLP_ENDPOINT=https://api.honeycomb.io:443
+OTEL_EXPORTER_OTLP_HEADERS="x-honeycomb-team=<API-KEY>,x-honeycomb-dataset=gotrue"
+```
 
-A comma separated list of key:value pairs. These key value pairs will be added as tags to all opentracing spans.
+Note that Honeycomb.io requires a paid plan to ingest metrics.
+
+If you need to debug an issue with traces or metrics not being pushed, you can
+set `DEBUG=true` to get more insights from the OpenTelemetry SDK.
+
+#### Custom resource attributes
+
+When using the OpenTelemetry tracing or metrics exporter you can define custom
+resource attributes using the [standard `OTEL_RESOURCE_ATTRIBUTES` environment
+variable](https://opentelemetry.io/docs/reference/specification/resource/sdk/#specifying-resource-information-via-an-environment-variable).
+
+A default attribute `gotrue.version` is provided containing the build version.
+
+#### Tracing HTTP routes
+
+All HTTP calls to the GoTrue API are traced. Routes use the parametrized
+version of the route, and the values for the route parameters can be found as
+the `http.route.params.<route-key>` span attribute.
+
+For example, the following request:
+
+```
+GET /admin/users/4acde936-82dc-4552-b851-831fb8ce0927/
+```
+
+will be traced as:
+
+```
+http.method = GET
+http.route = /admin/users/{user_id}
+http.route.params.user_id = 4acde936-82dc-4552-b851-831fb8ce0927
+```
 
-`SERVICE_NAME` - `string`
+#### Go runtime and HTTP metrics
 
-The name to use for the service.
+All of the Go runtime metrics are exposed. Some HTTP metrics are also collected
+by default.
 
 ### JSON Web Tokens (JWT)
 
diff --git a/api/admin.go b/api/admin.go
index dbbbf5da..dd860121 100644
--- a/api/admin.go
+++ b/api/admin.go
@@ -16,7 +16,7 @@ import (
 	"github.com/sethvargo/go-password/password"
 )
 
-type adminUserParams struct {
+type AdminUserParams struct {
 	Aud          string                 `json:"aud"`
 	Role         string                 `json:"role"`
 	Email        string                 `json:"email"`
@@ -29,6 +29,16 @@ type adminUserParams struct {
 	BanDuration  string                 `json:"ban_duration"`
 }
 
+type adminUserUpdateFactorParams struct {
+	FriendlyName string `json:"friendly_name"`
+	FactorType   string `json:"factor_type"`
+}
+
+type AdminListUsersResponse struct {
+	Users []*models.User `json:"users"`
+	Aud   string         `json:"aud"`
+}
+
 func (a *API) loadUser(w http.ResponseWriter, r *http.Request) (context.Context, error) {
 	ctx := r.Context()
 	db := a.db.WithContext(ctx)
@@ -51,8 +61,26 @@ func (a *API) loadUser(w http.ResponseWriter, r *http.Request) (context.Context,
 	return withUser(ctx, u), nil
 }
 
-func (a *API) getAdminParams(r *http.Request) (*adminUserParams, error) {
-	params := adminUserParams{}
+func (a *API) loadFactor(w http.ResponseWriter, r *http.Request) (context.Context, error) {
+	factorID, err := uuid.FromString(chi.URLParam(r, "factor_id"))
+	if err != nil {
+		return nil, badRequestError("factor_id must be an UUID")
+	}
+
+	observability.LogEntrySetField(r, "factor_id", factorID)
+
+	f, err := models.FindFactorByFactorID(a.db, factorID)
+	if err != nil {
+		if models.IsNotFoundError(err) {
+			return nil, notFoundError("Factor not found")
+		}
+		return nil, internalServerError("Database error loading factor").WithInternalError(err)
+	}
+	return withFactor(r.Context(), f), nil
+}
+
+func (a *API) getAdminParams(r *http.Request) (*AdminUserParams, error) {
+	params := AdminUserParams{}
 
 	body, err := getBodyBytes(r)
 	if err != nil {
@@ -90,9 +118,9 @@ func (a *API) adminUsers(w http.ResponseWriter, r *http.Request) error {
 	}
 	addPaginationHeaders(w, r, pageParams)
 
-	return sendJSON(w, http.StatusOK, map[string]interface{}{
-		"users": users,
-		"aud":   aud,
+	return sendJSON(w, http.StatusOK, AdminListUsersResponse{
+		Users: users,
+		Aud:   aud,
 	})
 }
 
@@ -115,6 +143,20 @@ func (a *API) adminUserUpdate(w http.ResponseWriter, r *http.Request) error {
 		return err
 	}
 
+	if params.Email != "" {
+		params.Email, err = a.validateEmail(ctx, params.Email)
+		if err != nil {
+			return err
+		}
+	}
+
+	if params.Phone != "" {
+		params.Phone, err = a.validatePhone(params.Phone)
+		if err != nil {
+			return err
+		}
+	}
+
 	err = db.Transaction(func(tx *storage.Connection) error {
 		if params.Role != "" {
 			if terr := user.SetRole(tx, params.Role); terr != nil {
@@ -229,7 +271,8 @@ func (a *API) adminUserCreate(w http.ResponseWriter, r *http.Request) error {
 	}
 
 	if params.Email != "" {
-		if err := a.validateEmail(ctx, params.Email); err != nil {
+		params.Email, err = a.validateEmail(ctx, params.Email)
+		if err != nil {
 			return err
 		}
 		if exists, err := models.IsDuplicatedEmail(db, params.Email, aud); err != nil {
@@ -358,3 +401,90 @@ func (a *API) adminUserDelete(w http.ResponseWriter, r *http.Request) error {
 
 	return sendJSON(w, http.StatusOK, map[string]interface{}{})
 }
+
+func (a *API) adminUserDeleteFactor(w http.ResponseWriter, r *http.Request) error {
+	ctx := r.Context()
+	user := getUser(ctx)
+	factor := getFactor(ctx)
+
+	MFAEnabled, err := models.IsMFAEnabled(a.db, user)
+	if err != nil {
+		return err
+	} else if !MFAEnabled {
+		return forbiddenError("You do not have a verified factor enrolled")
+	}
+
+	err = a.db.Transaction(func(tx *storage.Connection) error {
+		if terr := models.NewAuditLogEntry(r, tx, user, models.DeleteFactorAction, r.RemoteAddr, map[string]interface{}{
+			"user_id":   user.ID,
+			"factor_id": factor.ID,
+		}); terr != nil {
+			return terr
+		}
+		if terr := tx.Destroy(factor); terr != nil {
+			return internalServerError("Database error deleting factor").WithInternalError(terr)
+		}
+		return nil
+	})
+	if err != nil {
+		return err
+	}
+	return sendJSON(w, http.StatusOK, factor)
+}
+
+func (a *API) adminUserGetFactors(w http.ResponseWriter, r *http.Request) error {
+	ctx := r.Context()
+	user := getUser(ctx)
+	factors, terr := models.FindFactorsByUser(a.db, user)
+	if terr != nil {
+		return terr
+	}
+	return sendJSON(w, http.StatusOK, factors)
+}
+
+// adminUserUpdate updates a single factor object
+func (a *API) adminUserUpdateFactor(w http.ResponseWriter, r *http.Request) error {
+	ctx := r.Context()
+	factor := getFactor(ctx)
+	user := getUser(ctx)
+	adminUser := getAdminUser(ctx)
+	params := &adminUserUpdateFactorParams{}
+	body, err := getBodyBytes(r)
+	if err != nil {
+		return badRequestError("Could not read body").WithInternalError(err)
+	}
+
+	if err := json.Unmarshal(body, params); err != nil {
+		return badRequestError("Could not read factor update params: %v", err)
+	}
+
+	err = a.db.Transaction(func(tx *storage.Connection) error {
+		if params.FriendlyName != "" {
+			if terr := factor.UpdateFriendlyName(tx, params.FriendlyName); terr != nil {
+				return terr
+			}
+		}
+		if params.FactorType != "" {
+			if params.FactorType != models.TOTP {
+				return badRequestError("Factor Type not valid")
+			}
+			if terr := factor.UpdateFactorType(tx, params.FactorType); terr != nil {
+				return terr
+			}
+		}
+
+		if terr := models.NewAuditLogEntry(r, tx, adminUser, models.UpdateFactorAction, "", map[string]interface{}{
+			"user_id":     user.ID,
+			"factor_id":   factor.ID,
+			"factor_type": factor.FactorType,
+		}); terr != nil {
+			return terr
+		}
+		return nil
+	})
+	if err != nil {
+		return err
+	}
+
+	return sendJSON(w, http.StatusOK, factor)
+}
diff --git a/api/api.go b/api/api.go
index 9fed2523..74d3b721 100644
--- a/api/api.go
+++ b/api/api.go
@@ -2,12 +2,8 @@ package api
 
 import (
 	"context"
-	"errors"
 	"net/http"
-	"os"
-	"os/signal"
 	"regexp"
-	"syscall"
 	"time"
 
 	"github.com/didip/tollbooth/v5"
@@ -37,44 +33,6 @@ type API struct {
 	version string
 }
 
-// ListenAndServe starts the REST API
-func (a *API) ListenAndServe(hostAndPort string) {
-	log := logrus.WithField("component", "api")
-	server := &http.Server{
-		Addr:              hostAndPort,
-		Handler:           a.handler,
-		ReadHeaderTimeout: 2 * time.Second, // to mitigate a Slowloris attack
-	}
-
-	done := make(chan struct{})
-	defer close(done)
-	go func() {
-		waitForTermination(log, done)
-		ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
-		defer cancel()
-		err := server.Shutdown(ctx)
-		if err != nil && !errors.Is(err, context.Canceled) && errors.Is(err, context.DeadlineExceeded) {
-			log.WithError(err).Error("Shutdown failed")
-		}
-	}()
-
-	if err := server.ListenAndServe(); err != http.ErrServerClosed {
-		log.WithError(err).Fatal("http server listen failed")
-	}
-}
-
-// WaitForShutdown blocks until the system signals termination or done has a value
-func waitForTermination(log logrus.FieldLogger, done <-chan struct{}) {
-	signals := make(chan os.Signal, 1)
-	signal.Notify(signals, os.Interrupt, syscall.SIGTERM, syscall.SIGINT)
-	select {
-	case sig := <-signals:
-		log.Infof("Triggering shutdown from signal %s", sig)
-	case <-done:
-		log.Infof("Shutting down...")
-	}
-}
-
 // NewAPI instantiates a new REST API
 func NewAPI(globalConfig *conf.GlobalConfiguration, db *storage.Connection) *API {
 	return NewAPIWithVersion(context.Background(), globalConfig, db, defaultVersion)
@@ -104,10 +62,20 @@ func NewAPIWithVersion(ctx context.Context, globalConfig *conf.GlobalConfigurati
 	logger := observability.NewStructuredLogger(logrus.StandardLogger())
 
 	r := newRouter()
-	r.UseBypass(xffmw.Handler)
 	r.Use(addRequestID(globalConfig))
+
+	if globalConfig.Tracing.Enabled {
+		switch globalConfig.Tracing.Exporter {
+		case conf.OpenTelemetryTracing:
+			r.UseBypass(observability.RequestTracing())
+
+		case conf.OpenTracing:
+			r.UseBypass(opentracer)
+		}
+	}
+
+	r.UseBypass(xffmw.Handler)
 	r.Use(recoverer)
-	r.UseBypass(tracer)
 
 	r.Get("/health", api.HealthCheck)
 
@@ -162,6 +130,26 @@ func NewAPIWithVersion(ctx context.Context, globalConfig *conf.GlobalConfigurati
 			r.With(sharedLimiter).Put("/", api.UserUpdate)
 		})
 
+		if api.config.MFA.Enabled {
+			r.With(api.requireAuthentication).Route("/factors", func(r *router) {
+				r.Post("/", api.EnrollFactor)
+				r.Route("/{factor_id}", func(r *router) {
+					r.Use(api.loadFactor)
+
+					r.With(api.limitHandler(
+						tollbooth.NewLimiter(api.config.MFA.RateLimitChallengeAndVerify/60, &limiter.ExpirableOptions{
+							DefaultExpirationTTL: time.Minute,
+						}).SetBurst(30))).Post("/verify", api.VerifyFactor)
+					r.With(api.limitHandler(
+						tollbooth.NewLimiter(api.config.MFA.RateLimitChallengeAndVerify/60, &limiter.ExpirableOptions{
+							DefaultExpirationTTL: time.Minute,
+						}).SetBurst(30))).Post("/challenge", api.ChallengeFactor)
+					r.Delete("/", api.UnenrollFactor)
+
+				})
+			})
+		}
+
 		r.Route("/admin", func(r *router) {
 			r.Use(api.requireAdminCredentials)
 
@@ -175,6 +163,16 @@ func NewAPIWithVersion(ctx context.Context, globalConfig *conf.GlobalConfigurati
 
 				r.Route("/{user_id}", func(r *router) {
 					r.Use(api.loadUser)
+					if api.config.MFA.Enabled {
+						r.Route("/factors", func(r *router) {
+							r.Get("/", api.adminUserGetFactors)
+							r.Route("/{factor_id}", func(r *router) {
+								r.Use(api.loadFactor)
+								r.Delete("/", api.adminUserDeleteFactor)
+								r.Put("/", api.adminUserUpdateFactor)
+							})
+						})
+					}
 
 					r.Get("/", api.adminUserGet)
 					r.Put("/", api.adminUserUpdate)
@@ -188,7 +186,7 @@ func NewAPIWithVersion(ctx context.Context, globalConfig *conf.GlobalConfigurati
 
 	corsHandler := cors.New(cors.Options{
 		AllowedMethods:   []string{http.MethodGet, http.MethodPost, http.MethodPut, http.MethodDelete},
-		AllowedHeaders:   []string{"Accept", "Authorization", "Content-Type", audHeaderName, useCookieHeader},
+		AllowedHeaders:   []string{"Accept", "Authorization", "Content-Type", "X-Client-IP", "X-Client-Info", audHeaderName, useCookieHeader},
 		AllowCredentials: true,
 	})
 
@@ -211,12 +209,18 @@ func NewAPIFromConfigFile(filename string, version string) (*API, *conf.GlobalCo
 	return NewAPIWithVersion(context.Background(), config, db, version), config, nil
 }
 
+type HealthCheckResponse struct {
+	Version     string `json:"version"`
+	Name        string `json:"name"`
+	Description string `json:"description"`
+}
+
 // HealthCheck endpoint indicates if the gotrue api service is available
 func (a *API) HealthCheck(w http.ResponseWriter, r *http.Request) error {
-	return sendJSON(w, http.StatusOK, map[string]string{
-		"version":     a.version,
-		"name":        "GoTrue",
-		"description": "GoTrue is a user registration and authentication API",
+	return sendJSON(w, http.StatusOK, HealthCheckResponse{
+		Version:     a.version,
+		Name:        "GoTrue",
+		Description: "GoTrue is a user registration and authentication API",
 	})
 }
 
diff --git a/api/auth.go b/api/auth.go
index a3a38342..b771b18d 100644
--- a/api/auth.go
+++ b/api/auth.go
@@ -2,7 +2,6 @@ package api
 
 import (
 	"context"
-	"errors"
 	"fmt"
 	"net/http"
 	"time"
@@ -73,7 +72,7 @@ func (a *API) parseJWTClaims(bearer string, r *http.Request, w http.ResponseWrit
 	})
 	if err != nil {
 		a.clearCookieTokens(config, w)
-		return nil, unauthorizedError("Invalid token: %v", err)
+		return nil, unauthorizedError("invalid JWT: unable to parse or verify signature").WithInternalError(err)
 	}
 
 	return withToken(ctx, token), nil
@@ -84,18 +83,18 @@ func (a *API) maybeLoadUserOrSession(ctx context.Context) (context.Context, erro
 	claims := getClaims(ctx)
 
 	if claims == nil {
-		return ctx, errors.New("invalid token")
+		return ctx, unauthorizedError("invalid token: missing claims")
 	}
 
 	if claims.Subject == "" {
-		return nil, errors.New("invalid claim: subject missing")
+		return nil, unauthorizedError("invalid claim: missing sub claim")
 	}
 
 	var user *models.User
 	if claims.Subject != "" {
 		userId, err := uuid.FromString(claims.Subject)
 		if err != nil {
-			return ctx, err
+			return ctx, badRequestError("invalid claim: sub claim must be a UUID").WithInternalError(err)
 		}
 		user, err = models.FindUserByID(db, userId)
 		if err != nil {
diff --git a/api/cleanup.go b/api/cleanup.go
new file mode 100644
index 00000000..da0bcb1c
--- /dev/null
+++ b/api/cleanup.go
@@ -0,0 +1,30 @@
+package api
+
+import (
+	"context"
+	"sync"
+)
+
+var (
+	cleanupWaitGroup sync.WaitGroup
+)
+
+// WaitForCleanup waits until all API servers are shut down cleanly or until
+// the provided context signals done, whichever comes first.
+func WaitForCleanup(ctx context.Context) {
+	cleanupDone := make(chan struct{})
+
+	go func() {
+		defer close(cleanupDone)
+
+		cleanupWaitGroup.Wait()
+	}()
+
+	select {
+	case <-ctx.Done():
+		return
+
+	case <-cleanupDone:
+		return
+	}
+}
diff --git a/api/context.go b/api/context.go
index f4d1737f..ef893e0f 100644
--- a/api/context.go
+++ b/api/context.go
@@ -20,6 +20,7 @@ const (
 	signatureKey            = contextKey("signature")
 	externalProviderTypeKey = contextKey("external_provider_type")
 	userKey                 = contextKey("user")
+	factorKey               = contextKey("factor")
 	sessionKey              = contextKey("session")
 	externalReferrerKey     = contextKey("external_referrer")
 	functionHooksKey        = contextKey("function_hooks")
@@ -71,6 +72,11 @@ func withUser(ctx context.Context, u *models.User) context.Context {
 	return context.WithValue(ctx, userKey, u)
 }
 
+// with Factor adds the factor id to the context.
+func withFactor(ctx context.Context, f *models.Factor) context.Context {
+	return context.WithValue(ctx, factorKey, f)
+}
+
 // getUser reads the user from the context.
 func getUser(ctx context.Context) *models.User {
 	if ctx == nil {
@@ -83,6 +89,15 @@ func getUser(ctx context.Context) *models.User {
 	return obj.(*models.User)
 }
 
+// getFactor reads the factor id from the context
+func getFactor(ctx context.Context) *models.Factor {
+	obj := ctx.Value(factorKey)
+	if obj == nil {
+		return nil
+	}
+	return obj.(*models.Factor)
+}
+
 // withSession adds the session to the context.
 func withSession(ctx context.Context, s *models.Session) context.Context {
 	return context.WithValue(ctx, sessionKey, s)
diff --git a/api/external.go b/api/external.go
index 7427a1ae..db209105 100644
--- a/api/external.go
+++ b/api/external.go
@@ -278,8 +278,12 @@ func (a *API) internalExternalProviderCallback(w http.ResponseWriter, r *http.Re
 				}
 			}
 		}
+		if config.MFA.Enabled {
+			token, terr = a.MFA_issueRefreshToken(ctx, tx, user, models.OAuth, grantParams)
+		} else {
+			token, terr = a.issueRefreshToken(ctx, tx, user, grantParams)
+		}
 
-		token, terr = a.issueRefreshToken(ctx, tx, user, grantParams)
 		if terr != nil {
 			return oauthError("server_error", terr.Error())
 		}
diff --git a/api/helpers.go b/api/helpers.go
index aaa5ca13..30ce9aa5 100644
--- a/api/helpers.go
+++ b/api/helpers.go
@@ -10,7 +10,6 @@ import (
 	"net/http"
 	"net/http/httptrace"
 	"net/url"
-	"strings"
 
 	"github.com/gofrs/uuid"
 	"github.com/netlify/gotrue/conf"
@@ -103,13 +102,8 @@ func isRedirectURLValid(config *conf.GlobalConfiguration, redirectURL string) bo
 	}
 
 	// For case when user came from mobile app or other permitted resource - redirect back
-	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 {
+	for _, pattern := range config.URIAllowListMap {
+		if pattern.Match(redirectURL) {
 			return true
 		}
 	}
diff --git a/api/invite.go b/api/invite.go
index 7f2d425f..280a84a7 100644
--- a/api/invite.go
+++ b/api/invite.go
@@ -31,7 +31,8 @@ func (a *API) Invite(w http.ResponseWriter, r *http.Request) error {
 		return badRequestError("Could not read Invite params: %v", err)
 	}
 
-	if err := a.validateEmail(ctx, params.Email); err != nil {
+	params.Email, err = a.validateEmail(ctx, params.Email)
+	if err != nil {
 		return err
 	}
 
diff --git a/api/listener.go b/api/listener.go
new file mode 100644
index 00000000..5cf5598e
--- /dev/null
+++ b/api/listener.go
@@ -0,0 +1,47 @@
+package api
+
+import (
+	"context"
+	"errors"
+	"net"
+	"net/http"
+	"time"
+
+	"github.com/sirupsen/logrus"
+)
+
+// ListenAndServe starts the REST API
+func (a *API) ListenAndServe(ctx context.Context, hostAndPort string) {
+	baseCtx, cancel := context.WithCancel(context.Background())
+
+	log := logrus.WithField("component", "api")
+
+	server := &http.Server{
+		Addr:              hostAndPort,
+		Handler:           a.handler,
+		ReadHeaderTimeout: 2 * time.Second, // to mitigate a Slowloris attack
+		BaseContext: func(net.Listener) context.Context {
+			return baseCtx
+		},
+	}
+
+	cleanupWaitGroup.Add(1)
+	go func() {
+		defer cleanupWaitGroup.Done()
+
+		<-ctx.Done()
+
+		defer cancel() // close baseContext
+
+		shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), time.Minute)
+		defer shutdownCancel()
+
+		if err := server.Shutdown(shutdownCtx); err != nil && !errors.Is(err, context.Canceled) {
+			log.WithError(err).Error("shutdown failed")
+		}
+	}()
+
+	if err := server.ListenAndServe(); err != http.ErrServerClosed {
+		log.WithError(err).Fatal("http server listen failed")
+	}
+}
diff --git a/api/magic_link.go b/api/magic_link.go
index dad00287..1bdce600 100644
--- a/api/magic_link.go
+++ b/api/magic_link.go
@@ -43,7 +43,8 @@ func (a *API) MagicLink(w http.ResponseWriter, r *http.Request) error {
 	if params.Email == "" {
 		return unprocessableEntityError("Password recovery requires an email")
 	}
-	if err := a.validateEmail(ctx, params.Email); err != nil {
+	params.Email, err = a.validateEmail(ctx, params.Email)
+	if err != nil {
 		return err
 	}
 
diff --git a/api/mail.go b/api/mail.go
index d2684acf..1bf727a1 100644
--- a/api/mail.go
+++ b/api/mail.go
@@ -6,6 +6,7 @@ import (
 	"encoding/json"
 	"fmt"
 	"net/http"
+	"strings"
 	"time"
 
 	"github.com/netlify/gotrue/conf"
@@ -30,6 +31,15 @@ type GenerateLinkParams struct {
 	RedirectTo string                 `json:"redirect_to"`
 }
 
+type GenerateLinkResponse struct {
+	models.User
+	ActionLink       string `json:"action_link"`
+	EmailOtp         string `json:"email_otp"`
+	HashedToken      string `json:"hashed_token"`
+	VerificationType string `json:"verification_type"`
+	RedirectTo       string `json:"redirect_to"`
+}
+
 func (a *API) GenerateLink(w http.ResponseWriter, r *http.Request) error {
 	ctx := r.Context()
 	db := a.db.WithContext(ctx)
@@ -48,7 +58,8 @@ func (a *API) GenerateLink(w http.ResponseWriter, r *http.Request) error {
 		return badRequestError("Could not parse JSON: %v", err)
 	}
 
-	if err := a.validateEmail(ctx, params.Email); err != nil {
+	params.Email, err = a.validateEmail(ctx, params.Email)
+	if err != nil {
 		return err
 	}
 
@@ -149,7 +160,8 @@ func (a *API) GenerateLink(w http.ResponseWriter, r *http.Request) error {
 			if !config.Mailer.SecureEmailChangeEnabled && params.Type == "email_change_current" {
 				return unprocessableEntityError("Enable secure email change to generate link for current email")
 			}
-			if terr := a.validateEmail(ctx, params.NewEmail); terr != nil {
+			params.NewEmail, terr = a.validateEmail(ctx, params.NewEmail)
+			if terr != nil {
 				return unprocessableEntityError("The new email address provided is invalid")
 			}
 			if exists, terr := models.IsDuplicatedEmail(tx, params.NewEmail, user.Aud); terr != nil {
@@ -186,19 +198,14 @@ func (a *API) GenerateLink(w http.ResponseWriter, r *http.Request) error {
 		return err
 	}
 
-	resp := make(map[string]interface{})
-	u, err := json.Marshal(user)
-	if err != nil {
-		return internalServerError("User serialization error").WithInternalError(err)
-	}
-	if err = json.Unmarshal(u, &resp); err != nil {
-		return internalServerError("User serialization error").WithInternalError(err)
+	resp := GenerateLinkResponse{
+		User:             *user,
+		ActionLink:       url,
+		EmailOtp:         otp,
+		HashedToken:      hashedToken,
+		VerificationType: params.Type,
+		RedirectTo:       referrer,
 	}
-	resp["action_link"] = url
-	resp["email_otp"] = otp
-	resp["hashed_token"] = hashedToken
-	resp["verification_type"] = params.Type
-	resp["redirect_to"] = referrer
 
 	return sendJSON(w, http.StatusOK, resp)
 }
@@ -346,13 +353,13 @@ func (a *API) sendEmailChange(tx *storage.Connection, config *conf.GlobalConfigu
 	), "Database error updating user for email change")
 }
 
-func (a *API) validateEmail(ctx context.Context, email string) error {
+func (a *API) validateEmail(ctx context.Context, email string) (string, error) {
 	if email == "" {
-		return unprocessableEntityError("An email address is required")
+		return "", unprocessableEntityError("An email address is required")
 	}
 	mailer := a.Mailer(ctx)
 	if err := mailer.ValidateEmail(email); err != nil {
-		return unprocessableEntityError("Unable to validate email address: " + err.Error())
+		return "", unprocessableEntityError("Unable to validate email address: " + err.Error())
 	}
-	return nil
+	return strings.ToLower(email), nil
 }
diff --git a/api/mfa.go b/api/mfa.go
new file mode 100644
index 00000000..2faefad6
--- /dev/null
+++ b/api/mfa.go
@@ -0,0 +1,323 @@
+package api
+
+import (
+	"bytes"
+	"encoding/json"
+	"net/http"
+	"time"
+
+	"net/url"
+
+	"github.com/aaronarduino/goqrsvg"
+	svg "github.com/ajstarks/svgo"
+	"github.com/boombuler/barcode/qr"
+	"github.com/gofrs/uuid"
+	"github.com/netlify/gotrue/metering"
+	"github.com/netlify/gotrue/models"
+	"github.com/netlify/gotrue/storage"
+	"github.com/netlify/gotrue/utilities"
+	"github.com/pquerna/otp/totp"
+)
+
+const DefaultQRSize = 3
+
+type EnrollFactorParams struct {
+	FriendlyName string `json:"friendly_name"`
+	FactorType   string `json:"factor_type"`
+	Issuer       string `json:"issuer"`
+}
+
+type TOTPObject struct {
+	QRCode string `json:"qr_code"`
+	Secret string `json:"secret"`
+	URI    string `json:"uri"`
+}
+
+type EnrollFactorResponse struct {
+	ID   uuid.UUID  `json:"id"`
+	Type string     `json:"type"`
+	TOTP TOTPObject `json:"totp,omitempty"`
+}
+
+type VerifyFactorParams struct {
+	ChallengeID uuid.UUID `json:"challenge_id"`
+	Code        string    `json:"code"`
+}
+
+type ChallengeFactorResponse struct {
+	ID        uuid.UUID `json:"id"`
+	ExpiresAt int64     `json:"expires_at"`
+}
+
+type UnenrollFactorResponse struct {
+	ID uuid.UUID `json:"id"`
+}
+
+func (a *API) EnrollFactor(w http.ResponseWriter, r *http.Request) error {
+	ctx := r.Context()
+	user := getUser(ctx)
+	config := a.config
+
+	params := &EnrollFactorParams{}
+	issuer := ""
+	body, err := getBodyBytes(r)
+	if err != nil {
+		return internalServerError("Could not read body").WithInternalError(err)
+	}
+
+	if err := json.Unmarshal(body, params); err != nil {
+		return badRequestError("invalid body: unable to parse JSON").WithInternalError(err)
+	}
+
+	factorType := params.FactorType
+	if factorType != models.TOTP {
+		return badRequestError("factor_type needs to be totp")
+	}
+
+	if params.Issuer == "" {
+		u, err := url.ParseRequestURI(config.SiteURL)
+		if err != nil {
+			return internalServerError("site url is improperly formatted")
+		}
+		issuer = u.Host
+	} else {
+		issuer = params.Issuer
+	}
+
+	// Read from DB for certainty
+	factors, err := models.FindFactorsByUser(a.db, user)
+	if err != nil {
+		return internalServerError("error validating number of factors in system").WithInternalError(err)
+	}
+
+	if len(factors) >= int(config.MFA.MaxEnrolledFactors) {
+		return forbiddenError("Enrolled factors exceed allowed limit, unenroll to continue")
+	}
+	numVerifiedFactors := 0
+
+	// TODO: Remove this at v2
+	for _, factor := range factors {
+		if factor.Status == models.FactorStateVerified {
+			numVerifiedFactors += 1
+		}
+
+	}
+	if numVerifiedFactors >= 1 {
+		return forbiddenError("number of enrolled factors exceeds the allowed value, unenroll to continue")
+
+	}
+
+	key, err := totp.Generate(totp.GenerateOpts{
+		Issuer:      issuer,
+		AccountName: user.GetEmail(),
+	})
+	if err != nil {
+		return internalServerError("error generating QR Code secret key").WithInternalError(err)
+	}
+	var buf bytes.Buffer
+	svgData := svg.New(&buf)
+	qrCode, _ := qr.Encode(key.String(), qr.M, qr.Auto)
+	qs := goqrsvg.NewQrSVG(qrCode, DefaultQRSize)
+	qs.StartQrSVG(svgData)
+	if err = qs.WriteQrSVG(svgData); err != nil {
+		return internalServerError("error writing to QR Code").WithInternalError(err)
+	}
+	svgData.End()
+
+	factor, err := models.NewFactor(user, params.FriendlyName, params.FactorType, models.FactorStateUnverified, key.Secret())
+	if err != nil {
+		return internalServerError("database error creating factor").WithInternalError(err)
+	}
+	err = a.db.Transaction(func(tx *storage.Connection) error {
+		if terr := tx.Create(factor); terr != nil {
+			return terr
+		}
+		if terr := models.NewAuditLogEntry(r, tx, user, models.EnrollFactorAction, r.RemoteAddr, map[string]interface{}{
+			"factor_id": factor.ID,
+		}); terr != nil {
+			return terr
+		}
+		return nil
+	})
+	if err != nil {
+		return err
+	}
+
+	return sendJSON(w, http.StatusOK, &EnrollFactorResponse{
+		ID:   factor.ID,
+		Type: models.TOTP,
+		TOTP: TOTPObject{
+			// See: https://css-tricks.com/probably-dont-base64-svg/
+			QRCode: buf.String(),
+			Secret: factor.Secret,
+			URI:    key.URL(),
+		},
+	})
+}
+
+func (a *API) ChallengeFactor(w http.ResponseWriter, r *http.Request) error {
+	ctx := r.Context()
+	config := a.config
+
+	user := getUser(ctx)
+	factor := getFactor(ctx)
+	ipAddress := utilities.GetIPAddress(r)
+	challenge, err := models.NewChallenge(factor, ipAddress)
+	if err != nil {
+		return internalServerError("Database error creating challenge").WithInternalError(err)
+	}
+
+	err = a.db.Transaction(func(tx *storage.Connection) error {
+		if terr := tx.Create(challenge); terr != nil {
+			return terr
+		}
+		if terr := models.NewAuditLogEntry(r, tx, user, models.CreateChallengeAction, r.RemoteAddr, map[string]interface{}{
+			"factor_id":     factor.ID,
+			"factor_status": factor.Status,
+		}); terr != nil {
+			return terr
+		}
+		return nil
+	})
+	if err != nil {
+		return err
+	}
+
+	creationTime := challenge.CreatedAt
+	expiryTime := creationTime.Add(time.Second * time.Duration(config.MFA.ChallengeExpiryDuration))
+	return sendJSON(w, http.StatusOK, &ChallengeFactorResponse{
+		ID:        challenge.ID,
+		ExpiresAt: expiryTime.Unix(),
+	})
+}
+
+func (a *API) VerifyFactor(w http.ResponseWriter, r *http.Request) error {
+	var err error
+	ctx := r.Context()
+	user := getUser(ctx)
+	factor := getFactor(ctx)
+	config := a.config
+
+	params := &VerifyFactorParams{}
+	currentIP := utilities.GetIPAddress(r)
+
+	body, err := getBodyBytes(r)
+	if err != nil {
+		return internalServerError("Could not read body").WithInternalError(err)
+	}
+
+	if err := json.Unmarshal(body, params); err != nil {
+		return badRequestError("invalid body: unable to parse JSON").WithInternalError(err)
+	}
+
+	challenge, err := models.FindChallengeByChallengeID(a.db, params.ChallengeID)
+	if err != nil {
+		if models.IsNotFoundError(err) {
+			return notFoundError(err.Error())
+		}
+		return internalServerError("Database error finding Challenge").WithInternalError(err)
+	}
+
+	if challenge.VerifiedAt != nil || challenge.IPAddress != currentIP {
+		return badRequestError("Challenge and verify IP addresses mismatch")
+	}
+
+	hasExpired := time.Now().After(challenge.CreatedAt.Add(time.Second * time.Duration(config.MFA.ChallengeExpiryDuration)))
+	if hasExpired {
+		err := a.db.Transaction(func(tx *storage.Connection) error {
+			if terr := tx.Destroy(challenge); terr != nil {
+				return internalServerError("Database error deleting challenge").WithInternalError(terr)
+			}
+
+			return nil
+		})
+		if err != nil {
+			return err
+		}
+		return badRequestError("%v has expired, verify against another challenge or create a new challenge.", challenge.ID)
+	}
+
+	if valid := totp.Validate(params.Code, factor.Secret); !valid {
+		return badRequestError("Invalid TOTP code entered")
+	}
+
+	var token *AccessTokenResponse
+	err = a.db.Transaction(func(tx *storage.Connection) error {
+		var terr error
+		if terr = models.NewAuditLogEntry(r, tx, user, models.VerifyFactorAction, r.RemoteAddr, map[string]interface{}{
+			"factor_id":    factor.ID,
+			"challenge_id": challenge.ID,
+		}); terr != nil {
+			return terr
+		}
+		if terr = challenge.Verify(tx); terr != nil {
+			return terr
+		}
+		if factor.Status != models.FactorStateVerified {
+			if terr = factor.UpdateStatus(tx, models.FactorStateVerified); terr != nil {
+				return terr
+			}
+		}
+		user, terr = models.FindUserByID(tx, user.ID)
+		if terr != nil {
+			return terr
+		}
+		token, terr = a.updateMFASessionAndClaims(r, tx, user, models.TOTPSignIn, models.GrantParams{
+			FactorID: &factor.ID,
+		})
+		if terr != nil {
+			return terr
+		}
+		if terr = a.setCookieTokens(config, token, false, w); terr != nil {
+			return internalServerError("Failed to set JWT cookie. %s", terr)
+		}
+		if terr = models.InvalidateSessionsWithAALLessThan(tx, user.ID, models.AAL2.String()); terr != nil {
+			return internalServerError("Failed to update sessions. %s", terr)
+		}
+		return nil
+	})
+	if err != nil {
+		return err
+	}
+	metering.RecordLogin(string(models.MFACodeLoginAction), user.ID)
+
+	return sendJSON(w, http.StatusOK, token)
+
+}
+
+func (a *API) UnenrollFactor(w http.ResponseWriter, r *http.Request) error {
+	var err error
+	ctx := r.Context()
+	user := getUser(ctx)
+	factor := getFactor(ctx)
+	session := getSession(ctx)
+
+	if factor.Status == models.FactorStateVerified && session.AAL != models.AAL2.String() {
+		return badRequestError("AAL2 required to unenroll verified factor")
+	}
+
+	err = a.db.Transaction(func(tx *storage.Connection) error {
+		var terr error
+		if terr := tx.Destroy(factor); terr != nil {
+			return terr
+		}
+		if terr = models.NewAuditLogEntry(r, tx, user, models.UnenrollFactorAction, r.RemoteAddr, map[string]interface{}{
+			"factor_id":     factor.ID,
+			"factor_status": factor.Status,
+			"session_id":    session.ID,
+		}); terr != nil {
+			return terr
+		}
+		if terr = factor.DowngradeSessionsToAAL1(tx); terr != nil {
+			return terr
+		}
+		return nil
+	})
+	if err != nil {
+		return err
+	}
+
+	return sendJSON(w, http.StatusOK, &UnenrollFactorResponse{
+		ID: factor.ID,
+	})
+}
diff --git a/api/middleware.go b/api/middleware.go
index ff604d96..cd20032b 100644
--- a/api/middleware.go
+++ b/api/middleware.go
@@ -7,6 +7,7 @@ import (
 	"strings"
 	"time"
 
+	"github.com/netlify/gotrue/observability"
 	"github.com/netlify/gotrue/security"
 	"github.com/sirupsen/logrus"
 
@@ -49,11 +50,19 @@ func (f *FunctionHooks) UnmarshalJSON(b []byte) error {
 func (a *API) limitHandler(lmt *limiter.Limiter) middlewareHandler {
 	return func(w http.ResponseWriter, req *http.Request) (context.Context, error) {
 		c := req.Context()
+
 		if limitHeader := a.config.RateLimitHeader; limitHeader != "" {
-			key := req.Header.Get(a.config.RateLimitHeader)
-			err := tollbooth.LimitByKeys(lmt, []string{key})
-			if err != nil {
-				return c, httpError(http.StatusTooManyRequests, "Rate limit exceeded")
+			key := req.Header.Get(limitHeader)
+
+			if key == "" {
+				log := observability.GetLogEntry(req)
+				log.WithField("header", limitHeader).Warn("request does not have a value for the rate limiting header, rate limiting is not applied")
+				return c, nil
+			} else {
+				err := tollbooth.LimitByKeys(lmt, []string{key})
+				if err != nil {
+					return c, httpError(http.StatusTooManyRequests, "Rate limit exceeded")
+				}
 			}
 		}
 		return c, nil
@@ -131,6 +140,9 @@ func (a *API) verifyCaptcha(w http.ResponseWriter, req *http.Request) (context.C
 		// skip captcha validation if authorization header contains an admin role
 		return ctx, nil
 	}
+	if shouldIgnore := isIgnoreCaptchaRoute(req); shouldIgnore {
+		return ctx, nil
+	}
 	if config.Security.Captcha.Provider != "hcaptcha" {
 		logrus.WithField("provider", config.Security.Captcha.Provider).Warn("Unsupported captcha provider")
 		return nil, internalServerError("server misconfigured")
@@ -155,3 +167,11 @@ func (a *API) verifyCaptcha(w http.ResponseWriter, req *http.Request) (context.C
 	}
 	return ctx, nil
 }
+
+func isIgnoreCaptchaRoute(req *http.Request) bool {
+	// captcha shouldn't be enabled on requests to refresh the token
+	if req.URL.Path == "/token" && req.FormValue("grant_type") == "refresh_token" {
+		return true
+	}
+	return false
+}
diff --git a/api/tracer.go b/api/opentracer.go
similarity index 91%
rename from api/tracer.go
rename to api/opentracer.go
index 64652b8a..65bdcf65 100644
--- a/api/tracer.go
+++ b/api/opentracer.go
@@ -8,7 +8,7 @@ import (
 	"github.com/opentracing/opentracing-go"
 	"github.com/opentracing/opentracing-go/ext"
 	ddtrace_ext "gopkg.in/DataDog/dd-trace-go.v1/ddtrace/ext"
-	"gopkg.in/DataDog/dd-trace-go.v1/ddtrace/opentracer"
+	ddopentracer "gopkg.in/DataDog/dd-trace-go.v1/ddtrace/opentracer"
 )
 
 type tracingResponseWriter struct {
@@ -25,12 +25,12 @@ func (trw *tracingResponseWriter) WriteHeader(code int) {
 	trw.ResponseWriter.WriteHeader(code)
 }
 
-func tracer(next http.Handler) http.Handler {
+func opentracer(next http.Handler) http.Handler {
 	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
 		clientContext, _ := opentracing.GlobalTracer().Extract(opentracing.HTTPHeaders, opentracing.HTTPHeadersCarrier(r.Header))
 		span, traceCtx := opentracing.StartSpanFromContext(r.Context(), "http.handler",
 			ext.RPCServerOption(clientContext),
-			opentracer.SpanType(ddtrace_ext.AppTypeWeb),
+			ddopentracer.SpanType(ddtrace_ext.AppTypeWeb),
 		)
 		defer span.Finish()
 
diff --git a/api/otp.go b/api/otp.go
index d528baad..f565310d 100644
--- a/api/otp.go
+++ b/api/otp.go
@@ -171,7 +171,8 @@ func (a *API) shouldCreateUser(r *http.Request, params *OtpParams) (bool, error)
 		aud := a.requestAud(ctx, r)
 		var err error
 		if params.Email != "" {
-			if err := a.validateEmail(ctx, params.Email); err != nil {
+			params.Email, err = a.validateEmail(ctx, params.Email)
+			if err != nil {
 				return false, err
 			}
 			_, err = models.FindUserByEmailAndAudience(db, params.Email, aud)
diff --git a/api/recover.go b/api/recover.go
index 62310473..b8e0c694 100644
--- a/api/recover.go
+++ b/api/recover.go
@@ -37,7 +37,8 @@ func (a *API) Recover(w http.ResponseWriter, r *http.Request) error {
 	var user *models.User
 	aud := a.requestAud(ctx, r)
 
-	if err := a.validateEmail(ctx, params.Email); err != nil {
+	params.Email, err = a.validateEmail(ctx, params.Email)
+	if err != nil {
 		return err
 	}
 	user, err = models.FindUserByEmailAndAudience(db, params.Email, aud)
diff --git a/api/settings.go b/api/settings.go
index 4ba862cc..bf840b2e 100644
--- a/api/settings.go
+++ b/api/settings.go
@@ -31,6 +31,7 @@ type Settings struct {
 	MailerAutoconfirm bool             `json:"mailer_autoconfirm"`
 	PhoneAutoconfirm  bool             `json:"phone_autoconfirm"`
 	SmsProvider       string           `json:"sms_provider"`
+	MFAEnabled        bool             `json:"mfa_enabled"`
 }
 
 func (a *API) Settings(w http.ResponseWriter, r *http.Request) error {
@@ -63,5 +64,6 @@ func (a *API) Settings(w http.ResponseWriter, r *http.Request) error {
 		MailerAutoconfirm: config.Mailer.Autoconfirm,
 		PhoneAutoconfirm:  config.Sms.Autoconfirm,
 		SmsProvider:       config.Sms.Provider,
+		MFAEnabled:        config.MFA.Enabled,
 	})
 }
diff --git a/api/signup.go b/api/signup.go
index 88d4d201..4b32bc3b 100644
--- a/api/signup.go
+++ b/api/signup.go
@@ -73,7 +73,8 @@ func (a *API) Signup(w http.ResponseWriter, r *http.Request) error {
 		if !config.External.Email.Enabled {
 			return badRequestError("Email signups are disabled")
 		}
-		if err := a.validateEmail(ctx, params.Email); err != nil {
+		params.Email, err = a.validateEmail(ctx, params.Email)
+		if err != nil {
 			return err
 		}
 		user, err = models.FindUserByEmailAndAudience(db, params.Email, params.Aud)
@@ -220,7 +221,12 @@ func (a *API) Signup(w http.ResponseWriter, r *http.Request) error {
 				return terr
 			}
 
-			token, terr = a.issueRefreshToken(ctx, tx, user, grantParams)
+			if config.MFA.Enabled {
+				token, terr = a.MFA_issueRefreshToken(ctx, tx, user, models.PasswordGrant, grantParams)
+			} else {
+				token, terr = a.issueRefreshToken(ctx, tx, user, grantParams)
+			}
+
 			if terr != nil {
 				return terr
 			}
@@ -324,5 +330,13 @@ func (a *API) signupNewUser(ctx context.Context, conn *storage.Connection, param
 		return nil, err
 	}
 
+	// sometimes there may be triggers in the database that will modify the
+	// user data as it is being inserted. thus we load the user object
+	// again to fetch those changes.
+	err = conn.Eager().Load(user)
+	if err != nil {
+		return nil, internalServerError("Database error loading user after sign-up").WithInternalError(err)
+	}
+
 	return user, nil
 }
diff --git a/api/token.go b/api/token.go
index d79051a2..29d59cfa 100644
--- a/api/token.go
+++ b/api/token.go
@@ -22,12 +22,14 @@ import (
 // GoTrueClaims is a struct thats used for JWT claims
 type GoTrueClaims struct {
 	jwt.StandardClaims
-	Email        string                 `json:"email"`
-	Phone        string                 `json:"phone"`
-	AppMetaData  map[string]interface{} `json:"app_metadata"`
-	UserMetaData map[string]interface{} `json:"user_metadata"`
-	Role         string                 `json:"role"`
-	SessionId    string                 `json:"session_id,omitempty"`
+	Email                         string                 `json:"email"`
+	Phone                         string                 `json:"phone"`
+	AppMetaData                   map[string]interface{} `json:"app_metadata"`
+	UserMetaData                  map[string]interface{} `json:"user_metadata"`
+	Role                          string                 `json:"role"`
+	AuthenticatorAssuranceLevel   string                 `json:"aal,omitempty"`
+	AuthenticationMethodReference []models.AMREntry      `json:"amr,omitempty"`
+	SessionId                     string                 `json:"session_id,omitempty"`
 }
 
 // AccessTokenResponse represents an OAuth2 success response
@@ -72,6 +74,9 @@ func (p *IdTokenGrantParams) getVerifier(ctx context.Context, config *conf.Globa
 	case "apple":
 		oAuthProvider = config.External.Apple
 		oAuthProviderClientId = config.External.IosBundleId
+		if oAuthProviderClientId == "" {
+			oAuthProviderClientId = oAuthProvider.ClientID
+		}
 		provider, err = oidc.NewProvider(ctx, "https://appleid.apple.com")
 	case "azure":
 		oAuthProvider = config.External.Azure
@@ -221,8 +226,12 @@ func (a *API) ResourceOwnerPasswordGrant(ctx context.Context, w http.ResponseWri
 		if terr = triggerEventHooks(ctx, tx, LoginEvent, user, config); terr != nil {
 			return terr
 		}
+		if config.MFA.Enabled {
+			token, terr = a.MFA_issueRefreshToken(ctx, tx, user, models.PasswordGrant, grantParams)
+		} else {
+			token, terr = a.issueRefreshToken(ctx, tx, user, grantParams)
+		}
 
-		token, terr = a.issueRefreshToken(ctx, tx, user, grantParams)
 		if terr != nil {
 			return terr
 		}
@@ -329,8 +338,11 @@ func (a *API) RefreshTokenGrant(ctx context.Context, w http.ResponseWriter, r *h
 				return internalServerError(terr.Error())
 			}
 		}
-
-		tokenString, terr = generateAccessToken(user, newToken.SessionId, time.Second*time.Duration(config.JWT.Exp), config.JWT.Secret)
+		if config.MFA.Enabled {
+			tokenString, terr = MFA_generateAccessToken(tx, user, newToken.SessionId, time.Second*time.Duration(config.JWT.Exp), config.JWT.Secret)
+		} else {
+			tokenString, terr = generateAccessToken(user, newToken.SessionId, time.Second*time.Duration(config.JWT.Exp), config.JWT.Secret)
+		}
 		if terr != nil {
 			return internalServerError("error generating jwt token").WithInternalError(terr)
 		}
@@ -513,8 +525,13 @@ func (a *API) IdTokenGrant(ctx context.Context, w http.ResponseWriter, r *http.R
 				return terr
 			}
 		}
+		if config.MFA.Enabled {
+			token, terr = a.MFA_issueRefreshToken(ctx, tx, user, models.OAuth, grantParams)
+		} else {
+			token, terr = a.issueRefreshToken(ctx, tx, user, grantParams)
+
+		}
 
-		token, terr = a.issueRefreshToken(ctx, tx, user, grantParams)
 		if terr != nil {
 			return oauthError("server_error", terr.Error())
 		}
@@ -556,6 +573,38 @@ func generateAccessToken(user *models.User, sessionId *uuid.UUID, expiresIn time
 	return token.SignedString([]byte(secret))
 }
 
+func MFA_generateAccessToken(tx *storage.Connection, user *models.User, sessionId *uuid.UUID, expiresIn time.Duration, secret string) (string, error) {
+	aal, amr := models.AAL1.String(), []models.AMREntry{}
+	sid := ""
+	if sessionId != nil {
+		sid = sessionId.String()
+		session, terr := models.FindSessionById(tx, *sessionId)
+		if terr != nil {
+			return "", terr
+		}
+		aal, amr = session.CalculateAALAndAMR()
+	}
+
+	claims := &GoTrueClaims{
+		StandardClaims: jwt.StandardClaims{
+			Subject:   user.ID.String(),
+			Audience:  user.Aud,
+			ExpiresAt: time.Now().Add(expiresIn).Unix(),
+		},
+		Email:                         user.GetEmail(),
+		Phone:                         user.GetPhone(),
+		AppMetaData:                   user.AppMetaData,
+		UserMetaData:                  user.UserMetaData,
+		Role:                          user.Role,
+		SessionId:                     sid,
+		AuthenticatorAssuranceLevel:   aal,
+		AuthenticationMethodReference: amr,
+	}
+
+	token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
+	return token.SignedString([]byte(secret))
+}
+
 func (a *API) issueRefreshToken(ctx context.Context, conn *storage.Connection, user *models.User, grantParams models.GrantParams) (*AccessTokenResponse, error) {
 	config := a.config
 
@@ -591,6 +640,112 @@ func (a *API) issueRefreshToken(ctx context.Context, conn *storage.Connection, u
 	}, nil
 }
 
+func (a *API) MFA_issueRefreshToken(ctx context.Context, conn *storage.Connection, user *models.User, authenticationMethod models.AuthenticationMethod, grantParams models.GrantParams) (*AccessTokenResponse, error) {
+	config := a.config
+
+	now := time.Now()
+	user.LastSignInAt = &now
+
+	var tokenString string
+	var refreshToken *models.RefreshToken
+
+	err := conn.Transaction(func(tx *storage.Connection) error {
+		var terr error
+
+		refreshToken, terr = models.GrantAuthenticatedUser(tx, user, grantParams)
+		if terr != nil {
+			return internalServerError("Database error granting user").WithInternalError(terr)
+		}
+
+		session, terr := models.FindSessionById(tx, *refreshToken.SessionId)
+		if terr != nil {
+			return terr
+		}
+		terr = models.AddClaimToSession(tx, session, authenticationMethod)
+		if terr != nil {
+			return terr
+		}
+
+		// TODO(Joel): Replace when feature flag is lifted
+		tokenString, terr = MFA_generateAccessToken(tx, user, refreshToken.SessionId, time.Second*time.Duration(config.JWT.Exp), config.JWT.Secret)
+		if terr != nil {
+			return internalServerError("error generating jwt token").WithInternalError(terr)
+		}
+		return nil
+	})
+	if err != nil {
+		return nil, err
+	}
+
+	return &AccessTokenResponse{
+		Token:        tokenString,
+		TokenType:    "bearer",
+		ExpiresIn:    config.JWT.Exp,
+		RefreshToken: refreshToken.Token,
+		User:         user,
+	}, nil
+}
+
+func (a *API) updateMFASessionAndClaims(r *http.Request, tx *storage.Connection, user *models.User, authenticationMethod models.AuthenticationMethod, grantParams models.GrantParams) (*AccessTokenResponse, error) {
+	ctx := r.Context()
+	config := a.config
+	var tokenString string
+	var refreshToken *models.RefreshToken
+	currentClaims := getClaims(ctx)
+	sessionId, err := uuid.FromString(currentClaims.SessionId)
+	if err != nil {
+		return nil, internalServerError("Cannot read SessionId claim as UUID").WithInternalError(err)
+	}
+	err = tx.Transaction(func(tx *storage.Connection) error {
+		session, terr := models.FindSessionById(tx, sessionId)
+		if terr != nil {
+			return terr
+		}
+		terr = models.AddClaimToSession(tx, session, authenticationMethod)
+		if terr != nil {
+			return terr
+		}
+		session, terr = models.FindSessionById(tx, sessionId)
+		if terr != nil {
+			return terr
+		}
+		currentToken, terr := models.FindTokenBySessionID(tx, &session.ID)
+		if terr != nil {
+			return terr
+		}
+		// Swap to ensure current token is the latest one
+		refreshToken, terr = models.GrantRefreshTokenSwap(r, tx, user, currentToken)
+		if terr != nil {
+			return terr
+		}
+		aal, _ := session.CalculateAALAndAMR()
+		if err := session.UpdateAssociatedFactor(tx, grantParams.FactorID); err != nil {
+			return err
+		}
+		if err := session.UpdateAssociatedAAL(tx, aal); err != nil {
+			return err
+		}
+
+		// TODO(Joel): Replace when feature flag is lifted
+		tokenString, terr = MFA_generateAccessToken(tx, user, &sessionId, time.Second*time.Duration(config.JWT.Exp), config.JWT.Secret)
+
+		if terr != nil {
+			return internalServerError("error generating jwt token").WithInternalError(terr)
+		}
+		return nil
+	})
+	if err != nil {
+		return nil, err
+	}
+	return &AccessTokenResponse{
+		Token:        tokenString,
+		TokenType:    "bearer",
+		ExpiresIn:    config.JWT.Exp,
+		RefreshToken: refreshToken.Token,
+		User:         user,
+	}, nil
+}
+
 // setCookieTokens sets the access_token & refresh_token in the cookies
 func (a *API) setCookieTokens(config *conf.GlobalConfiguration, token *AccessTokenResponse, session bool, w http.ResponseWriter) error {
 	// don't need to catch error here since we always set the cookie name
diff --git a/api/user.go b/api/user.go
index 1a6969ba..9f32c511 100644
--- a/api/user.go
+++ b/api/user.go
@@ -98,7 +98,8 @@ func (a *API) UserUpdate(w http.ResponseWriter, r *http.Request) error {
 		}
 
 		if params.Email != "" && params.Email != user.GetEmail() {
-			if terr = a.validateEmail(ctx, params.Email); terr != nil {
+			params.Email, terr = a.validateEmail(ctx, params.Email)
+			if terr != nil {
 				return terr
 			}
 
diff --git a/api/verify.go b/api/verify.go
index eb69d304..27f70a51 100644
--- a/api/verify.go
+++ b/api/verify.go
@@ -123,7 +123,11 @@ func (a *API) verifyGet(w http.ResponseWriter, r *http.Request) error {
 			return terr
 		}
 
-		token, terr = a.issueRefreshToken(ctx, tx, user, grantParams)
+		if config.MFA.Enabled {
+			token, terr = a.MFA_issueRefreshToken(ctx, tx, user, models.OTP, grantParams)
+		} else {
+			token, terr = a.issueRefreshToken(ctx, tx, user, grantParams)
+		}
 		if terr != nil {
 			return terr
 		}
@@ -220,8 +224,12 @@ func (a *API) verifyPost(w http.ResponseWriter, r *http.Request) error {
 		if terr != nil {
 			return terr
 		}
+		if config.MFA.Enabled {
+			token, terr = a.MFA_issueRefreshToken(ctx, tx, user, models.OTP, grantParams)
+		} else {
+			token, terr = a.issueRefreshToken(ctx, tx, user, grantParams)
 
-		token, terr = a.issueRefreshToken(ctx, tx, user, grantParams)
+		}
 		if terr != nil {
 			return terr
 		}
@@ -475,7 +483,8 @@ func (a *API) verifyUserAndToken(ctx context.Context, conn *storage.Connection,
 			return nil, badRequestError("Invalid sms verification type")
 		}
 	} else if isEmailOtpVerification(params) {
-		if err := a.validateEmail(ctx, params.Email); err != nil {
+		params.Email, err = a.validateEmail(ctx, params.Email)
+		if err != nil {
 			return nil, unprocessableEntityError("Invalid email format").WithInternalError(err)
 		}
 		tokenHash = fmt.Sprintf("%x", sha256.Sum224([]byte(string(params.Email)+params.Token)))
diff --git a/cmd/migrate_cmd.go b/cmd/migrate_cmd.go
index ee6a9313..476e6fa3 100644
--- a/cmd/migrate_cmd.go
+++ b/cmd/migrate_cmd.go
@@ -19,7 +19,7 @@ var migrateCmd = cobra.Command{
 }
 
 func migrate(cmd *cobra.Command, args []string) {
-	globalConfig := loadGlobalConfig()
+	globalConfig := loadGlobalConfig(cmd.Context())
 
 	if globalConfig.DB.Driver == "" && globalConfig.DB.URL != "" {
 		u, err := url.Parse(globalConfig.DB.URL)
diff --git a/cmd/root_cmd.go b/cmd/root_cmd.go
index 9164e712..71810089 100644
--- a/cmd/root_cmd.go
+++ b/cmd/root_cmd.go
@@ -1,11 +1,12 @@
 package cmd
 
 import (
-	"github.com/sirupsen/logrus"
-	"github.com/spf13/cobra"
+	"context"
 
 	"github.com/netlify/gotrue/conf"
 	"github.com/netlify/gotrue/observability"
+	"github.com/sirupsen/logrus"
+	"github.com/spf13/cobra"
 )
 
 var configFile = ""
@@ -13,8 +14,8 @@ var configFile = ""
 var rootCmd = cobra.Command{
 	Use: "gotrue",
 	Run: func(cmd *cobra.Command, args []string) {
-		migrate(&migrateCmd, args)
-		execWithConfig(cmd, serve)
+		migrate(cmd, args)
+		serve(cmd.Context())
 	},
 }
 
@@ -26,7 +27,11 @@ func RootCommand() *cobra.Command {
 	return &rootCmd
 }
 
-func loadGlobalConfig() *conf.GlobalConfiguration {
+func loadGlobalConfig(ctx context.Context) *conf.GlobalConfiguration {
+	if ctx == nil {
+		panic("context must not be nil")
+	}
+
 	config, err := conf.LoadGlobal(configFile)
 	if err != nil {
 		logrus.Fatalf("Failed to load configuration: %+v", err)
@@ -36,17 +41,17 @@ func loadGlobalConfig() *conf.GlobalConfiguration {
 		logrus.WithError(err).Error("unable to configure logging")
 	}
 
-	if err := observability.ConfigureTracing(&config.Tracing); err != nil {
+	if err := observability.ConfigureTracing(ctx, &config.Tracing); err != nil {
 		logrus.WithError(err).Error("unable to configure tracing")
 	}
 
-	return config
-}
+	if err := observability.ConfigureMetrics(ctx, &config.Metrics); err != nil {
+		logrus.WithError(err).Error("unable to configure metrics")
+	}
 
-func execWithConfig(cmd *cobra.Command, fn func(config *conf.GlobalConfiguration)) {
-	fn(loadGlobalConfig())
+	return config
 }
 
 func execWithConfigAndArgs(cmd *cobra.Command, fn func(config *conf.GlobalConfiguration, args []string), args []string) {
-	fn(loadGlobalConfig(), args)
+	fn(loadGlobalConfig(cmd.Context()), args)
 }
diff --git a/cmd/serve_cmd.go b/cmd/serve_cmd.go
index f23fe73f..448c15ab 100644
--- a/cmd/serve_cmd.go
+++ b/cmd/serve_cmd.go
@@ -2,7 +2,7 @@ package cmd
 
 import (
 	"context"
-	"fmt"
+	"net"
 
 	"github.com/netlify/gotrue/api"
 	"github.com/netlify/gotrue/conf"
@@ -16,20 +16,26 @@ var serveCmd = cobra.Command{
 	Use:  "serve",
 	Long: "Start API server",
 	Run: func(cmd *cobra.Command, args []string) {
-		execWithConfig(cmd, serve)
+		serve(cmd.Context())
 	},
 }
 
-func serve(config *conf.GlobalConfiguration) {
+func serve(ctx context.Context) {
+	config, err := conf.LoadGlobal(configFile)
+	if err != nil {
+		logrus.WithError(err).Fatal("unable to load config")
+	}
+
 	db, err := storage.Dial(config)
 	if err != nil {
-		logrus.Fatalf("Error opening database: %+v", err)
+		logrus.Fatalf("error opening database: %+v", err)
 	}
 	defer db.Close()
 
-	api := api.NewAPIWithVersion(context.Background(), config, db, utilities.Version)
+	api := api.NewAPIWithVersion(ctx, config, db, utilities.Version)
+
+	addr := net.JoinHostPort(config.API.Host, config.API.Port)
+	logrus.Infof("GoTrue API started on: %s", addr)
 
-	l := fmt.Sprintf("%v:%v", config.API.Host, config.API.Port)
-	logrus.Infof("GoTrue API started on: %s", l)
-	api.ListenAndServe(l)
+	api.ListenAndServe(ctx, addr)
 }
diff --git a/conf/configuration.go b/conf/configuration.go
index 960ae464..fef348f9 100644
--- a/conf/configuration.go
+++ b/conf/configuration.go
@@ -12,6 +12,7 @@ import (
 )
 
 const defaultMinPasswordLength int = 6
+const defaultChallengeExpiryDuration float64 = 300
 
 // OAuthProviderConfiguration holds all config related to external account providers.
 type OAuthProviderConfiguration struct {
@@ -51,9 +52,17 @@ type JWTConfiguration struct {
 	DefaultGroupName string   `json:"default_group_name" split_words:"true"`
 }
 
+// MFAConfiguration holds all the MFA related Configuration
+type MFAConfiguration struct {
+	Enabled                     bool    `default:"false"`
+	ChallengeExpiryDuration     float64 `json:"challenge_expiry_duration" default:"300" split_words:"true"`
+	RateLimitChallengeAndVerify float64 `split_words:"true" default:"15"`
+	MaxEnrolledFactors          float64 `split_words:"true" default:"10"`
+}
+
 type APIConfiguration struct {
 	Host            string
-	Port            int `envconfig:"PORT" default:"8081"`
+	Port            string `envconfig:"PORT" default:"8081"`
 	Endpoint        string
 	RequestIDHeader string `envconfig:"REQUEST_ID_HEADER"`
 	ExternalURL     string `json:"external_url" envconfig:"API_EXTERNAL_URL"`
@@ -80,6 +89,7 @@ type GlobalConfiguration struct {
 	Logging               LoggingConfig `envconfig:"LOG"`
 	OperatorToken         string        `split_words:"true" required:"false"`
 	Tracing               TracingConfig
+	Metrics               MetricsConfig
 	SMTP                  SMTPConfiguration
 	RateLimitHeader       string  `split_words:"true"`
 	RateLimitEmailSent    float64 `split_words:"true" default:"30"`
@@ -96,6 +106,7 @@ type GlobalConfiguration struct {
 	DisableSignup     bool                     `json:"disable_signup" split_words:"true"`
 	Webhook           WebhookConfig            `json:"webhook" split_words:"true"`
 	Security          SecurityConfiguration    `json:"security"`
+	MFA               MFAConfiguration         `json:"MFA"`
 	Cookie            struct {
 		Key      string `json:"key"`
 		Domain   string `json:"domain"`
@@ -162,7 +173,7 @@ type MailerConfiguration struct {
 }
 
 type PhoneProviderConfiguration struct {
-	Enabled bool `json:"enabled"`
+	Enabled bool `json:"enabled" default:"false"`
 }
 
 type SmsProviderConfiguration struct {
@@ -244,7 +255,6 @@ func (w *WebhookConfig) HasEvent(event string) bool {
 	return false
 }
 
-// LoadGlobal loads configuration from file and environment variables.
 func LoadGlobal(filename string) (*GlobalConfiguration, error) {
 	if err := loadEnvironment(filename); err != nil {
 		return nil, err
@@ -353,6 +363,9 @@ func (config *GlobalConfiguration) ApplyDefaults() error {
 	if config.PasswordMinLength < defaultMinPasswordLength {
 		config.PasswordMinLength = defaultMinPasswordLength
 	}
+	if config.MFA.ChallengeExpiryDuration < defaultChallengeExpiryDuration {
+		config.MFA.ChallengeExpiryDuration = defaultChallengeExpiryDuration
+	}
 
 	return nil
 }
@@ -365,6 +378,7 @@ func (c *GlobalConfiguration) Validate() error {
 		&c.API,
 		&c.DB,
 		&c.Tracing,
+		&c.Metrics,
 		&c.SMTP,
 	}
 
diff --git a/conf/metrics.go b/conf/metrics.go
new file mode 100644
index 00000000..ac6f7ec2
--- /dev/null
+++ b/conf/metrics.go
@@ -0,0 +1,26 @@
+package conf
+
+type MetricsExporter = string
+
+const (
+	Prometheus           MetricsExporter = "prometheus"
+	OpenTelemetryMetrics MetricsExporter = "opentelemetry"
+)
+
+type MetricsConfig struct {
+	Enabled bool
+
+	Exporter MetricsExporter `default:"opentelemetry"`
+
+	// ExporterProtocol is the OTEL_EXPORTER_OTLP_PROTOCOL env variable,
+	// only available when exporter is opentelemetry. See:
+	// https://github.com/open-telemetry/opentelemetry-specification/blob/main/specification/protocol/exporter.md
+	ExporterProtocol string `default:"http/protobuf" envconfig:"OTEL_EXPORTER_OTLP_PROTOCOL"`
+
+	PrometheusListenHost string `default:"0.0.0.0" envconfig:"OTEL_EXPORTER_PROMETHEUS_HOST"`
+	PrometheusListenPort string `default:"9100" envconfig:"OTEL_EXPORTER_PROMETHEUS_PORT"`
+}
+
+func (mc MetricsConfig) Validate() error {
+	return nil
+}
diff --git a/conf/tracing.go b/conf/tracing.go
index eb5cd3fd..717cae47 100644
--- a/conf/tracing.go
+++ b/conf/tracing.go
@@ -1,11 +1,32 @@
 package conf
 
+type TracingExporter = string
+
+const (
+	OpenTelemetryTracing TracingExporter = "opentelemetry"
+	OpenTracing          TracingExporter = "opentracing"
+)
+
 type TracingConfig struct {
-	Enabled     bool `default:"false"`
-	Host        string
-	Port        string
+	Enabled  bool
+	Exporter TracingExporter `default:"opentelemetry"`
+
+	// ExporterProtocol is the OTEL_EXPORTER_OTLP_PROTOCOL env variable,
+	// only available when exporter is opentelemetry. See:
+	// https://github.com/open-telemetry/opentelemetry-specification/blob/main/specification/protocol/exporter.md
+	ExporterProtocol string `default:"http/protobuf" envconfig:"OTEL_EXPORTER_OTLP_PROTOCOL"`
+
+	// Host is the host of the OpenTracing collector.
+	Host string
+
+	// Port is the port of the OpenTracing collector.
+	Port string
+
+	// ServiceName is the service name to use with OpenTracing.
 	ServiceName string `default:"gotrue" split_words:"true"`
-	Tags        map[string]string
+
+	// Tags are the tags to associate with OpenTracing.
+	Tags map[string]string
 }
 
 func (tc *TracingConfig) Validate() error {
diff --git a/crypto/crypto.go b/crypto/crypto.go
index 6912a75b..70980c16 100644
--- a/crypto/crypto.go
+++ b/crypto/crypto.go
@@ -13,8 +13,12 @@ import (
 )
 
 // SecureToken creates a new random token
-func SecureToken() string {
-	b := make([]byte, 16)
+func SecureToken(options ...int) string {
+	length := 16
+	if len(options) > 0 {
+		length = options[0]
+	}
+	b := make([]byte, length)
 	if _, err := io.ReadFull(rand.Reader, b); err != nil {
 		panic(err.Error()) // rand should never fail
 	}
diff --git a/docker-compose-dev.yml b/docker-compose-dev.yml
index 53cf44b9..fb2a8292 100644
--- a/docker-compose-dev.yml
+++ b/docker-compose-dev.yml
@@ -9,6 +9,7 @@ services:
       dockerfile: Dockerfile.dev
     ports:
       - '9999:9999'
+      - '9100:9100'
     environment:
       - GOTRUE_DB_MIGRATIONS_PATH=/go/src/github.com/netlify/gotrue/migrations
     volumes:
diff --git a/docs/admin.go b/docs/admin.go
new file mode 100644
index 00000000..5fdabff0
--- /dev/null
+++ b/docs/admin.go
@@ -0,0 +1,106 @@
+//lint:file-ignore U1000 ignore go-swagger template
+package docs
+
+import (
+	"github.com/netlify/gotrue/api"
+)
+
+// swagger:route GET /admin/users admin admin-list-users
+// List all users.
+// security:
+//   - bearer:
+// responses:
+//   200: adminListUserResponse
+// 	 401: unauthorizedError
+
+// The list of users.
+// swagger:response adminListUserResponse
+type adminListUserResponseWrapper struct {
+	// in:body
+	Body api.AdminListUsersResponse
+}
+
+// swagger:route POST /admin/users admin admin-create-user
+// Returns the created user.
+// security:
+//   - bearer:
+// responses:
+//   200: userResponse
+// 	 401: unauthorizedError
+
+// The user to be created.
+// swagger:parameters admin-create-user
+type adminUserParamsWrapper struct {
+	// in:body
+	Body api.AdminUserParams
+}
+
+// swagger:route GET /admin/user/{user_id} admin admin-get-user
+// Get a user.
+// security:
+//   - bearer:
+// parameters:
+// + name: user_id
+//   in: path
+//   description: The user's id
+//   required: true
+// responses:
+//   200: userResponse
+// 	 401: unauthorizedError
+
+// The user specified.
+// swagger:response userResponse
+
+// swagger:route PUT /admin/user/{user_id} admin admin-update-user
+// Update a user.
+// security:
+//   - bearer:
+// parameters:
+// + name: user_id
+//   in: path
+//   description: The user's id
+//   required: true
+// responses:
+//   200: userResponse
+// 	 401: unauthorizedError
+
+// The updated user.
+// swagger:response userResponse
+
+// swagger:route DELETE /admin/user/{user_id} admin admin-delete-user
+// Deletes a user.
+// security:
+//   - bearer:
+// parameters:
+// + name: user_id
+//   in: path
+//   description: The user's id
+//   required: true
+// responses:
+//   200: deleteUserResponse
+// 	 401: unauthorizedError
+
+// The updated user.
+// swagger:response deleteUserResponse
+type deleteUserResponseWrapper struct{}
+
+// swagger:route POST /admin/generate_link admin admin-generate-link
+// Generates an email action link.
+// security:
+//   - bearer:
+// responses:
+//   200: generateLinkResponse
+// 	 401: unauthorizedError
+
+// swagger:parameters admin-generate-link
+type generateLinkParams struct {
+	// in:body
+	Body api.GenerateLinkParams
+}
+
+// The response object for generate link.
+// swagger:response generateLinkResponse
+type generateLinkResponseWrapper struct {
+	// in:body
+	Body api.GenerateLinkResponse
+}
diff --git a/docs/doc.go b/docs/doc.go
new file mode 100644
index 00000000..52e4302c
--- /dev/null
+++ b/docs/doc.go
@@ -0,0 +1,20 @@
+// Package classification gotrue
+//
+// Documentation of the gotrue API.
+//
+//     Schemes: http, https
+//     BasePath: /
+//     Version: 1.0.0
+//     Host: localhost:9999
+//
+//     SecurityDefinitions:
+//     bearer:
+//       type: apiKey
+//       name: Authentication
+//       in: header
+//
+//     Produces:
+//     - application/json
+//
+// swagger:meta
+package docs
diff --git a/docs/errors.go b/docs/errors.go
new file mode 100644
index 00000000..a4064458
--- /dev/null
+++ b/docs/errors.go
@@ -0,0 +1,6 @@
+//lint:file-ignore U1000 ignore go-swagger template
+package docs
+
+// This endpoint requires a bearer token.
+// swagger:response unauthorizedError
+type unauthorizedError struct{}
diff --git a/docs/health.go b/docs/health.go
new file mode 100644
index 00000000..08c2fcc3
--- /dev/null
+++ b/docs/health.go
@@ -0,0 +1,15 @@
+//lint:file-ignore U1000 ignore go-swagger template
+package docs
+
+import "github.com/netlify/gotrue/api"
+
+// swagger:route GET /health health health
+// The healthcheck endpoint for gotrue. Returns the current gotrue version.
+// responses:
+//   200: healthCheckResponse
+
+// swagger:response healthCheckResponse
+type healthCheckResponseWrapper struct {
+	// in:body
+	Body api.HealthCheckResponse
+}
diff --git a/docs/invite.go b/docs/invite.go
new file mode 100644
index 00000000..fb6b93e3
--- /dev/null
+++ b/docs/invite.go
@@ -0,0 +1,18 @@
+//lint:file-ignore U1000 ignore go-swagger template
+package docs
+
+import "github.com/netlify/gotrue/api"
+
+// swagger:route POST /invite invite invite
+// Sends an invite link to the user.
+// responses:
+//   200: inviteResponse
+
+// swagger:parameters invite
+type inviteParamsWrapper struct {
+	// in:body
+	Body api.InviteParams
+}
+
+// swagger:response inviteResponse
+type inviteResponseWrapper struct{}
diff --git a/docs/logout.go b/docs/logout.go
new file mode 100644
index 00000000..f0b11257
--- /dev/null
+++ b/docs/logout.go
@@ -0,0 +1,12 @@
+//lint:file-ignore U1000 ignore go-swagger template
+package docs
+
+// swagger:route POST /logout logout logout
+// Logs out the user.
+// security:
+//   - bearer:
+// responses:
+//   204: logoutResponse
+
+// swagger:response logoutResponse
+type logoutResponseWrapper struct{}
diff --git a/docs/oauth.go b/docs/oauth.go
new file mode 100644
index 00000000..9b3c223b
--- /dev/null
+++ b/docs/oauth.go
@@ -0,0 +1,25 @@
+//lint:file-ignore U1000 ignore go-swagger template
+package docs
+
+// swagger:route GET /authorize oauth authorize
+// Redirects the user to the 3rd-party OAuth provider to start the OAuth1.0 or OAuth2.0 authentication process.
+// parameters:
+// + name: redirect_to
+//   in: query
+//   description: The redirect url to return the user to after the `/callback` endpoint has completed.
+//   required: false
+// responses:
+//   302: authorizeResponse
+
+// Redirects user to the 3rd-party OAuth provider
+// swagger:response authorizeResponse
+type authorizeResponseWrapper struct{}
+
+// swagger:route GET /callback oauth callback
+// Receives the redirect from an external provider during the OAuth authentication process. Starts the process of creating an access and refresh token.
+// responses:
+//   302: callbackResponse
+
+// Redirects user to the redirect url specified in `/authorize`. If no `redirect_url` is provided, the user will be redirected to the `SITE_URL`.
+// swagger:response callbackResponse
+type callbackResponseWrapper struct{}
diff --git a/docs/otp.go b/docs/otp.go
new file mode 100644
index 00000000..3998900d
--- /dev/null
+++ b/docs/otp.go
@@ -0,0 +1,19 @@
+//lint:file-ignore U1000 ignore go-swagger template
+package docs
+
+import "github.com/netlify/gotrue/api"
+
+// swagger:route POST /otp otp otp
+// Passwordless sign-in method for email or phone.
+// responses:
+//   200: otpResponse
+
+// swagger:parameters otp
+type otpParamsWrapper struct {
+	// Only an email or phone should be provided.
+	// in:body
+	Body api.OtpParams
+}
+
+// swagger:response otpResponse
+type otpResponseWrapper struct{}
diff --git a/docs/recover.go b/docs/recover.go
new file mode 100644
index 00000000..d3d39cb0
--- /dev/null
+++ b/docs/recover.go
@@ -0,0 +1,18 @@
+//lint:file-ignore U1000 ignore go-swagger template
+package docs
+
+import "github.com/netlify/gotrue/api"
+
+// swagger:route POST /recover recovery recovery
+// Sends a password recovery email link to the user's email.
+// responses:
+//   200: recoveryResponse
+
+// swagger:parameters recovery
+type recoveryParamsWrapper struct {
+	// in:body
+	Body api.RecoverParams
+}
+
+// swagger:response recoveryResponse
+type recoveryResponseWrapper struct{}
diff --git a/docs/settings.go b/docs/settings.go
new file mode 100644
index 00000000..56c95171
--- /dev/null
+++ b/docs/settings.go
@@ -0,0 +1,15 @@
+//lint:file-ignore U1000 ignore go-swagger template
+package docs
+
+import "github.com/netlify/gotrue/api"
+
+// swagger:route GET /settings settings settings
+// Returns the configuration settings for the gotrue server.
+// responses:
+//   200: settingsResponse
+
+// swagger:response settingsResponse
+type settingsResponseWrapper struct {
+	// in:body
+	Body api.Settings
+}
diff --git a/docs/signup.go b/docs/signup.go
new file mode 100644
index 00000000..d8022c4f
--- /dev/null
+++ b/docs/signup.go
@@ -0,0 +1,17 @@
+//lint:file-ignore U1000 ignore go-swagger template
+package docs
+
+import (
+	"github.com/netlify/gotrue/api"
+)
+
+// swagger:route POST /signup signup signup
+// Password-based signup with either email or phone.
+// responses:
+//   200: userResponse
+
+// swagger:parameters signup
+type signupParamsWrapper struct {
+	// in:body
+	Body api.SignupParams
+}
diff --git a/docs/token.go b/docs/token.go
new file mode 100644
index 00000000..fdf5fa60
--- /dev/null
+++ b/docs/token.go
@@ -0,0 +1,34 @@
+//lint:file-ignore U1000 ignore go-swagger template
+package docs
+
+import (
+	"github.com/netlify/gotrue/api"
+)
+
+// swagger:route POST /token?grant_type=password token token-password
+// Signs in a user with a password.
+// responses:
+//   200: tokenResponse
+
+// swagger:parameters token-password
+type tokenPasswordGrantParamsWrapper struct {
+	// in:body
+	Body api.PasswordGrantParams
+}
+
+// swagger:route POST /token?grant_type=refresh_token token token-refresh
+// Refreshes a user's refresh token.
+// responses:
+//   200: tokenResponse
+
+// swagger:parameters token-refresh
+type tokenRefreshTokenGrantParamsWrapper struct {
+	// in:body
+	Body api.RefreshTokenGrantParams
+}
+
+// swagger:response tokenResponse
+type tokenResponseWrapper struct {
+	// in:body
+	Body api.AccessTokenResponse
+}
diff --git a/docs/user.go b/docs/user.go
new file mode 100644
index 00000000..02cddf4c
--- /dev/null
+++ b/docs/user.go
@@ -0,0 +1,37 @@
+//lint:file-ignore U1000 ignore go-swagger template
+package docs
+
+import (
+	"github.com/netlify/gotrue/api"
+	"github.com/netlify/gotrue/models"
+)
+
+// swagger:route GET /user user user-get
+// Get information for the logged-in user.
+// security:
+//   - bearer:
+// responses:
+//   200: userResponse
+// 	 401: unauthorizedError
+
+// The current user.
+// swagger:response userResponse
+type userResponseWrapper struct {
+	// in:body
+	Body models.User
+}
+
+// swagger:route PUT /user user user-put
+// Returns the updated user.
+// security:
+//   - bearer:
+// responses:
+//   200: userResponse
+// 	 401: unauthorizedError
+
+// The current user.
+// swagger:parameters user-put
+type userUpdateParams struct {
+	// in:body
+	Body api.UserUpdateParams
+}
diff --git a/docs/verify.go b/docs/verify.go
new file mode 100644
index 00000000..fad723f5
--- /dev/null
+++ b/docs/verify.go
@@ -0,0 +1,24 @@
+//lint:file-ignore U1000 ignore go-swagger template
+package docs
+
+import (
+	"github.com/netlify/gotrue/api"
+)
+
+// swagger:route GET /verify verify verify-get
+// Verifies a sign up.
+
+// swagger:parameters verify-get
+type verifyGetParamsWrapper struct {
+	// in:query
+	api.VerifyParams
+}
+
+// swagger:route POST /verify verify verify-post
+// Verifies a sign up.
+
+// swagger:parameters verify-post
+type verifyPostParamsWrapper struct {
+	// in:body
+	Body api.VerifyParams
+}
diff --git a/go.mod b/go.mod
index 7f077e28..5495192a 100644
--- a/go.mod
+++ b/go.mod
@@ -1,11 +1,16 @@
 module github.com/netlify/gotrue
 
 require (
+	github.com/Masterminds/semver/v3 v3.1.1 // indirect
+	github.com/aaronarduino/goqrsvg v0.0.0-20220419053939-17e843f1dd40
+	github.com/ajstarks/svgo v0.0.0-20211024235047-1546f124cd8b
 	github.com/badoux/checkmail v0.0.0-20170203135005-d0a759655d62
+	github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc
 	github.com/coreos/go-oidc/v3 v3.0.0
 	github.com/didip/tollbooth/v5 v5.1.1
 	github.com/go-chi/chi v4.0.2+incompatible
 	github.com/gobuffalo/pop/v5 v5.3.3
+	github.com/gobuffalo/validate/v3 v3.3.0 // indirect
 	github.com/gobwas/glob v0.2.3
 	github.com/gofrs/uuid v4.0.0+incompatible
 	github.com/golang-jwt/jwt v3.2.1+incompatible
@@ -14,49 +19,79 @@ require (
 	github.com/jackc/pgconn v1.8.0
 	github.com/jackc/pgerrcode v0.0.0-20201024163028-a0d42d470451
 	github.com/jackc/pgproto3/v2 v2.0.7 // indirect
-	github.com/jmoiron/sqlx v1.3.1 // indirect
+	github.com/jmoiron/sqlx v1.3.1
 	github.com/joho/godotenv v1.3.0
 	github.com/kelseyhightower/envconfig v1.4.0
 	github.com/lestrrat-go/jwx v0.9.0
+	github.com/lib/pq v1.9.0 // indirect
+	github.com/microcosm-cc/bluemonday v1.0.19 // indirect
 	github.com/mitchellh/mapstructure v1.1.2
 	github.com/mrjones/oauth v0.0.0-20190623134757-126b35219450
 	github.com/netlify/mailme v1.1.1
-	github.com/opentracing/opentracing-go v1.1.0
 	github.com/pkg/errors v0.9.1
+	github.com/pquerna/otp v1.3.0
 	github.com/rs/cors v1.6.0
 	github.com/sebest/xff v0.0.0-20160910043805-6c115e0ffa35
 	github.com/sethvargo/go-password v0.2.0
-	github.com/sirupsen/logrus v1.7.0
+	github.com/sirupsen/logrus v1.8.1
 	github.com/spf13/cobra v1.1.3
-	github.com/stretchr/testify v1.6.1
+	github.com/stretchr/testify v1.8.0
 	golang.org/x/crypto v0.0.0-20201221181555-eec23a3978ad
-	golang.org/x/oauth2 v0.0.0-20200902213428-5d25da1a8d43
-	gopkg.in/DataDog/dd-trace-go.v1 v1.12.1
+	golang.org/x/oauth2 v0.0.0-20211104180415-d3ed0bb246c8
 	gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df
 )
 
+require github.com/gobuffalo/nulls v0.4.0 // indirect
+
+require (
+	github.com/XSAM/otelsql v0.16.0
+	github.com/bombsimon/logrusr/v3 v3.0.0
+	github.com/opentracing/opentracing-go v1.1.0
+	go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.36.0
+	go.opentelemetry.io/contrib/instrumentation/runtime v0.35.0
+	go.opentelemetry.io/otel v1.10.0
+	go.opentelemetry.io/otel/exporters/otlp/otlpmetric v0.31.0
+	go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v0.31.0
+	go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v0.31.0
+	go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.10.0
+	go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.10.0
+	go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.10.0
+	go.opentelemetry.io/otel/exporters/prometheus v0.31.0
+	go.opentelemetry.io/otel/metric v0.32.0
+	go.opentelemetry.io/otel/sdk v1.10.0
+	go.opentelemetry.io/otel/sdk/metric v0.31.0
+	go.opentelemetry.io/otel/trace v1.10.0
+	gopkg.in/DataDog/dd-trace-go.v1 v1.12.1
+	gopkg.in/h2non/gock.v1 v1.1.2
+)
+
+require github.com/jackc/pgx/v4 v4.10.1
+
 require (
-	github.com/Masterminds/semver/v3 v3.1.1 // indirect
 	github.com/aymerick/douceur v0.2.0 // indirect
+	github.com/beorn7/perks v1.0.1 // indirect
+	github.com/cenkalti/backoff/v4 v4.1.3 // indirect
+	github.com/cespare/xxhash/v2 v2.1.2 // indirect
 	github.com/davecgh/go-spew v1.1.1 // indirect
 	github.com/fatih/color v1.10.0 // indirect
 	github.com/fatih/structs v1.1.0 // indirect
+	github.com/felixge/httpsnoop v1.0.3 // indirect
+	github.com/go-logr/logr v1.2.3 // indirect
+	github.com/go-logr/stdr v1.2.2 // indirect
 	github.com/go-sql-driver/mysql v1.5.0 // indirect
 	github.com/gobuffalo/envy v1.9.0 // indirect
 	github.com/gobuffalo/fizz v1.13.0 // indirect
 	github.com/gobuffalo/flect v0.2.2 // indirect
 	github.com/gobuffalo/github_flavored_markdown v1.1.0 // indirect
 	github.com/gobuffalo/helpers v0.6.1 // indirect
-	github.com/gobuffalo/nulls v0.4.0 // indirect
 	github.com/gobuffalo/packd v1.0.0 // indirect
 	github.com/gobuffalo/packr/v2 v2.8.1 // indirect
 	github.com/gobuffalo/plush/v4 v4.1.0 // indirect
 	github.com/gobuffalo/tags/v3 v3.1.0 // indirect
-	github.com/gobuffalo/validate/v3 v3.3.0 // indirect
-	github.com/golang/protobuf v1.4.2 // indirect
-	github.com/google/go-cmp v0.5.2 // indirect
+	github.com/golang/protobuf v1.5.2 // indirect
 	github.com/gorilla/context v1.1.1 // indirect
 	github.com/gorilla/css v1.0.0 // indirect
+	github.com/grpc-ecosystem/grpc-gateway/v2 v2.7.0 // indirect
 	github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542 // indirect
 	github.com/inconshreveable/mousetrap v1.0.0 // indirect
 	github.com/jackc/chunkreader/v2 v2.0.1 // indirect
@@ -64,40 +99,45 @@ require (
 	github.com/jackc/pgpassfile v1.0.0 // indirect
 	github.com/jackc/pgservicefile v0.0.0-20200714003250-2b9c44734f2b // indirect
 	github.com/jackc/pgtype v1.6.2 // indirect
-	github.com/jackc/pgx/v4 v4.10.1 // indirect
 	github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
 	github.com/kr/pretty v0.3.0 // indirect
-	github.com/lib/pq v1.9.0 // indirect
 	github.com/luna-duclos/instrumentedsql v1.1.3 // indirect
 	github.com/mattn/go-colorable v0.1.8 // indirect
 	github.com/mattn/go-isatty v0.0.12 // indirect
 	github.com/mattn/go-sqlite3 v2.0.3+incompatible // indirect
-	github.com/microcosm-cc/bluemonday v1.0.16 // indirect
+	github.com/matttproud/golang_protobuf_extensions v1.0.1 // indirect
 	github.com/netlify/netlify-commons v0.32.0 // indirect
 	github.com/patrickmn/go-cache v2.1.0+incompatible // indirect
 	github.com/philhofer/fwd v1.0.0 // indirect
 	github.com/pmezard/go-difflib v1.0.0 // indirect
+	github.com/prometheus/client_golang v1.12.2 // indirect
+	github.com/prometheus/client_model v0.2.0 // indirect
+	github.com/prometheus/common v0.32.1 // indirect
+	github.com/prometheus/procfs v0.7.3 // indirect
 	github.com/rogpeppe/go-internal v1.8.0 // indirect
 	github.com/sergi/go-diff v1.1.0 // indirect
 	github.com/sourcegraph/annotate v0.0.0-20160123013949-f4cad6c6324d // indirect
 	github.com/sourcegraph/syntaxhighlight v0.0.0-20170531221838-bd320f5d308e // indirect
 	github.com/spf13/pflag v1.0.5 // indirect
-	github.com/stretchr/objx v0.2.0 // indirect
+	github.com/stretchr/objx v0.4.0 // indirect
 	github.com/tinylib/msgp v1.1.0 // indirect
+	go.opentelemetry.io/otel/exporters/otlp/internal/retry v1.10.0 // indirect
+	go.opentelemetry.io/proto/otlp v0.19.0 // indirect
 	golang.org/x/net v0.0.0-20220121210141-e204ce36a2ba // indirect
 	golang.org/x/sync v0.0.0-20201207232520-09787c993a3a // indirect
-	golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e // indirect
+	golang.org/x/sys v0.0.0-20220422013727-9388b58f7150 // indirect
 	golang.org/x/text v0.3.7 // indirect
 	golang.org/x/time v0.0.0-20200416051211-89c76fbcd5d1 // indirect
 	golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect
 	google.golang.org/appengine v1.6.6 // indirect
-	google.golang.org/protobuf v1.25.0 // indirect
+	google.golang.org/genproto v0.0.0-20211118181313-81c1377c94b1 // indirect
+	google.golang.org/grpc v1.46.2 // indirect
+	google.golang.org/protobuf v1.28.0 // indirect
 	gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect
 	gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
-	gopkg.in/h2non/gock.v1 v1.1.2 // indirect
 	gopkg.in/square/go-jose.v2 v2.5.1 // indirect
 	gopkg.in/yaml.v2 v2.4.0 // indirect
-	gopkg.in/yaml.v3 v3.0.0 // indirect
+	gopkg.in/yaml.v3 v3.0.1 // indirect
 )
 
 go 1.18
diff --git a/go.sum b/go.sum
index 3e4fc9df..5d3701eb 100644
--- a/go.sum
+++ b/go.sum
@@ -39,8 +39,20 @@ github.com/Masterminds/semver/v3 v3.0.3/go.mod h1:VPu/7SZ7ePZ3QOrcuXROw5FAcLl4a0
 github.com/Masterminds/semver/v3 v3.1.1 h1:hLg3sBzpNErnxhQtUy/mmLR2I9foDujNK030IGemrRc=
 github.com/Masterminds/semver/v3 v3.1.1/go.mod h1:VPu/7SZ7ePZ3QOrcuXROw5FAcLl4a0cBrbBpGY/8hQs=
 github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU=
+github.com/XSAM/otelsql v0.16.0 h1:pOqeHGYCJmP5ezW0OvAGA+zzdgW/sV8nLHTxVnPgiXU=
+github.com/XSAM/otelsql v0.16.0/go.mod h1:DpO7NCSeqQdr23nU0yapjR3jGx2OdO/PihPRG+/PV0Y=
+github.com/aaronarduino/goqrsvg v0.0.0-20220419053939-17e843f1dd40 h1:uz4N2yHL4MF8vZX+36n+tcxeUf8D/gL4aJkyouhDw4A=
+github.com/aaronarduino/goqrsvg v0.0.0-20220419053939-17e843f1dd40/go.mod h1:dytw+5qs+pdi61fO/S4OmXR7AuEq/HvNCuG03KxQHT4=
+github.com/ajstarks/deck v0.0.0-20200831202436-30c9fc6549a9/go.mod h1:JynElWSGnm/4RlzPXRlREEwqTHAN3T56Bv2ITsFT3gY=
+github.com/ajstarks/deck/generate v0.0.0-20210309230005-c3f852c02e19/go.mod h1:T13YZdzov6OU0A1+RfKZiZN9ca6VeKdBdyDV+BY97Tk=
+github.com/ajstarks/svgo v0.0.0-20211024235047-1546f124cd8b h1:slYM766cy2nI3BwyRiyQj/Ud48djTMtMebDqepE95rw=
+github.com/ajstarks/svgo v0.0.0-20211024235047-1546f124cd8b/go.mod h1:1KcenG0jGWcpt8ov532z81sp/kMMUG485J2InIOyADM=
 github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
+github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
 github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
+github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
+github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho=
+github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY=
 github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o=
 github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8=
 github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY=
@@ -50,16 +62,28 @@ github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuP
 github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
 github.com/badoux/checkmail v0.0.0-20170203135005-d0a759655d62 h1:vMqcPzLT1/mbYew0gM6EJy4/sCNy9lY9rmlFO+pPwhY=
 github.com/badoux/checkmail v0.0.0-20170203135005-d0a759655d62/go.mod h1:r5ZalvRl3tXevRNJkwIB6DC4DD3DMjIlY9NEU1XGoaQ=
+github.com/benbjohnson/clock v1.3.0 h1:ip6w0uFQkncKQ979AypyG0ER7mqUSBdKLOgAle/AT8A=
 github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
 github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8=
+github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
+github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
 github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs=
 github.com/bitly/go-simplejson v0.5.0/go.mod h1:cXHtHw4XUPsvGaxgjIAn8PhEWG9NfngEKAMDJEczWVA=
 github.com/bketelsen/crypt v0.0.3-0.20200106085610-5cbc8cc4026c/go.mod h1:MKsuJmJgSg28kpZDP6UIiPt0e0Oz0kqKNGyRaWEPv84=
 github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869/go.mod h1:Ekp36dRnpXw/yCqJaO+ZrUyxD+3VXMFFr56k5XYrpB4=
+github.com/bombsimon/logrusr/v3 v3.0.0 h1:tcAoLfuAhKP9npBxWzSdpsvKPQt1XV02nSf2lZA82TQ=
+github.com/bombsimon/logrusr/v3 v3.0.0/go.mod h1:PksPPgSFEL2I52pla2glgCyyd2OqOHAnFF5E+g8Ixco=
+github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI=
+github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
 github.com/bugsnag/bugsnag-go v1.5.3/go.mod h1:2oa8nejYd4cQ/b0hMIopN0lCRxU0bueqREvZLWFrtK8=
 github.com/bugsnag/panicwrap v1.2.0/go.mod h1:D/8v3kj0zr8ZAKg1AQ6crr+5VwKN5eIywRkfhyM/+dE=
+github.com/cenkalti/backoff/v4 v4.1.3 h1:cFAlzYUlVYDysBEH2T5hyJZMh3+5+WCBvSnK6Q8UtC4=
+github.com/cenkalti/backoff/v4 v4.1.3/go.mod h1:scbssz8iZGpm3xbr14ovlUdkxfGXNInqkPWOWmG2CLw=
 github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
 github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc=
+github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
+github.com/cespare/xxhash/v2 v2.1.2 h1:YRXhKfTDauu4ajMg1TPgFO5jnlC2HCbmLXMcTG5cbYE=
+github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
 github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
 github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
 github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
@@ -67,6 +91,13 @@ github.com/circonus-labs/circonus-gometrics v2.3.1+incompatible/go.mod h1:nmEj6D
 github.com/circonus-labs/circonusllhist v0.1.3/go.mod h1:kMXHVDlOchFAehlya5ePtbp5jckzBHf4XRpQvBOLI+I=
 github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
 github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
+github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
+github.com/cncf/udpa/go v0.0.0-20210930031921-04548b0d99d4/go.mod h1:6pvJx4me5XPnfI9Z40ddWsdw2W/uZgQLFXToKeRcDiI=
+github.com/cncf/xds/go v0.0.0-20210312221358-fbca930ec8ed/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
+github.com/cncf/xds/go v0.0.0-20210805033703-aa0b78936158/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
+github.com/cncf/xds/go v0.0.0-20210922020428-25de7278fc84/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
+github.com/cncf/xds/go v0.0.0-20211001041855-01bcc9b48dfe/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
+github.com/cncf/xds/go v0.0.0-20211011173535-cb28da3451f1/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
 github.com/cockroachdb/apd v1.1.0 h1:3LFP3629v+1aKXU5Q37mxmRxX/pIu1nijXydLShEq5I=
 github.com/cockroachdb/apd v1.1.0/go.mod h1:8Sl8LxpKi29FqWXR16WEFZRNSz3SoPzUzeMeY4+DwBQ=
 github.com/coreos/bbolt v1.3.2/go.mod h1:iRUV2dpdMOn7Bo10OQBFzIJO9kkE559Wcmn+qkEiiKk=
@@ -94,6 +125,10 @@ github.com/didip/tollbooth/v5 v5.1.1/go.mod h1:d9rzwOULswrD3YIrAQmP3bfjxab32Df4I
 github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
 github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
 github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
+github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk=
+github.com/envoyproxy/go-control-plane v0.9.9-0.20210512163311-63b5d3c536b0/go.mod h1:hliV/p42l8fGbc6Y9bQ70uLwIvmJyVE5k4iMKlh8wCQ=
+github.com/envoyproxy/go-control-plane v0.9.10-0.20210907150352-cf90f659a021/go.mod h1:AFq3mo9L8Lqqiid3OhADV3RfLJnjiw63cSpi+fDTRC0=
+github.com/envoyproxy/go-control-plane v0.10.2-0.20220325020618-49ff273808a1/go.mod h1:KJwIaB5Mv44NWtYuAOFCVOjcI94vtpEz2JU/D2v6IjE=
 github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
 github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=
 github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL+zU=
@@ -101,6 +136,8 @@ github.com/fatih/color v1.10.0 h1:s36xzo75JdqLaaWoiEHk767eHiwo0598uUxyfiPkDsg=
 github.com/fatih/color v1.10.0/go.mod h1:ELkj/draVOlAH/xkhN6mQ50Qd0MPOk5AAr3maGEBuJM=
 github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo=
 github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M=
+github.com/felixge/httpsnoop v1.0.3 h1:s/nj+GCswXYzN5v2DpNMuMQYe+0DDwt5WVCU6CWBdXk=
+github.com/felixge/httpsnoop v1.0.3/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
 github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
 github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
 github.com/globalsign/mgo v0.0.0-20181015135952-eeefdecb41b8/go.mod h1:xkRDCp4j0OGD1HRkm4kmhM+pmpv3AKq5SU7GMg4oO/Q=
@@ -110,8 +147,16 @@ github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9
 github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
 github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
 github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
+github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
+github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY=
 github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE=
 github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk=
+github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A=
+github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
+github.com/go-logr/logr v1.2.3 h1:2DntVwHkVopvECVRSlL5PSo9eG+cAkDCuckLubN+rq0=
+github.com/go-logr/logr v1.2.3/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
+github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
+github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
 github.com/go-sql-driver/mysql v1.4.0/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
 github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
 github.com/go-sql-driver/mysql v1.5.0 h1:ozyZYNQW3x3HtqT1jira07DN2PArx2v7/mN66gGcHOs=
@@ -173,6 +218,8 @@ github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zV
 github.com/golang-jwt/jwt v3.2.1+incompatible h1:73Z+4BJcrTC+KczS6WvTPvRGOp1WmfEP4Q1lOd9Z/+c=
 github.com/golang-jwt/jwt v3.2.1+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I=
 github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
+github.com/golang/glog v1.0.0 h1:nfP3RFugxnNRyKgeWd4oI1nYvXpxrx8ck8ZrcizshdQ=
+github.com/golang/glog v1.0.0/go.mod h1:EWib/APOK0SL3dFbYqvxE3UYd8E6s1ouQ7iEp/0LWV4=
 github.com/golang/groupcache v0.0.0-20190129154638-5b532d6fd5ef/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
 github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
 github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
@@ -196,8 +243,11 @@ github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrU
 github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
 github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
 github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=
-github.com/golang/protobuf v1.4.2 h1:+Z5KGCizgyZCbGh1KZqA0fcLLkwbsjIzS4aV2v7wJX0=
 github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
+github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
+github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
+github.com/golang/protobuf v1.5.2 h1:ROPKBNFfQgOUMifHyP+KYbvpjbdoFNs+aK7DXlji0Tw=
+github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
 github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
 github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
 github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
@@ -207,8 +257,11 @@ github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
 github.com/google/go-cmp v0.4.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
 github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
 github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
-github.com/google/go-cmp v0.5.2 h1:X2ev0eStA3AbceY54o37/0PQ/UWqKEiiO2dKL5OPaFM=
-github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
+github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
+github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
+github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
+github.com/google/go-cmp v0.5.8 h1:e6P7q2lk1O+qJJb4BtCQXlK8vWEO8V1ZeuEdJNOqZyg=
+github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
 github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
 github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
 github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
@@ -219,6 +272,7 @@ github.com/google/pprof v0.0.0-20200229191704-1ebb73c60ed3/go.mod h1:ZgVRPoUq/hf
 github.com/google/pprof v0.0.0-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
 github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
 github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
+github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
 github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
 github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
 github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
@@ -235,6 +289,9 @@ github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/ad
 github.com/grpc-ecosystem/go-grpc-middleware v1.0.0/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs=
 github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk=
 github.com/grpc-ecosystem/grpc-gateway v1.9.0/go.mod h1:vNeuVxBJEsws4ogUvrchl83t/GYV9WGTSLVdBhOQFDY=
+github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw=
+github.com/grpc-ecosystem/grpc-gateway/v2 v2.7.0 h1:BZHcxBETFHIdVyhyEfOvn/RdU/QGdLI4y34qQGjGWO0=
+github.com/grpc-ecosystem/grpc-gateway/v2 v2.7.0/go.mod h1:hgWBS7lorOAVIJEQMi4ZsPv9hVvWI6+ch50m39Pf2Ks=
 github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542 h1:2VTzZjLZBgl62/EtslCrtky5vbi9dd7HrQPQIx6wqiw=
 github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542/go.mod h1:Ow0tF8D4Kplbc8s8sSb3V2oUCygFHVp8gC3Dn6U4MNI=
 github.com/hashicorp/consul/api v1.1.0/go.mod h1:VmuI/Lkw1nC05EYQWNKwWGbkg+FbDBtguAZLlVdkD9Q=
@@ -329,11 +386,16 @@ github.com/jmoiron/sqlx v1.3.1/go.mod h1:2BljVx/86SuTyjE+aPYlHCTNvZrnJXghYGpNiXL
 github.com/joho/godotenv v1.3.0 h1:Zjp+RcGpHhGlrMbJzXTrZZPrWj+1vfm90La1wgB6Bhc=
 github.com/joho/godotenv v1.3.0/go.mod h1:7hK45KPybAkOC6peb+G5yklZfMxEjkZhHbwpqxOKXbg=
 github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22gdxWY5EU2bo=
+github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4=
 github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
+github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
+github.com/json-iterator/go v1.1.11/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
+github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
 github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=
 github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk=
 github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
 github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w=
+github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM=
 github.com/kardianos/osext v0.0.0-20190222173326-2bc1f35cddc0/go.mod h1:1NbS8ALrpOvjt0rHPNLyCIeMtbizbir8U//inJ+zuB8=
 github.com/karrick/godirwalk v1.15.3/go.mod h1:j4mkqPuvaLI8mp1DroR3P6ad7cyYd4c1qeJ3RV7ULlk=
 github.com/karrick/godirwalk v1.15.8/go.mod h1:j4mkqPuvaLI8mp1DroR3P6ad7cyYd4c1qeJ3RV7ULlk=
@@ -347,6 +409,7 @@ github.com/kisielk/errcheck v1.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvW
 github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
 github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
 github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
+github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
 github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc=
 github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
 github.com/kr/pretty v0.2.0/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
@@ -396,10 +459,11 @@ github.com/mattn/go-sqlite3 v1.9.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOq
 github.com/mattn/go-sqlite3 v1.14.6/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
 github.com/mattn/go-sqlite3 v2.0.3+incompatible h1:gXHsfypPkaMZrKbD5209QV9jbUTJKjyR5WD3HYQSd+U=
 github.com/mattn/go-sqlite3 v2.0.3+incompatible/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
+github.com/matttproud/golang_protobuf_extensions v1.0.1 h1:4hp9jkHxhMHkqkrB3Ix0jegS5sx/RkqARlsWZ6pIwiU=
 github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
 github.com/microcosm-cc/bluemonday v1.0.2/go.mod h1:iVP4YcDBq+n/5fb23BhYFvIMq/leAFZyRl6bYmGDlGc=
-github.com/microcosm-cc/bluemonday v1.0.16 h1:kHmAq2t7WPWLjiGvzKa5o3HzSfahUKiOq7fAPUiMNIc=
-github.com/microcosm-cc/bluemonday v1.0.16/go.mod h1:Z0r70sCuXHig8YpBzCc5eGHAap2K7e/u082ZUpDRRqM=
+github.com/microcosm-cc/bluemonday v1.0.19 h1:OI7hoF5FY4pFz2VA//RN8TfM0YJ2dJcl4P4APrCWy6c=
+github.com/microcosm-cc/bluemonday v1.0.19/go.mod h1:QNzV2UbLK2/53oIIwTOyLUSABMkjZ4tqiyC1g/DyqxE=
 github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg=
 github.com/mitchellh/cli v1.0.0/go.mod h1:hNIlj7HEI86fIcpObd7a0FcrxTWetlwJDGcceTlRvqc=
 github.com/mitchellh/go-homedir v1.0.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
@@ -410,11 +474,15 @@ github.com/mitchellh/iochan v1.0.0/go.mod h1:JwYml1nuB7xOzsp52dPpHFffvOCDupsG0Qu
 github.com/mitchellh/mapstructure v0.0.0-20160808181253-ca63d7c062ee/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
 github.com/mitchellh/mapstructure v1.1.2 h1:fmNYVwqnSfB9mZU6OS2O6GsXM+wcskZDuKQzvN1EDeE=
 github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
+github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
 github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
+github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
 github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
+github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
 github.com/mrjones/oauth v0.0.0-20190623134757-126b35219450 h1:j2kD3MT1z4PXCiUllUJF9mWUESr9TWKS7iEKsQ/IipM=
 github.com/mrjones/oauth v0.0.0-20190623134757-126b35219450/go.mod h1:skjdDftzkFALcuGzYSklqYd8gvat6F1gZJ4YPVbkZpM=
 github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
+github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
 github.com/nats-io/jwt v0.2.6/go.mod h1:mQxQ0uHQ9FhEVPIcTSKwx2lqZEpXWWcCgA7R6NrWvvY=
 github.com/nats-io/nats-server/v2 v2.0.0/go.mod h1:RyVdsHHvY4B6c9pWG+uRLpZ0h0XsqiuKp2XCTurP5LI=
 github.com/nats-io/nats-streaming-server v0.15.1/go.mod h1:bJ1+2CS8MqvkGfr/NwnCF+Lw6aLnL3F5kenM8bZmdCw=
@@ -423,6 +491,7 @@ github.com/nats-io/nkeys v0.0.2/go.mod h1:dab7URMsZm6Z/jp9Z5UGa87Uutgc2mVpXLC4B7
 github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
 github.com/nats-io/stan.go v0.4.5/go.mod h1:Ji7mK6gRZJSH1nc3ZJH6vi7zn/QnZhpR9Arm4iuzsUQ=
 github.com/nats-io/stan.go v0.5.0/go.mod h1:dYqB+vMN3C2F9pT1FRQpg9eHbjPj6mP0yYuyBNuXHZE=
+github.com/nbio/st v0.0.0-20140626010706-e9e8d9816f32 h1:W6apQkHrMkS0Muv8G/TipAy/FJl/rCYT0+EuS8+Z0z4=
 github.com/nbio/st v0.0.0-20140626010706-e9e8d9816f32/go.mod h1:9wM+0iRr9ahx58uYLpLIr5fm8diHn0JbqRycJi6w0Ms=
 github.com/netlify/mailme v1.1.1 h1:S/ANl+Hy/EIoJUgGiLJYYLZJ2QOTG452R73qTQudMns=
 github.com/netlify/mailme v1.1.1/go.mod h1:8g03BJmU+ps7ma5vcH+t8aMtaicQTMX3ffP7RJ8xY8g=
@@ -448,21 +517,40 @@ github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE
 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
 github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI=
+github.com/pquerna/otp v1.3.0 h1:oJV/SkzR33anKXwQU3Of42rL4wbrffP4uvUf1SvS5Xs=
+github.com/pquerna/otp v1.3.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
 github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=
 github.com/prometheus/client_golang v0.9.2/go.mod h1:OsXs2jCmiKlQ1lTBmv21f2mNfw4xf/QclQDMrYNZzcM=
 github.com/prometheus/client_golang v0.9.3/go.mod h1:/TN21ttK/J9q6uSwhBd54HahCDft0ttaMvbicHlPoso=
+github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo=
+github.com/prometheus/client_golang v1.7.1/go.mod h1:PY5Wy2awLA44sXw4AOSfFBetzPP4j5+D6mVACh+pe2M=
+github.com/prometheus/client_golang v1.11.0/go.mod h1:Z6t4BnS23TR94PD6BsDNk8yVqroYurpAkEiz0P2BEV0=
+github.com/prometheus/client_golang v1.12.2 h1:51L9cDoUHVrXx4zWYlcLQIZ+d+VXHgqnYKkIuq4g/34=
+github.com/prometheus/client_golang v1.12.2/go.mod h1:3Z9XVyYiZYEO+YQWt3RD2R3jrbd179Rt297l4aS6nDY=
 github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo=
 github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
 github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
+github.com/prometheus/client_model v0.2.0 h1:uq5h0d+GuxiXLJLNABMgp2qUWDPiLvgCzz2dUR+/W/M=
+github.com/prometheus/client_model v0.2.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
 github.com/prometheus/common v0.0.0-20181113130724-41aa239b4cce/go.mod h1:daVV7qP5qjZbuso7PdcryaAu0sAZbrN9i7WWcTMWvro=
 github.com/prometheus/common v0.0.0-20181126121408-4724e9255275/go.mod h1:daVV7qP5qjZbuso7PdcryaAu0sAZbrN9i7WWcTMWvro=
 github.com/prometheus/common v0.4.0/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4=
+github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4=
+github.com/prometheus/common v0.10.0/go.mod h1:Tlit/dnDKsSWFlCLTWaA1cyBgKHSMdTB80sz/V91rCo=
+github.com/prometheus/common v0.26.0/go.mod h1:M7rCNAaPfAosfx8veZJCuw84e35h3Cfd9VFqTh1DIvc=
+github.com/prometheus/common v0.32.1 h1:hWIdL3N2HoUx3B8j3YN9mWor0qhY/NlEKZEaXxuIRh4=
+github.com/prometheus/common v0.32.1/go.mod h1:vu+V0TpY+O6vW9J44gczi3Ap/oXXR10b+M/gUGO4Hls=
 github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=
 github.com/prometheus/procfs v0.0.0-20181204211112-1dc9a6cbc91a/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=
 github.com/prometheus/procfs v0.0.0-20190507164030-5867b95ac084/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA=
 github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA=
+github.com/prometheus/procfs v0.1.3/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4OA4YeYWdaU=
+github.com/prometheus/procfs v0.6.0/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA=
+github.com/prometheus/procfs v0.7.3 h1:4jVXhlkAyzOScmCkXBTOLRLTz8EeU+eyjrwB/EPq0VU=
+github.com/prometheus/procfs v0.7.3/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA=
 github.com/prometheus/tsdb v0.7.1/go.mod h1:qhTCs0VvXwvX/y3TZrWD7rabWM+ijKTux40TwIPHuXU=
 github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg=
+github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ=
 github.com/rogpeppe/go-internal v1.1.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
 github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
 github.com/rogpeppe/go-internal v1.3.2/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
@@ -496,8 +584,9 @@ github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeV
 github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
 github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q=
 github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
-github.com/sirupsen/logrus v1.7.0 h1:ShrD1U9pZB12TX0cVy0DtePoCH97K8EtX+mg7ZARUtM=
-github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
+github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88=
+github.com/sirupsen/logrus v1.8.1 h1:dJKuHgqk1NNQlqoA6BTlM1Wf9DOH3NBjQyu0h9+AZZE=
+github.com/sirupsen/logrus v1.8.1/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
 github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
 github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA=
 github.com/soheilhy/cmux v0.1.4/go.mod h1:IM3LyeVVIOuxMH7sFAkER9+bJ4dT7Ms6E4xg4kGIyLM=
@@ -524,14 +613,17 @@ github.com/spf13/viper v1.4.0/go.mod h1:PTJ7Z/lr49W6bUbkmS1V3by4uWynFiR9p7+dSq/y
 github.com/spf13/viper v1.7.0/go.mod h1:8WkrPz2fc9jxqZNCJI/76HCieCp4Q8HaLFoCha5qpdg=
 github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
 github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
-github.com/stretchr/objx v0.2.0 h1:Hbg2NidpLE8veEBkEZTL3CvlkUIVzuU9jDplZO54c48=
 github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE=
+github.com/stretchr/objx v0.4.0 h1:M2gUjqZET1qApGOWNSnZ49BAIMX4F/1plDv3+l31EJ4=
+github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
 github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
 github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
 github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
 github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
-github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0=
-github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PKk=
+github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
 github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw=
 github.com/tinylib/msgp v1.1.0 h1:9fQd+ICuRIu/ue4vxJZu6/LzxN0HwMds2nq/0cFvxHU=
 github.com/tinylib/msgp v1.1.0/go.mod h1:+d+yLhGm8mzTaHzB+wgMYrodPfmZrzkirds8fDWklFE=
@@ -544,6 +636,7 @@ github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:
 github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
 github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
 github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
+github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
 github.com/zenazn/goji v0.9.0/go.mod h1:7S9M489iMyHBNxwZnk9/EHS098H4/F6TATF2mIxtB1Q=
 go.etcd.io/bbolt v1.3.2/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU=
 go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU=
@@ -551,9 +644,43 @@ go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8=
 go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
 go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
 go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
+go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.36.0 h1:qZ3KzA4qPzLBDtQyPk4ydjlg8zvXbNysnFHaVMKJbVo=
+go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.36.0/go.mod h1:14Oo79mRwusSI02L0EfG3Gp1uF3+1wSL+D4zDysxyqs=
+go.opentelemetry.io/contrib/instrumentation/runtime v0.35.0 h1:VvinIr6FpeUnW0ATkV+NBPT+bMkytiqRuA6Hj+t4Ylc=
+go.opentelemetry.io/contrib/instrumentation/runtime v0.35.0/go.mod h1:7iORGR19PYmn3eq0kEI4qAVdzeXcZiqYyS2pGUnVXXU=
+go.opentelemetry.io/otel v1.10.0 h1:Y7DTJMR6zs1xkS/upamJYk0SxxN4C9AqRd77jmZnyY4=
+go.opentelemetry.io/otel v1.10.0/go.mod h1:NbvWjCthWHKBEUMpf0/v8ZRZlni86PpGFEMA9pnQSnQ=
+go.opentelemetry.io/otel/exporters/otlp/internal/retry v1.10.0 h1:TaB+1rQhddO1sF71MpZOZAuSPW1klK2M8XxfrBMfK7Y=
+go.opentelemetry.io/otel/exporters/otlp/internal/retry v1.10.0/go.mod h1:78XhIg8Ht9vR4tbLNUhXsiOnE2HOuSeKAiAcoVQEpOY=
+go.opentelemetry.io/otel/exporters/otlp/otlpmetric v0.31.0 h1:H0+xwv4shKw0gfj/ZqR13qO2N/dBQogB1OcRjJjV39Y=
+go.opentelemetry.io/otel/exporters/otlp/otlpmetric v0.31.0/go.mod h1:nkenGD8vcvs0uN6WhR90ZVHQlgDsRmXicnNadMnk+XQ=
+go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v0.31.0 h1:BaQ2xM5cPmldVCMvbLoy5tcLUhXCtIhItDYBNw83B7Y=
+go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v0.31.0/go.mod h1:VRr8tlXQEsTdesDCh0qBe2iKDWhpi3ZqDYw6VlZ8MhI=
+go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v0.31.0 h1:MuEG0gG27QZQrqhNl0f7vQ5Nl03OQfFeDAqWkGt+1zM=
+go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v0.31.0/go.mod h1:52qtPFDDaa0FaSyyzPnxWMehx2SZv0xuobTlNEZA2JA=
+go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.10.0 h1:pDDYmo0QadUPal5fwXoY1pmMpFcdyhXOmL5drCrI3vU=
+go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.10.0/go.mod h1:Krqnjl22jUJ0HgMzw5eveuCvFDXY4nSYb4F8t5gdrag=
+go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.10.0 h1:KtiUEhQmj/Pa874bVYKGNVdq8NPKiacPbaRRtgXi+t4=
+go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.10.0/go.mod h1:OfUCyyIiDvNXHWpcWgbF+MWvqPZiNa3YDEnivcnYsV0=
+go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.10.0 h1:S8DedULB3gp93Rh+9Z+7NTEv+6Id/KYS7LDyipZ9iCE=
+go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.10.0/go.mod h1:5WV40MLWwvWlGP7Xm8g3pMcg0pKOUY609qxJn8y7LmM=
+go.opentelemetry.io/otel/exporters/prometheus v0.31.0 h1:jwtnOGBM8dIty5AVZ+9ZCzZexCea3aVKmUfZAQcHqxs=
+go.opentelemetry.io/otel/exporters/prometheus v0.31.0/go.mod h1:QarXIB8L79IwIPoNgG3A6zNvBgVmcppeFogV1d8612s=
+go.opentelemetry.io/otel/metric v0.32.0 h1:lh5KMDB8xlMM4kwE38vlZJ3rZeiWrjw3As1vclfC01k=
+go.opentelemetry.io/otel/metric v0.32.0/go.mod h1:PVDNTt297p8ehm949jsIzd+Z2bIZJYQQG/uuHTeWFHY=
+go.opentelemetry.io/otel/sdk v1.10.0 h1:jZ6K7sVn04kk/3DNUdJ4mqRlGDiXAVuIG+MMENpTNdY=
+go.opentelemetry.io/otel/sdk v1.10.0/go.mod h1:vO06iKzD5baltJz1zarxMCNHFpUlUiOy4s65ECtn6kE=
+go.opentelemetry.io/otel/sdk/metric v0.31.0 h1:2sZx4R43ZMhJdteKAlKoHvRgrMp53V1aRxvEf5lCq8Q=
+go.opentelemetry.io/otel/sdk/metric v0.31.0/go.mod h1:fl0SmNnX9mN9xgU6OLYLMBMrNAsaZQi7qBwprwO3abk=
+go.opentelemetry.io/otel/trace v1.10.0 h1:npQMbR8o7mum8uF95yFbOEJffhs1sbCOfDh8zAJiH5E=
+go.opentelemetry.io/otel/trace v1.10.0/go.mod h1:Sij3YYczqAdz+EhmGhE6TpTxUO5/F/AzrK+kxfGqySM=
+go.opentelemetry.io/proto/otlp v0.7.0/go.mod h1:PqfVotwruBrMGOCsRd/89rSnXhoiJIqeYNgFYFoEGnI=
+go.opentelemetry.io/proto/otlp v0.19.0 h1:IVN6GR+mhC4s5yfcTbmzHYODqvWAp3ZedA2SJPI1Nnw=
+go.opentelemetry.io/proto/otlp v0.19.0/go.mod h1:H7XAot3MsfNsj7EXtrA2q5xSNQ10UqI405h3+duxN4U=
 go.uber.org/atomic v1.3.2/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
 go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
 go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ=
+go.uber.org/goleak v1.1.12 h1:gZAh5/EyT/HQwlpkCy6wTpqfH9H8Lz8zbm3dZh+OyzA=
 go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0=
 go.uber.org/multierr v1.5.0/go.mod h1:FeouvMocqHpRaaGuG9EjoKcStLC43Zu/fmqdUMPcKYU=
 go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee/go.mod h1:vJERXedbb3MVM5f9Ejo0C68/HhF8uaILCdgjnY+goOA=
@@ -643,7 +770,9 @@ golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/
 golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
 golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
 golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
-golang.org/x/net v0.0.0-20210614182718-04defd469f4e/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
+golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
+golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
+golang.org/x/net v0.0.0-20210525063256-abc453219eb5/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
 golang.org/x/net v0.0.0-20220121210141-e204ce36a2ba h1:6u6sik+bn/y7vILcYkK3iwTBWN7WtBvB0+SZswQnbf8=
 golang.org/x/net v0.0.0-20220121210141-e204ce36a2ba/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
 golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
@@ -651,8 +780,9 @@ golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4Iltr
 golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
 golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
 golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
-golang.org/x/oauth2 v0.0.0-20200902213428-5d25da1a8d43 h1:ld7aEMNHoBnnDAX15v1T6z31v8HwR2A9FYOuAhWqkwc=
-golang.org/x/oauth2 v0.0.0-20200902213428-5d25da1a8d43/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
+golang.org/x/oauth2 v0.0.0-20210514164344-f6687ab2804c/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
+golang.org/x/oauth2 v0.0.0-20211104180415-d3ed0bb246c8 h1:RerP+noqYHUQ8CMRcPlC2nvTa4dcBIjegkuWdcUDuqg=
+golang.org/x/oauth2 v0.0.0-20211104180415-d3ed0bb246c8/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
 golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -661,6 +791,7 @@ golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJ
 golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20201207232520-09787c993a3a h1:DcqTD9SDLc+1P/r1EmRBwnVsrOwW+kk2vWf9n+1sGhs=
 golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@@ -689,6 +820,7 @@ golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7w
 golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200106162015-b016eb3dc98e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -702,11 +834,20 @@ golang.org/x/sys v0.0.0-20200501052902-10377860bb8e/go.mod h1:h1NjWce9XRLGQEsW7w
 golang.org/x/sys v0.0.0-20200511232937-7e40ca221e25/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200615200032-f1bc736245b1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200625212154-ddb9806d33ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e h1:fLOSk5Q00efkSvAm+4xcoXD+RRmLmmulPn5I3Y9F2EM=
-golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20210603081109-ebe580a85c40/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220114195835-da31bd327af9/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220422013727-9388b58f7150 h1:xHms4gcpe1YE7A3yIllJXP16CMAGuqwO2lX1mTyyRRc=
+golang.org/x/sys v0.0.0-20220422013727-9388b58f7150/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
 golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 h1:JGgROgKl9N8DuW20oFS5gxc+lE67/N3FcwmBPMe7ArY=
@@ -715,6 +856,7 @@ golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
 golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
 golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
 golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
+golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
 golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
 golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk=
 golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
@@ -774,6 +916,7 @@ golang.org/x/tools v0.0.0-20200618134242-20370b0cb4b2/go.mod h1:EkVYQZoAsY45+roY
 golang.org/x/tools v0.0.0-20200729194436-6467de6f59a7/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
 golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
 golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
+golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0=
 golang.org/x/xerrors v0.0.0-20190410155217-1f06c39b4373/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 golang.org/x/xerrors v0.0.0-20190513163551-3ee3066db522/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
@@ -828,12 +971,15 @@ google.golang.org/genproto v0.0.0-20200312145019-da6875a35672/go.mod h1:55QSHmfG
 google.golang.org/genproto v0.0.0-20200331122359-1ee6d9798940/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
 google.golang.org/genproto v0.0.0-20200430143042-b979b6f78d84/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
 google.golang.org/genproto v0.0.0-20200511104702-f5ebc3bea380/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
+google.golang.org/genproto v0.0.0-20200513103714-09dca8ec2884/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
 google.golang.org/genproto v0.0.0-20200515170657-fc4c6c6a6587/go.mod h1:YsZOwe1myG/8QRHRsmBRE1LrgQY60beZKjly0O1fX9U=
 google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
 google.golang.org/genproto v0.0.0-20200618031413-b414f8b61790/go.mod h1:jDfRM7FcilCzHH/e9qn6dsT145K34l5v+OpcnNgKAAA=
 google.golang.org/genproto v0.0.0-20200729003335-053ba62fc06f/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
 google.golang.org/genproto v0.0.0-20200804131852-c06518451d9c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
 google.golang.org/genproto v0.0.0-20200825200019-8632dd797987/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
+google.golang.org/genproto v0.0.0-20211118181313-81c1377c94b1 h1:b9mVrqYfq3P4bCdaLg1qtBnPzUYgglsIdjZkL/fQVOE=
+google.golang.org/genproto v0.0.0-20211118181313-81c1377c94b1/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
 google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
 google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
 google.golang.org/grpc v1.21.0/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
@@ -847,6 +993,12 @@ google.golang.org/grpc v1.28.0/go.mod h1:rpkK4SK4GF4Ach/+MFLZUBavHOvF2JJB5uozKKa
 google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk=
 google.golang.org/grpc v1.30.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
 google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
+google.golang.org/grpc v1.33.1/go.mod h1:fr5YgcSWrqhRRxogOsw7RzIpsmvOZ6IcH4kBYTpR3n0=
+google.golang.org/grpc v1.36.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU=
+google.golang.org/grpc v1.40.0/go.mod h1:ogyxbiOoUXAkP+4+xa6PZSE9DZgIHtSpzjDTB9KAK34=
+google.golang.org/grpc v1.42.0/go.mod h1:k+4IHHFw41K8+bbowsex27ge2rCb65oeWqe4jJ590SU=
+google.golang.org/grpc v1.46.2 h1:u+MLGgVf7vRdjEYZ8wDFhAVNmhkbJ5hmrA1LMWK1CAQ=
+google.golang.org/grpc v1.46.2/go.mod h1:vN9eftEi1UMyUsIF80+uQXhHjbXYbm0uXoFCACuMGWk=
 google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
 google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
 google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
@@ -856,8 +1008,12 @@ google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2
 google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
 google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
 google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4=
-google.golang.org/protobuf v1.25.0 h1:Ejskq+SyPohKW+1uil0JJMtmHCgJPJ/qWTxr8qp+R4c=
 google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
+google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
+google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
+google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
+google.golang.org/protobuf v1.28.0 h1:w43yiav+6bVFTBQFZX0r7ipe9JQ1QsbMgHwbBziscLw=
+google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
 gopkg.in/DataDog/dd-trace-go.v1 v1.12.1 h1:zkyLw+Uq6BvGwy5hFeLVI1ePgOkqJswFPL1uOx6SSA4=
 gopkg.in/DataDog/dd-trace-go.v1 v1.12.1/go.mod h1:DVp8HmDh8PuTu2Z0fVVlBsyWaC++fzwVCaGWylTe3tg=
 gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=
@@ -882,14 +1038,17 @@ gopkg.in/square/go-jose.v2 v2.5.1/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76
 gopkg.in/yaml.v2 v2.0.0-20170812160011-eb3733d160e7/go.mod h1:JAlM8MvJe8wmxCU4Bli9HhUf9+ttbYbLASfIpnQbh74=
 gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
 gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
 gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
 gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
 gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
 gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
 gopkg.in/yaml.v3 v3.0.0-20190924164351-c8b7dadae555/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
 gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/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=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/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=
@@ -897,6 +1056,7 @@ honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWh
 honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
 honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
 honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
+honnef.co/go/tools v0.1.3/go.mod h1:NgwopIslSNH47DimFoV78dnkksY2EFtX0ajyb3K/las=
 rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8=
 rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0=
 rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA=
diff --git a/main.go b/main.go
index 519f3803..1c124d35 100644
--- a/main.go
+++ b/main.go
@@ -1,13 +1,71 @@
 package main
 
 import (
-	"log"
+	"context"
+	"os/signal"
+	"sync"
+	"syscall"
+	"time"
 
+	"github.com/netlify/gotrue/api"
 	"github.com/netlify/gotrue/cmd"
+	"github.com/netlify/gotrue/observability"
+	"github.com/sirupsen/logrus"
 )
 
+func init() {
+	logrus.SetFormatter(&logrus.JSONFormatter{})
+}
+
 func main() {
-	if err := cmd.RootCommand().Execute(); err != nil {
-		log.Fatal(err)
+	execCtx, execCancel := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGHUP, syscall.SIGINT)
+	defer execCancel()
+
+	go func() {
+		<-execCtx.Done()
+		logrus.Info("received graceful shutdown signal")
+	}()
+
+	// command is expected to obey the cancellation signal on execCtx and
+	// block while it is running
+	if err := cmd.RootCommand().ExecuteContext(execCtx); err != nil {
+		logrus.WithError(err).Fatal(err)
+	}
+
+	shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), time.Minute)
+	defer shutdownCancel()
+
+	var wg sync.WaitGroup
+
+	wg.Add(1)
+	go func() {
+		defer wg.Done()
+
+		// wait for API servers to shut down gracefully
+		api.WaitForCleanup(shutdownCtx)
+	}()
+
+	wg.Add(1)
+	go func() {
+		defer wg.Done()
+
+		// wait for metrics and trace exporters to shut down gracefully
+		observability.WaitForCleanup(shutdownCtx)
+	}()
+
+	cleanupDone := make(chan struct{})
+	go func() {
+		defer close(cleanupDone)
+		wg.Wait()
+	}()
+
+	select {
+	case <-shutdownCtx.Done():
+		// cleanup timed out
+		return
+
+	case <-cleanupDone:
+		// cleanup finished before timing out
+		return
 	}
 }
diff --git a/migrations/20221003041349_add_mfa_schema.up.sql b/migrations/20221003041349_add_mfa_schema.up.sql
new file mode 100644
index 00000000..49851f52
--- /dev/null
+++ b/migrations/20221003041349_add_mfa_schema.up.sql
@@ -0,0 +1,50 @@
+-- see: https://stackoverflow.com/questions/7624919/check-if-a-user-defined-type-already-exists-in-postgresql/48382296#48382296
+do $$ begin
+    create type factor_type as enum('totp', 'webauthn');
+    create type factor_status as enum('unverified', 'verified');
+    create type aal_level as enum('aal1', 'aal2', 'aal3');
+exception
+    when duplicate_object then null;
+end $$;
+
+-- auth.mfa_factors definition
+create table if not exists {{ index .Options "Namespace" }}.mfa_factors(
+       id uuid not null,
+       user_id uuid not null,
+       friendly_name text null,
+       factor_type factor_type not null,
+       status factor_status not null,
+       created_at timestamptz not null,
+       updated_at timestamptz not null,
+       secret text null,
+       constraint mfa_factors_pkey primary key(id),
+       constraint mfa_factors_user_id_fkey foreign key (user_id) references {{ index .Options "Namespace" }}.users(id) on delete cascade
+);
+comment on table {{ index .Options "Namespace" }}.mfa_factors is 'auth: stores metadata about factors';
+
+create unique index mfa_factors_user_friendly_name_unique on {{ index .Options "Namespace" }}.mfa_factors (friendly_name, user_id) where trim(friendly_name) <> '';
+
+-- auth.mfa_challenges definition
+create table if not exists {{ index .Options "Namespace" }}.mfa_challenges(
+       id uuid not null,
+       factor_id uuid not null,
+       created_at timestamptz not null,
+       verified_at timestamptz  null,
+       ip_address  inet not null,
+       constraint mfa_challenges_pkey primary key (id),
+       constraint mfa_challenges_auth_factor_id_fkey foreign key (factor_id) references {{ index .Options "Namespace" }}.mfa_factors(id) on delete cascade
+);
+comment on table {{ index .Options "Namespace" }}.mfa_challenges is 'auth: stores metadata about challenge requests made';
+
+
+
+-- add factor_id and amr claims to session
+create table if not exists {{ index .Options "Namespace" }}.mfa_amr_claims(
+    session_id uuid not null,
+    created_at timestamptz not null,
+    updated_at timestamptz not null,
+    authentication_method text not null,
+    constraint mfa_amr_claims_session_id_authentication_method_pkey unique(session_id, authentication_method),
+    constraint mfa_amr_claims_session_id_fkey foreign key(session_id) references {{ index .Options "Namespace" }}.sessions(id) on delete cascade
+);
+comment on table auth.mfa_amr_claims is 'auth: stores authenticator method reference claims for multi factor authentication';
diff --git a/migrations/20221003041400_add_aal_and_factor_id_to_sessions.up.sql b/migrations/20221003041400_add_aal_and_factor_id_to_sessions.up.sql
new file mode 100644
index 00000000..cc8a2096
--- /dev/null
+++ b/migrations/20221003041400_add_aal_and_factor_id_to_sessions.up.sql
@@ -0,0 +1,3 @@
+-- add factor_id to sessions
+ alter table {{ index .Options "Namespace" }}.sessions add column if not exists factor_id uuid null;
+ alter table {{ index .Options "Namespace" }}.sessions add column if not exists aal aal_level null;
diff --git a/migrations/20221011041400_add_mfa_indexes.up.sql b/migrations/20221011041400_add_mfa_indexes.up.sql
new file mode 100644
index 00000000..cecdcba5
--- /dev/null
+++ b/migrations/20221011041400_add_mfa_indexes.up.sql
@@ -0,0 +1,9 @@
+create index if not exists refresh_token_session_id on {{ index .Options "Namespace" }}.refresh_tokens using btree(session_id);
+
+alter table {{ index .Options "Namespace" }}.mfa_amr_claims
+  add column if not exists id uuid not null,
+  add constraint amr_id_pk primary key(id);
+
+create index if not exists user_id_created_at_idx on {{ index .Options "Namespace" }}.sessions (user_id, created_at);
+create index if not exists factor_id_created_at_idx on {{ index .Options "Namespace" }}.mfa_factors (user_id, created_at);
+
diff --git a/models/amr.go b/models/amr.go
new file mode 100644
index 00000000..b68ca7cb
--- /dev/null
+++ b/models/amr.go
@@ -0,0 +1,35 @@
+package models
+
+import (
+	"time"
+
+	"github.com/gobuffalo/pop/v5"
+	"github.com/gofrs/uuid"
+	"github.com/netlify/gotrue/storage"
+	"github.com/pkg/errors"
+)
+
+type AMRClaim struct {
+	ID                   uuid.UUID `json:"id" db:"id"`
+	SessionID            uuid.UUID `json:"session_id" db:"session_id"`
+	CreatedAt            time.Time `json:"created_at" db:"created_at"`
+	UpdatedAt            time.Time `json:"updated_at" db:"updated_at"`
+	AuthenticationMethod string    `json:"authentication_method" db:"authentication_method"`
+}
+
+func (AMRClaim) TableName() string {
+	tableName := "mfa_amr_claims"
+	return tableName
+}
+
+func AddClaimToSession(tx *storage.Connection, session *Session, authenticationMethod AuthenticationMethod) error {
+	id, err := uuid.NewV4()
+	if err != nil {
+		return errors.Wrap(err, "Error generating unique claim id")
+	}
+	currentTime := time.Now()
+	return tx.RawQuery("INSERT INTO "+(&pop.Model{Value: AMRClaim{}}).TableName()+
+		`(id, session_id, created_at, updated_at, authentication_method) values (?, ?, ?, ?, ?)
+			ON CONFLICT ON CONSTRAINT mfa_amr_claims_session_id_authentication_method_pkey
+			DO UPDATE SET updated_at = ?;`, id, session.ID, currentTime, currentTime, authenticationMethod.String(), currentTime).Exec()
+}
diff --git a/models/audit_log_entry.go b/models/audit_log_entry.go
index c499031c..9e915a0e 100644
--- a/models/audit_log_entry.go
+++ b/models/audit_log_entry.go
@@ -30,11 +30,22 @@ const (
 	UserRepeatedSignUpAction        AuditAction = "user_repeated_signup"
 	TokenRevokedAction              AuditAction = "token_revoked"
 	TokenRefreshedAction            AuditAction = "token_refreshed"
-
-	account auditLogType = "account"
-	team    auditLogType = "team"
-	token   auditLogType = "token"
-	user    auditLogType = "user"
+	GenerateRecoveryCodesAction     AuditAction = "generate_recovery_codes"
+	EnrollFactorAction              AuditAction = "factor_in_progress"
+	UnenrollFactorAction            AuditAction = "factor_unenrolled"
+	CreateChallengeAction           AuditAction = "challenge_created"
+	VerifyFactorAction              AuditAction = "verification_attempted"
+	DeleteFactorAction              AuditAction = "factor_deleted"
+	DeleteRecoveryCodesAction       AuditAction = "recovery_codes_deleted"
+	UpdateFactorAction              AuditAction = "factor_updated"
+	MFACodeLoginAction              AuditAction = "mfa_code_login"
+
+	account       auditLogType = "account"
+	team          auditLogType = "team"
+	token         auditLogType = "token"
+	user          auditLogType = "user"
+	factor        auditLogType = "factor"
+	recoveryCodes auditLogType = "recovery_codes"
 )
 
 var ActionLogTypeMap = map[AuditAction]auditLogType{
@@ -50,6 +61,15 @@ var ActionLogTypeMap = map[AuditAction]auditLogType{
 	UserRecoveryRequestedAction:     user,
 	UserConfirmationRequestedAction: user,
 	UserRepeatedSignUpAction:        user,
+	GenerateRecoveryCodesAction:     user,
+	EnrollFactorAction:              factor,
+	UnenrollFactorAction:            factor,
+	CreateChallengeAction:           factor,
+	VerifyFactorAction:              factor,
+	DeleteFactorAction:              factor,
+	UpdateFactorAction:              factor,
+	MFACodeLoginAction:              factor,
+	DeleteRecoveryCodesAction:       recoveryCodes,
 }
 
 // AuditLogEntry is the database model for audit log entries.
diff --git a/models/challenge.go b/models/challenge.go
new file mode 100644
index 00000000..be1a2625
--- /dev/null
+++ b/models/challenge.go
@@ -0,0 +1,63 @@
+package models
+
+import (
+	"database/sql"
+	"github.com/gofrs/uuid"
+	"github.com/netlify/gotrue/storage"
+	"github.com/pkg/errors"
+	"time"
+)
+
+type Challenge struct {
+	ID         uuid.UUID  `json:"challenge_id" db:"id"`
+	FactorID   uuid.UUID  `json:"factor_id" db:"factor_id"`
+	CreatedAt  time.Time  `json:"created_at" db:"created_at"`
+	VerifiedAt *time.Time `json:"verified_at,omitempty" db:"verified_at"`
+	IPAddress  string     `json:"ip_address" db:"ip_address"`
+}
+
+func (Challenge) TableName() string {
+	tableName := "mfa_challenges"
+	return tableName
+}
+
+const ChallengePrefix = "challenge"
+
+func NewChallenge(factor *Factor, ipAddress string) (*Challenge, error) {
+	id, err := uuid.NewV4()
+	if err != nil {
+		return nil, errors.Wrap(err, "Error generating unique id")
+	}
+	challenge := &Challenge{
+		ID:        id,
+		FactorID:  factor.ID,
+		IPAddress: ipAddress,
+	}
+	return challenge, nil
+}
+
+func FindChallengeByChallengeID(tx *storage.Connection, challengeID uuid.UUID) (*Challenge, error) {
+	challenge, err := findChallenge(tx, "id = ?", challengeID)
+	if err != nil {
+		return nil, ChallengeNotFoundError{}
+	}
+	return challenge, nil
+}
+
+// Update the verification timestamp
+func (f *Challenge) Verify(tx *storage.Connection) error {
+	now := time.Now()
+	f.VerifiedAt = &now
+	return tx.UpdateOnly(f, "verified_at")
+}
+
+func findChallenge(tx *storage.Connection, query string, args ...interface{}) (*Challenge, error) {
+	obj := &Challenge{}
+	if err := tx.Eager().Q().Where(query, args...).First(obj); err != nil {
+		if errors.Cause(err) == sql.ErrNoRows {
+			return nil, ChallengeNotFoundError{}
+		}
+		return nil, errors.Wrap(err, "error finding challenge")
+	}
+	return obj, nil
+}
diff --git a/models/connection.go b/models/connection.go
index 8ffa95e7..dae66955 100644
--- a/models/connection.go
+++ b/models/connection.go
@@ -40,6 +40,9 @@ func TruncateAll(conn *storage.Connection) error {
 			(&pop.Model{Value: RefreshToken{}}).TableName(),
 			(&pop.Model{Value: AuditLogEntry{}}).TableName(),
 			(&pop.Model{Value: Session{}}).TableName(),
+			(&pop.Model{Value: Factor{}}).TableName(),
+			(&pop.Model{Value: Challenge{}}).TableName(),
+			(&pop.Model{Value: AMRClaim{}}).TableName(),
 		}
 
 		for _, tableName := range tables {
diff --git a/models/errors.go b/models/errors.go
index 773cc852..e4ab935e 100644
--- a/models/errors.go
+++ b/models/errors.go
@@ -17,6 +17,10 @@ func IsNotFoundError(err error) bool {
 		return true
 	case IdentityNotFoundError, *IdentityNotFoundError:
 		return true
+	case ChallengeNotFoundError:
+		return true
+	case FactorNotFoundError:
+		return true
 	}
 	return false
 }
@@ -62,6 +66,20 @@ func (e InstanceNotFoundError) Error() string {
 	return "Instance not found"
 }
 
+// FactorNotFoundError represents when a user is not found.
+type FactorNotFoundError struct{}
+
+func (e FactorNotFoundError) Error() string {
+	return "Factor not found"
+}
+
+// ChallengeNotFoundError represents when a user is not found.
+type ChallengeNotFoundError struct{}
+
+func (e ChallengeNotFoundError) Error() string {
+	return "Challenge not found"
+}
+
 type TotpSecretNotFoundError struct{}
 
 func (e TotpSecretNotFoundError) Error() string {
diff --git a/models/factor.go b/models/factor.go
new file mode 100644
index 00000000..f21f2a94
--- /dev/null
+++ b/models/factor.go
@@ -0,0 +1,151 @@
+package models
+
+import (
+	"database/sql"
+	"time"
+
+	"github.com/gofrs/uuid"
+	"github.com/netlify/gotrue/storage"
+	"github.com/pkg/errors"
+)
+
+const (
+	FactorStateUnverified = "unverified"
+	FactorStateVerified   = "verified"
+)
+
+const TOTP = "totp"
+
+type AuthenticationMethod int
+
+const (
+	OAuth AuthenticationMethod = iota
+	PasswordGrant
+	OTP
+	TOTPSignIn
+)
+
+func (authMethod AuthenticationMethod) String() string {
+	switch authMethod {
+	case OAuth:
+		return "oauth"
+	case PasswordGrant:
+		return "password"
+	case OTP:
+		return "otp"
+	case TOTPSignIn:
+		return "mfa/totp"
+	}
+	return ""
+}
+
+type Factor struct {
+	ID           uuid.UUID `json:"id" db:"id"`
+	User         User      `json:"-" belongs_to:"user"`
+	UserID       uuid.UUID `json:"-" db:"user_id"`
+	CreatedAt    time.Time `json:"created_at" db:"created_at"`
+	UpdatedAt    time.Time `json:"updated_at" db:"updated_at"`
+	Status       string    `json:"status" db:"status"`
+	FriendlyName string    `json:"friendly_name,omitempty" db:"friendly_name"`
+	Secret       string    `json:"-" db:"secret"`
+	FactorType   string    `json:"factor_type" db:"factor_type"`
+}
+
+func (Factor) TableName() string {
+	tableName := "mfa_factors"
+	return tableName
+}
+
+func NewFactor(user *User, friendlyName string, factorType string, status, secret string) (*Factor, error) {
+	id, err := uuid.NewV4()
+	if err != nil {
+		return nil, errors.Wrap(err, "Error generating unique id")
+	}
+	factor := &Factor{
+		UserID:       user.ID,
+		ID:           id,
+		Status:       status,
+		FriendlyName: friendlyName,
+		Secret:       secret,
+		FactorType:   factorType,
+	}
+	return factor, nil
+}
+
+// FindFactorsByUser returns all factors belonging to a user ordered by timestamp
+func FindFactorsByUser(tx *storage.Connection, user *User) ([]*Factor, error) {
+	factors := []*Factor{}
+	if err := tx.Q().Where("user_id = ?", user.ID).Order("created_at asc").All(&factors); err != nil {
+		if errors.Cause(err) == sql.ErrNoRows {
+			return factors, nil
+		}
+		return nil, errors.Wrap(err, "Database error when finding MFA factors associated to user")
+	}
+	return factors, nil
+}
+
+func FindFactorByFactorID(tx *storage.Connection, factorID uuid.UUID) (*Factor, error) {
+	factor, err := findFactor(tx, "id = ?", factorID)
+	if err != nil {
+		return nil, FactorNotFoundError{}
+	}
+	return factor, nil
+}
+
+func findFactor(tx *storage.Connection, query string, args ...interface{}) (*Factor, error) {
+	obj := &Factor{}
+	if err := tx.Eager().Q().Where(query, args...).First(obj); err != nil {
+		if errors.Cause(err) == sql.ErrNoRows {
+			return nil, FactorNotFoundError{}
+		}
+		return nil, errors.Wrap(err, "Database error finding factor")
+	}
+
+	return obj, nil
+}
+
+func FindVerifiedFactorsByUser(tx *storage.Connection, user *User) ([]*Factor, error) {
+	factors := []*Factor{}
+	if err := tx.Q().Where("user_id = ? AND status = ?", user.ID, FactorStateVerified).All(&factors); err != nil {
+		if errors.Cause(err) == sql.ErrNoRows {
+			return factors, nil
+		}
+		return nil, errors.Wrap(err, "Database error when finding verified MFA factors")
+	}
+	return factors, nil
+}
+
+// UpdateFriendlyName changes the friendly name
+func (f *Factor) UpdateFriendlyName(tx *storage.Connection, friendlyName string) error {
+	f.FriendlyName = friendlyName
+	return tx.UpdateOnly(f, "friendly_name", "updated_at")
+}
+
+// UpdateStatus modifies the factor status
+func (f *Factor) UpdateStatus(tx *storage.Connection, status string) error {
+	f.Status = status
+	return tx.UpdateOnly(f, "status", "updated_at")
+}
+
+// IsMFAEnabled determines if user has met the conditions to activate MFA
+func IsMFAEnabled(tx *storage.Connection, user *User) (bool, error) {
+	factors, err := FindVerifiedFactorsByUser(tx, user)
+	if err != nil {
+		return false, err
+	}
+	if len(factors) >= 1 {
+		return true, nil
+	}
+	return false, nil
+}
+
+// UpdateFactorType modifies the factor type
+func (f *Factor) UpdateFactorType(tx *storage.Connection, factorType string) error {
+	f.FactorType = factorType
+	return tx.UpdateOnly(f, "factor_type", "updated_at")
+}
+
+func (f *Factor) DowngradeSessionsToAAL1(tx *storage.Connection) error {
+	return updateFactorAssociatedSessions(tx, f.UserID, f.ID, AAL1.String())
+
+}
diff --git a/models/refresh_token.go b/models/refresh_token.go
index 847244ff..497aade1 100644
--- a/models/refresh_token.go
+++ b/models/refresh_token.go
@@ -3,6 +3,7 @@ package models
 import (
 	"database/sql"
 	"net/http"
+	"os"
 	"time"
 
 	"github.com/gobuffalo/pop/v5"
@@ -38,6 +39,7 @@ func (RefreshToken) TableName() string {
 // GrantParams is used to pass session-specific parameters when issuing a new
 // refresh token to authenticated users.
 type GrantParams struct {
+	FactorID *uuid.UUID
 }
 
 // GrantAuthenticatedUser creates a refresh token for the provided user.
@@ -58,6 +60,7 @@ func GrantRefreshTokenSwap(r *http.Request, tx *storage.Connection, user *User,
 		if terr = tx.UpdateOnly(token, "revoked"); terr != nil {
 			return terr
 		}
+
 		newToken, terr = createRefreshToken(rtx, user, token, nil)
 		return terr
 	})
@@ -98,6 +101,18 @@ func GetValidChildToken(tx *storage.Connection, token *RefreshToken) (*RefreshTo
 	return refreshToken, nil
 }
 
+func FindTokenBySessionID(tx *storage.Connection, sessionId *uuid.UUID) (*RefreshToken, error) {
+	refreshToken := &RefreshToken{}
+	err := tx.Q().Where("session_id = ?", sessionId).Order("created_at asc").First(refreshToken)
+	if err != nil {
+		if errors.Cause(err) == sql.ErrNoRows {
+			return nil, RefreshTokenNotFoundError{}
+		}
+		return nil, err
+	}
+	return refreshToken, nil
+}
+
 func createRefreshToken(tx *storage.Connection, user *User, oldToken *RefreshToken, params *GrantParams) (*RefreshToken, error) {
 	token := &RefreshToken{
 		UserID: user.ID,
@@ -107,11 +122,19 @@ func createRefreshToken(tx *storage.Connection, user *User, oldToken *RefreshTok
 	if oldToken != nil {
 		token.Parent = storage.NullString(oldToken.Token)
 		token.SessionId = oldToken.SessionId
+
 	}
 
 	if token.SessionId == nil {
 		// Existing refresh tokens may have a null session_id if they were created before v2.15.3
-		session, err := CreateSession(tx, user)
+		var session *Session
+		var err error
+		// TODO(Joel): Find better workaround
+		if os.Getenv("GOTRUE_MFA_ENABLED") == "true" {
+			session, err = MFA_CreateSession(tx, user, params.FactorID)
+		} else {
+			session, err = CreateSession(tx, user)
+		}
 		if err != nil {
 			return nil, errors.Wrap(err, "Error generated unique session id")
 		}
diff --git a/models/sessions.go b/models/sessions.go
index 331d9fe5..c13382f4 100644
--- a/models/sessions.go
+++ b/models/sessions.go
@@ -2,6 +2,7 @@ package models
 
 import (
 	"database/sql"
+	"sort"
 	"time"
 
 	"github.com/gobuffalo/pop/v5"
@@ -10,11 +11,57 @@ import (
 	"github.com/pkg/errors"
 )
 
+type AuthenticatorAssuranceLevel int
+
+const (
+	AAL1 AuthenticatorAssuranceLevel = iota
+	AAL2
+	AAL3
+)
+
+func (aal AuthenticatorAssuranceLevel) String() string {
+	switch aal {
+	case AAL1:
+		return "aal1"
+	case AAL2:
+		return "aal2"
+	case AAL3:
+		return "aal3"
+	default:
+		return ""
+	}
+}
+
+// AMREntry represents a method that a user has logged in together with the corresponding time
+type AMREntry struct {
+	Method    string `json:"method"`
+	Timestamp int64  `json:"timestamp"`
+}
+
+type sortAMREntries struct {
+	Array []AMREntry
+}
+
+func (s sortAMREntries) Len() int {
+	return len(s.Array)
+}
+
+func (s sortAMREntries) Less(i, j int) bool {
+	return s.Array[i].Timestamp < s.Array[j].Timestamp
+}
+
+func (s sortAMREntries) Swap(i, j int) {
+	s.Array[j], s.Array[i] = s.Array[i], s.Array[j]
+}
+
 type Session struct {
-	ID        uuid.UUID `json:"-" db:"id"`
-	UserID    uuid.UUID `json:"user_id" db:"user_id"`
-	CreatedAt time.Time `json:"created_at" db:"created_at"`
-	UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
+	ID        uuid.UUID  `json:"-" db:"id"`
+	UserID    uuid.UUID  `json:"user_id" db:"user_id"`
+	CreatedAt time.Time  `json:"created_at" db:"created_at"`
+	UpdatedAt time.Time  `json:"updated_at" db:"updated_at"`
+	FactorID  *uuid.UUID `json:"factor_id" db:"factor_id"`
+	AMRClaims []AMRClaim `json:"amr,omitempty" has_many:"amr_claims"`
+	AAL       string     `json:"aal" db:"aal"`
 }
 
 func (Session) TableName() string {
@@ -22,20 +69,35 @@ func (Session) TableName() string {
 	return tableName
 }
 
-func NewSession(user *User) (*Session, error) {
+func NewSession(user *User, factorID *uuid.UUID) (*Session, error) {
 	id, err := uuid.NewV4()
 	if err != nil {
 		return nil, errors.Wrap(err, "Error generating unique session id")
 	}
+
 	session := &Session{
-		ID:     id,
-		UserID: user.ID,
+		ID:        id,
+		UserID:    user.ID,
+		FactorID:  factorID,
+		AAL:       AAL1.String(),
+		AMRClaims: []AMRClaim{},
 	}
 	return session, nil
 }
 
 func CreateSession(tx *storage.Connection, user *User) (*Session, error) {
-	session, err := NewSession(user)
+	session, err := NewSession(user, &uuid.Nil)
+	if err != nil {
+		return nil, err
+	}
+	if err := tx.Create(session); err != nil {
+		return nil, errors.Wrap(err, "error creating session")
+	}
+	return session, nil
+}
+
+func MFA_CreateSession(tx *storage.Connection, user *User, factorID *uuid.UUID) (*Session, error) {
+	session, err := NewSession(user, factorID)
 	if err != nil {
 		return nil, err
 	}
@@ -51,11 +113,30 @@ func FindSessionById(tx *storage.Connection, id uuid.UUID) (*Session, error) {
 		if errors.Cause(err) == sql.ErrNoRows {
 			return nil, SessionNotFoundError{}
 		}
-		return nil, errors.Wrap(err, "error finding user")
+		return nil, errors.Wrap(err, "error finding session")
 	}
 	return session, nil
 }
 
+func FindSessionByUserID(tx *storage.Connection, userId uuid.UUID) (*Session, error) {
+	session := &Session{}
+	if err := tx.Eager().Q().Where("user_id = ?", userId).Order("created_at asc").First(session); err != nil {
+		if errors.Cause(err) == sql.ErrNoRows {
+			return nil, SessionNotFoundError{}
+		}
+		return nil, errors.Wrap(err, "error finding session")
+	}
+	return session, nil
+}
+
+func updateFactorAssociatedSessions(tx *storage.Connection, userID, factorID uuid.UUID, aal string) error {
+	return tx.RawQuery("UPDATE "+(&pop.Model{Value: Session{}}).TableName()+" set aal = ?, factor_id = ? WHERE user_id = ? AND factor_id = ?", aal, uuid.Nil, userID, factorID).Exec()
+}
+
+func InvalidateSessionsWithAALLessThan(tx *storage.Connection, userID uuid.UUID, level string) error {
+	return tx.RawQuery("DELETE FROM "+(&pop.Model{Value: Session{}}).TableName()+" WHERE user_id = ? AND aal < ?", userID, level).Exec()
+}
+
 // Logout deletes all sessions for a user.
 func Logout(tx *storage.Connection, userId uuid.UUID) error {
 	return tx.RawQuery("DELETE FROM "+(&pop.Model{Value: Session{}}).TableName()+" WHERE user_id = ?", userId).Exec()
@@ -64,3 +145,37 @@ func Logout(tx *storage.Connection, userId uuid.UUID) error {
 func LogoutSession(tx *storage.Connection, sessionId uuid.UUID) error {
 	return tx.RawQuery("DELETE FROM "+(&pop.Model{Value: Session{}}).TableName()+" WHERE id = ?", sessionId).Exec()
 }
+
+func (s *Session) UpdateAssociatedFactor(tx *storage.Connection, factorID *uuid.UUID) error {
+	s.FactorID = factorID
+	return tx.Update(s)
+}
+
+func (s *Session) UpdateAssociatedAAL(tx *storage.Connection, aal string) error {
+	s.AAL = aal
+	return tx.Update(s)
+}
+
+func (s *Session) CalculateAALAndAMR() (aal string, amr []AMREntry) {
+	amr, aal = []AMREntry{}, AAL1.String()
+	for _, claim := range s.AMRClaims {
+		if claim.AuthenticationMethod == TOTPSignIn.String() {
+			aal = AAL2.String()
+		}
+		amr = append(amr, AMREntry{Method: claim.AuthenticationMethod, Timestamp: claim.UpdatedAt.Unix()})
+	}
+
+	// makes sure that the AMR claims are always ordered most-recent first
+
+	// sort in ascending order
+	sort.Sort(sortAMREntries{
+		Array: amr,
+	})
+
+	// now reverse for descending order
+	_ = sort.Reverse(sortAMREntries{
+		Array: amr,
+	})
+
+	return aal, amr
+}
diff --git a/models/user.go b/models/user.go
index 4bf521ff..4f4116ef 100644
--- a/models/user.go
+++ b/models/user.go
@@ -56,6 +56,7 @@ type User struct {
 	AppMetaData  JSONMap `json:"app_metadata" db:"raw_app_meta_data"`
 	UserMetaData JSONMap `json:"user_metadata" db:"raw_user_meta_data"`
 
+	Factors    []Factor   `json:"factors,omitempty" has_many:"factors"`
 	Identities []Identity `json:"identities" has_many:"identities"`
 
 	CreatedAt   time.Time  `json:"created_at" db:"created_at"`
diff --git a/observability/cleanup.go b/observability/cleanup.go
new file mode 100644
index 00000000..b6e409a7
--- /dev/null
+++ b/observability/cleanup.go
@@ -0,0 +1,30 @@
+package observability
+
+import (
+	"context"
+	"sync"
+)
+
+var (
+	cleanupWaitGroup sync.WaitGroup
+)
+
+// WaitForCleanup waits until all observability long-running goroutines shut
+// down cleanly or until the provided context signals done.
+func WaitForCleanup(ctx context.Context) {
+	cleanupDone := make(chan struct{})
+
+	go func() {
+		defer close(cleanupDone)
+
+		cleanupWaitGroup.Wait()
+	}()
+
+	select {
+	case <-ctx.Done():
+		return
+
+	case <-cleanupDone:
+		return
+	}
+}
diff --git a/observability/logging.go b/observability/logging.go
index 4c9a35c5..be7598a9 100644
--- a/observability/logging.go
+++ b/observability/logging.go
@@ -4,10 +4,12 @@ import (
 	"os"
 	"sync"
 
+	"github.com/bombsimon/logrusr/v3"
 	"github.com/gobuffalo/pop/v5"
 	"github.com/gobuffalo/pop/v5/logging"
 	"github.com/netlify/gotrue/conf"
 	"github.com/sirupsen/logrus"
+	"go.opentelemetry.io/otel"
 )
 
 const (
@@ -54,6 +56,8 @@ func ConfigureLogging(config *conf.LoggingConfig) error {
 		logrus.WithFields(f)
 
 		setPopLogger(config.SQL)
+
+		otel.SetLogger(logrusr.New(logrus.StandardLogger().WithField("component", "otel")))
 	})
 
 	return err
diff --git a/observability/metrics.go b/observability/metrics.go
new file mode 100644
index 00000000..9ab18d0e
--- /dev/null
+++ b/observability/metrics.go
@@ -0,0 +1,185 @@
+package observability
+
+import (
+	"context"
+	"fmt"
+	"net"
+	"net/http"
+	"sync"
+	"time"
+
+	"github.com/netlify/gotrue/conf"
+	"github.com/sirupsen/logrus"
+
+	"go.opentelemetry.io/otel/exporters/otlp/otlpmetric"
+	"go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc"
+	"go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp"
+	"go.opentelemetry.io/otel/exporters/prometheus"
+	"go.opentelemetry.io/otel/metric"
+	metricglobal "go.opentelemetry.io/otel/metric/global"
+	basicmetriccontroller "go.opentelemetry.io/otel/sdk/metric/controller/basic"
+	exportmetricaggregation "go.opentelemetry.io/otel/sdk/metric/export/aggregation"
+	basicmetricprocessor "go.opentelemetry.io/otel/sdk/metric/processor/basic"
+	simplemetricselector "go.opentelemetry.io/otel/sdk/metric/selector/simple"
+
+	otelruntimemetrics "go.opentelemetry.io/contrib/instrumentation/runtime"
+)
+
+func Meter(instrumentationName string, opts ...metric.MeterOption) metric.Meter {
+	return metricglobal.Meter(instrumentationName, opts...)
+}
+
+func enablePrometheusMetrics(ctx context.Context, mc *conf.MetricsConfig) error {
+	controller := basicmetriccontroller.New(
+		basicmetricprocessor.NewFactory(
+			simplemetricselector.NewWithHistogramDistribution(),
+			exportmetricaggregation.CumulativeTemporalitySelector(),
+			basicmetricprocessor.WithMemory(true), // pushes all metrics, not only the collected ones
+		),
+		basicmetriccontroller.WithResource(openTelemetryResource()),
+	)
+
+	exporter, err := prometheus.New(prometheus.Config{}, controller)
+	if err != nil {
+		return err
+	}
+
+	metricglobal.SetMeterProvider(exporter.MeterProvider())
+
+	cleanupWaitGroup.Add(1)
+	go func() {
+		addr := net.JoinHostPort(mc.PrometheusListenHost, mc.PrometheusListenPort)
+		baseContext, cancel := context.WithCancel(context.Background())
+
+		server := &http.Server{
+			Addr:    addr,
+			Handler: exporter,
+			BaseContext: func(net.Listener) context.Context {
+				return baseContext
+			},
+			ReadHeaderTimeout: 2 * time.Second, // to mitigate a Slowloris attack
+		}
+
+		go func() {
+			defer cleanupWaitGroup.Done()
+			<-ctx.Done()
+
+			cancel() // close baseContext
+
+			shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second)
+			defer shutdownCancel()
+
+			if err := server.Shutdown(shutdownCtx); err != nil {
+				logrus.WithError(err).Errorf("prometheus server (%s) failed to gracefully shut down", addr)
+			}
+		}()
+
+		logrus.Infof("prometheus server listening on %s", addr)
+
+		if err := server.ListenAndServe(); err != nil {
+			logrus.WithError(err).Errorf("prometheus server (%s) shut down", addr)
+		} else {
+			logrus.Info("prometheus metric exporter shut down")
+		}
+	}()
+
+	return nil
+}
+
+func enableOpenTelemetryMetrics(ctx context.Context, mc *conf.MetricsConfig) error {
+	var (
+		err            error
+		metricExporter *otlpmetric.Exporter
+	)
+
+	switch mc.ExporterProtocol {
+	case "grpc":
+		metricExporter, err = otlpmetricgrpc.New(ctx)
+		if err != nil {
+			return err
+		}
+
+	case "http/protobuf":
+		metricExporter, err = otlpmetrichttp.New(ctx)
+		if err != nil {
+			return err
+		}
+
+	default: // http/json for example
+		return fmt.Errorf("unsupported OpenTelemetry exporter protocol %q", mc.ExporterProtocol)
+	}
+
+	controller := basicmetriccontroller.New(
+		basicmetricprocessor.NewFactory(
+			simplemetricselector.NewWithHistogramDistribution(),
+			metricExporter,
+		),
+		basicmetriccontroller.WithExporter(metricExporter),
+		basicmetriccontroller.WithResource(openTelemetryResource()),
+	)
+
+	metricglobal.SetMeterProvider(controller)
+
+	cleanupWaitGroup.Add(1)
+	go func() {
+		defer cleanupWaitGroup.Done()
+
+		<-ctx.Done()
+
+		shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second)
+		defer shutdownCancel()
+
+		if err := metricExporter.Shutdown(shutdownCtx); err != nil {
+			logrus.WithError(err).Error("unable to gracefully shut down OpenTelemetry metric exporter")
+		} else {
+			logrus.Info("OpenTelemetry metric exporter shut down")
+		}
+	}()
+
+	if err := controller.Start(ctx); err != nil {
+		logrus.WithError(err).Error("unable to start pushing OpenTelemetry metrics")
+	} else {
+		logrus.Info("OpenTelemetry metrics exporter started")
+	}
+
+	return nil
+}
+
+var (
+	metricsOnce *sync.Once = &sync.Once{}
+)
+
+func ConfigureMetrics(ctx context.Context, mc *conf.MetricsConfig) error {
+	if ctx == nil {
+		panic("context must not be nil")
+	}
+
+	var err error
+
+	metricsOnce.Do(func() {
+		if mc.Enabled {
+			switch mc.Exporter {
+			case conf.Prometheus:
+				if err = enablePrometheusMetrics(ctx, mc); err != nil {
+					logrus.WithError(err).Error("unable to start prometheus metrics exporter")
+					return
+				}
+
+			case conf.OpenTelemetryMetrics:
+				if err = enableOpenTelemetryMetrics(ctx, mc); err != nil {
+					logrus.WithError(err).Error("unable to start OTLP metrics exporter")
+
+					return
+				}
+			}
+		}
+
+		if err := otelruntimemetrics.Start(otelruntimemetrics.WithMinimumReadMemStatsInterval(time.Second)); err != nil {
+			logrus.WithError(err).Error("unable to start OpenTelemetry Go runtime metrics collection")
+		} else {
+			logrus.Info("Go runtime metrics collection started")
+		}
+	})
+
+	return err
+}
diff --git a/observability/request-tracing.go b/observability/request-tracing.go
new file mode 100644
index 00000000..b63a4077
--- /dev/null
+++ b/observability/request-tracing.go
@@ -0,0 +1,74 @@
+package observability
+
+import (
+	"net/http"
+
+	"github.com/go-chi/chi"
+	"github.com/sirupsen/logrus"
+	"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
+	"go.opentelemetry.io/otel/attribute"
+	semconv "go.opentelemetry.io/otel/semconv/v1.12.0"
+	"go.opentelemetry.io/otel/trace"
+)
+
+// traceChiRoutesSafely attempts to extract the Chi RouteContext. If the
+// request does not have a RouteContext it will recover from the panic and
+// attempt to figure out the route from the URL's path.
+func traceChiRoutesSafely(r *http.Request) {
+	defer func() {
+		if rec := recover(); rec != nil {
+			logrus.WithField("error", rec).Error("unable to trace chi routes, traces may be off")
+
+			span := trace.SpanFromContext(r.Context())
+			span.SetAttributes(semconv.HTTPRouteKey.String(r.URL.Path))
+		}
+	}()
+
+	routeContext := chi.RouteContext(r.Context())
+	span := trace.SpanFromContext(r.Context())
+	span.SetAttributes(semconv.HTTPRouteKey.String(routeContext.RoutePattern()))
+}
+
+// traceChiRouteURLParamsSafely attempts to extract the Chi RouteContext
+// URLParams values for the route and assign them to the tracing span. If the
+// request does not have a RouteContext it will recover from the panic and not
+// set any params.
+func traceChiRouteURLParamsSafely(r *http.Request) {
+	defer func() {
+		if rec := recover(); rec != nil {
+			logrus.WithField("error", rec).Error("unable to trace route with route params, traces may be off")
+		}
+	}()
+
+	routeContext := chi.RouteContext(r.Context())
+	span := trace.SpanFromContext(r.Context())
+
+	var attributes []attribute.KeyValue
+
+	for i := 0; i < len(routeContext.URLParams.Keys); i += 1 {
+		key := routeContext.URLParams.Keys[i]
+		value := routeContext.URLParams.Values[i]
+
+		attributes = append(attributes, attribute.String("http.route.param."+key, value))
+	}
+
+	if len(attributes) > 0 {
+		span.SetAttributes(attributes...)
+	}
+}
+
+// RequestTracing returns an HTTP handler that traces all HTTP requests coming
+// in. Supports Chi routers, so this should be one of the first middlewares on
+// the router.
+func RequestTracing() func(http.Handler) http.Handler {
+	return func(next http.Handler) http.Handler {
+		fn := func(w http.ResponseWriter, r *http.Request) {
+			defer traceChiRoutesSafely(r)
+			defer traceChiRouteURLParamsSafely(r)
+
+			next.ServeHTTP(w, r)
+		}
+
+		return otelhttp.NewHandler(http.HandlerFunc(fn), "api")
+	}
+}
diff --git a/observability/tracing.go b/observability/tracing.go
index 2cc7e14c..7ac3837a 100644
--- a/observability/tracing.go
+++ b/observability/tracing.go
@@ -1,38 +1,153 @@
 package observability
 
 import (
+	"context"
+	"fmt"
 	"net"
 	"sync"
+	"time"
 
 	"github.com/netlify/gotrue/conf"
+	"github.com/netlify/gotrue/utilities"
+	"github.com/sirupsen/logrus"
+
 	"github.com/opentracing/opentracing-go"
 	"gopkg.in/DataDog/dd-trace-go.v1/ddtrace/opentracer"
 	"gopkg.in/DataDog/dd-trace-go.v1/ddtrace/tracer"
+
+	"go.opentelemetry.io/otel"
+	"go.opentelemetry.io/otel/attribute"
+	"go.opentelemetry.io/otel/exporters/otlp/otlptrace"
+	"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc"
+	"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp"
+	"go.opentelemetry.io/otel/propagation"
+	sdkresource "go.opentelemetry.io/otel/sdk/resource"
+	sdktrace "go.opentelemetry.io/otel/sdk/trace"
+	"go.opentelemetry.io/otel/trace"
 )
 
+func Tracer(name string, opts ...trace.TracerOption) trace.Tracer {
+	return otel.Tracer(name, opts...)
+}
+
+func openTelemetryResource() *sdkresource.Resource {
+	environmentResource := sdkresource.Environment()
+	gotrueResource := sdkresource.NewSchemaless(attribute.String("gotrue.version", utilities.Version))
+
+	mergedResource, err := sdkresource.Merge(environmentResource, gotrueResource)
+	if err != nil {
+		logrus.WithError(err).Error("unable to merge OpenTelemetry environment and gotrue resources")
+
+		return environmentResource
+	}
+
+	return mergedResource
+}
+
+func enableOpenTelemetryTracing(ctx context.Context, tc *conf.TracingConfig) error {
+	var (
+		err           error
+		traceExporter *otlptrace.Exporter
+	)
+
+	switch tc.ExporterProtocol {
+	case "grpc":
+		traceExporter, err = otlptracegrpc.New(ctx)
+		if err != nil {
+			return err
+		}
+
+	case "http/protobuf":
+		traceExporter, err = otlptracehttp.New(ctx)
+		if err != nil {
+			return err
+		}
+
+	default: // http/json for example
+		return fmt.Errorf("unsupported OpenTelemetry exporter protocol %q", tc.ExporterProtocol)
+	}
+
+	traceProvider := sdktrace.NewTracerProvider(
+		sdktrace.WithBatcher(traceExporter),
+		sdktrace.WithResource(openTelemetryResource()),
+	)
+
+	otel.SetTracerProvider(traceProvider)
+
+	// Register the W3C trace context and baggage propagators so data is
+	// propagated across services/processes
+	otel.SetTextMapPropagator(
+		propagation.NewCompositeTextMapPropagator(
+			propagation.TraceContext{},
+			propagation.Baggage{},
+		),
+	)
+
+	cleanupWaitGroup.Add(1)
+	go func() {
+		defer cleanupWaitGroup.Done()
+
+		<-ctx.Done()
+
+		shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second)
+		defer shutdownCancel()
+
+		if err := traceExporter.Shutdown(shutdownCtx); err != nil {
+			logrus.WithError(err).Error("unable to shutdown OpenTelemetry trace exporter")
+		}
+
+		if err := traceProvider.Shutdown(shutdownCtx); err != nil {
+			logrus.WithError(err).Error("unable to shutdown OpenTelemetry trace provider")
+		}
+	}()
+
+	logrus.Info("OpenTelemetry trace exporter started")
+
+	return nil
+}
+
+func enableOpenTracing(tc *conf.TracingConfig) {
+	tracerOps := []tracer.StartOption{
+		tracer.WithServiceName(tc.ServiceName),
+		tracer.WithAgentAddr(net.JoinHostPort(tc.Host, tc.Port)),
+	}
+
+	for k, v := range tc.Tags {
+		tracerOps = append(tracerOps, tracer.WithGlobalTag(k, v))
+	}
+
+	opentracing.SetGlobalTracer(opentracer.New(tracerOps...))
+}
+
 var (
 	tracingOnce sync.Once
 )
 
-func ConfigureTracing(tc *conf.TracingConfig) error {
+// ConfigureTracing sets up global tracing configuration for OpenTracing /
+// OpenTelemetry. The context should be the global context. Cancelling this
+// context will cancel tracing collection.
+func ConfigureTracing(ctx context.Context, tc *conf.TracingConfig) error {
+	if ctx == nil {
+		panic("context must not be nil")
+	}
+
+	var err error
+
 	tracingOnce.Do(func() {
-		var t opentracing.Tracer = opentracing.NoopTracer{}
+		opentracing.SetGlobalTracer(opentracing.NoopTracer{})
 
 		if tc.Enabled {
-			tracerOps := []tracer.StartOption{
-				tracer.WithServiceName(tc.ServiceName),
-				tracer.WithAgentAddr(net.JoinHostPort(tc.Host, tc.Port)),
-			}
+			switch tc.Exporter {
+			case conf.OpenTelemetryTracing:
+				if err = enableOpenTelemetryTracing(ctx, tc); err != nil {
+					logrus.WithError(err).Error("unable to start OTLP trace exporter")
+				}
 
-			for k, v := range tc.Tags {
-				tracerOps = append(tracerOps, tracer.WithGlobalTag(k, v))
+			case conf.OpenTracing:
+				enableOpenTracing(tc)
 			}
-
-			t = opentracer.New(tracerOps...)
 		}
-
-		opentracing.SetGlobalTracer(t)
 	})
 
-	return nil
+	return err
 }
diff --git a/security/hcaptcha.go b/security/hcaptcha.go
index 66bb3a1c..ba8e6da1 100644
--- a/security/hcaptcha.go
+++ b/security/hcaptcha.go
@@ -57,10 +57,6 @@ func init() {
 }
 
 func VerifyRequest(r *http.Request, secretKey string) (VerificationResult, error) {
-	if r.FormValue("grant_type") == "refresh_token" {
-		// captcha shouldn't be enabled on requests to refresh the token
-		return SuccessfullyVerified, nil
-	}
 	res := GotrueRequest{}
 	bodyBytes, err := io.ReadAll(r.Body)
 	if err != nil {
diff --git a/storage/dial.go b/storage/dial.go
index 71064da8..24f19851 100644
--- a/storage/dial.go
+++ b/storage/dial.go
@@ -2,11 +2,14 @@ package storage
 
 import (
 	"context"
+	"database/sql"
 	"net/url"
 	"reflect"
 
+	"github.com/XSAM/otelsql"
 	"github.com/gobuffalo/pop/v5"
 	"github.com/gobuffalo/pop/v5/columns"
+	"github.com/jmoiron/sqlx"
 	"github.com/netlify/gotrue/conf"
 	"github.com/pkg/errors"
 	"github.com/sirupsen/logrus"
@@ -27,12 +30,33 @@ func Dial(config *conf.GlobalConfiguration) (*Connection, error) {
 		config.DB.Driver = u.Scheme
 	}
 
+	driver := ""
 	if config.DB.Driver != "postgres" {
 		logrus.Warn("DEPRECATION NOTICE: only PostgreSQL is supported by Supabase's GoTrue, will be removed soon")
+	} else {
+		// pop v5 uses pgx as the default PostgreSQL driver
+		driver = "pgx"
+	}
+
+	if driver != "" && (config.Tracing.Enabled || config.Metrics.Enabled) {
+		instrumentedDriver, err := otelsql.Register(driver)
+		if err != nil {
+			logrus.WithError(err).Errorf("unable to instrument sql driver %q for use with OpenTelemetry", driver)
+		} else {
+			logrus.Debugf("using %s as an instrumented driver for OpenTelemetry", instrumentedDriver)
+
+			// sqlx needs to be informed that the new instrumented
+			// driver has the same semantics as the
+			// non-instrumented driver
+			sqlx.BindDriver(instrumentedDriver, sqlx.BindType(driver))
+
+			driver = instrumentedDriver
+		}
 	}
 
 	db, err := pop.NewConnection(&pop.ConnectionDetails{
 		Dialect: config.DB.Driver,
+		Driver:  driver,
 		URL:     config.DB.URL,
 		Pool:    config.DB.MaxPoolSize,
 	})
@@ -42,9 +66,38 @@ func Dial(config *conf.GlobalConfiguration) (*Connection, error) {
 	if err := db.Open(); err != nil {
 		return nil, errors.Wrap(err, "checking database connection")
 	}
+
+	if config.Metrics.Enabled {
+		registerOpenTelemetryDatabaseStats(db)
+	}
+
 	return &Connection{db}, nil
 }
 
+func registerOpenTelemetryDatabaseStats(db *pop.Connection) {
+	defer func() {
+		if rec := recover(); rec != nil {
+			logrus.WithField("error", rec).Error("registerOpenTelemetryDatabaseStats is not able to determine database object with reflection -- panicked")
+		}
+	}()
+
+	dbval := reflect.Indirect(reflect.ValueOf(db.Store))
+	dbfield := dbval.Field(0)
+	sqldbfield := reflect.Indirect(dbfield).Field(0)
+
+	sqldb, ok := sqldbfield.Interface().(*sql.DB)
+	if !ok || sqldb == nil {
+		logrus.Error("registerOpenTelemetryDatabaseStats is not able to determine database object with reflection")
+		return
+	}
+
+	if err := otelsql.RegisterDBStatsMetrics(sqldb); err != nil {
+		logrus.WithError(err).Error("unable to register OpenTelemetry stats metrics for databse")
+	} else {
+		logrus.Debug("registered OpenTelemetry stats metrics for database")
+	}
+}
+
 func (c *Connection) Transaction(fn func(*Connection) error) error {
 	if c.TX == nil {
 		return c.Connection.Transaction(func(tx *pop.Connection) error {
diff --git a/swagger.json b/swagger.json
new file mode 100644
index 00000000..6a36e7f8
--- /dev/null
+++ b/swagger.json
@@ -0,0 +1,1295 @@
+{
+  "produces": [
+    "application/json"
+  ],
+  "schemes": [
+    "http",
+    "https"
+  ],
+  "swagger": "2.0",
+  "info": {
+    "description": "Documentation of the gotrue API.",
+    "title": "gotrue",
+    "version": "1.0.0"
+  },
+  "host": "localhost:9999",
+  "basePath": "/",
+  "paths": {
+    "/admin/generate_link": {
+      "post": {
+        "security": [
+          {
+            "bearer": []
+          }
+        ],
+        "tags": [
+          "admin"
+        ],
+        "summary": "Generates an email action link.",
+        "operationId": "admin-generate-link",
+        "parameters": [
+          {
+            "name": "Body",
+            "in": "body",
+            "schema": {
+              "$ref": "#/definitions/GenerateLinkParams"
+            }
+          }
+        ],
+        "responses": {
+          "200": {
+            "$ref": "#/responses/generateLinkResponse"
+          },
+          "401": {
+            "$ref": "#/responses/unauthorizedError"
+          }
+        }
+      }
+    },
+    "/admin/user/{user_id}": {
+      "get": {
+        "security": [
+          {
+            "bearer": []
+          }
+        ],
+        "tags": [
+          "admin"
+        ],
+        "summary": "Get a user.",
+        "operationId": "admin-get-user",
+        "parameters": [
+          {
+            "description": "The user's id",
+            "name": "user_id",
+            "in": "path",
+            "required": true
+          }
+        ],
+        "responses": {
+          "200": {
+            "$ref": "#/responses/userResponse"
+          },
+          "401": {
+            "$ref": "#/responses/unauthorizedError"
+          }
+        }
+      },
+      "put": {
+        "security": [
+          {
+            "bearer": []
+          }
+        ],
+        "tags": [
+          "admin"
+        ],
+        "summary": "Update a user.",
+        "operationId": "admin-update-user",
+        "parameters": [
+          {
+            "description": "The user's id",
+            "name": "user_id",
+            "in": "path",
+            "required": true
+          }
+        ],
+        "responses": {
+          "200": {
+            "$ref": "#/responses/userResponse"
+          },
+          "401": {
+            "$ref": "#/responses/unauthorizedError"
+          }
+        }
+      },
+      "delete": {
+        "security": [
+          {
+            "bearer": []
+          }
+        ],
+        "tags": [
+          "admin"
+        ],
+        "summary": "Deletes a user.",
+        "operationId": "admin-delete-user",
+        "parameters": [
+          {
+            "description": "The user's id",
+            "name": "user_id",
+            "in": "path",
+            "required": true
+          }
+        ],
+        "responses": {
+          "200": {
+            "$ref": "#/responses/deleteUserResponse"
+          },
+          "401": {
+            "$ref": "#/responses/unauthorizedError"
+          }
+        }
+      }
+    },
+    "/admin/users": {
+      "get": {
+        "security": [
+          {
+            "bearer": []
+          }
+        ],
+        "tags": [
+          "admin"
+        ],
+        "summary": "List all users.",
+        "operationId": "admin-list-users",
+        "responses": {
+          "200": {
+            "$ref": "#/responses/adminListUserResponse"
+          },
+          "401": {
+            "$ref": "#/responses/unauthorizedError"
+          }
+        }
+      },
+      "post": {
+        "security": [
+          {
+            "bearer": []
+          }
+        ],
+        "tags": [
+          "admin"
+        ],
+        "summary": "Returns the created user.",
+        "operationId": "admin-create-user",
+        "parameters": [
+          {
+            "name": "Body",
+            "in": "body",
+            "schema": {
+              "$ref": "#/definitions/AdminUserParams"
+            }
+          }
+        ],
+        "responses": {
+          "200": {
+            "$ref": "#/responses/userResponse"
+          },
+          "401": {
+            "$ref": "#/responses/unauthorizedError"
+          }
+        }
+      }
+    },
+    "/authorize": {
+      "get": {
+        "tags": [
+          "oauth"
+        ],
+        "summary": "Redirects the user to the 3rd-party OAuth provider to start the OAuth1.0 or OAuth2.0 authentication process.",
+        "operationId": "authorize",
+        "parameters": [
+          {
+            "description": "The redirect url to return the user to after the `/callback` endpoint has completed.",
+            "name": "redirect_to",
+            "in": "query"
+          }
+        ],
+        "responses": {
+          "302": {
+            "$ref": "#/responses/authorizeResponse"
+          }
+        }
+      }
+    },
+    "/callback": {
+      "get": {
+        "tags": [
+          "oauth"
+        ],
+        "summary": "Receives the redirect from an external provider during the OAuth authentication process. Starts the process of creating an access and refresh token.",
+        "operationId": "callback",
+        "responses": {
+          "302": {
+            "$ref": "#/responses/callbackResponse"
+          }
+        }
+      }
+    },
+    "/health": {
+      "get": {
+        "tags": [
+          "health"
+        ],
+        "summary": "The healthcheck endpoint for gotrue. Returns the current gotrue version.",
+        "operationId": "health",
+        "responses": {
+          "200": {
+            "$ref": "#/responses/healthCheckResponse"
+          }
+        }
+      }
+    },
+    "/invite": {
+      "post": {
+        "tags": [
+          "invite"
+        ],
+        "summary": "Sends an invite link to the user.",
+        "operationId": "invite",
+        "parameters": [
+          {
+            "name": "Body",
+            "in": "body",
+            "schema": {
+              "$ref": "#/definitions/InviteParams"
+            }
+          }
+        ],
+        "responses": {
+          "200": {
+            "$ref": "#/responses/inviteResponse"
+          }
+        }
+      }
+    },
+    "/logout": {
+      "post": {
+        "security": [
+          {
+            "bearer": []
+          }
+        ],
+        "tags": [
+          "logout"
+        ],
+        "summary": "Logs out the user.",
+        "operationId": "logout",
+        "responses": {
+          "204": {
+            "$ref": "#/responses/logoutResponse"
+          }
+        }
+      }
+    },
+    "/otp": {
+      "post": {
+        "tags": [
+          "otp"
+        ],
+        "summary": "Passwordless sign-in method for email or phone.",
+        "operationId": "otp",
+        "parameters": [
+          {
+            "description": "Only an email or phone should be provided.",
+            "name": "Body",
+            "in": "body",
+            "schema": {
+              "$ref": "#/definitions/OtpParams"
+            }
+          }
+        ],
+        "responses": {
+          "200": {
+            "$ref": "#/responses/otpResponse"
+          }
+        }
+      }
+    },
+    "/recover": {
+      "post": {
+        "tags": [
+          "recovery"
+        ],
+        "summary": "Sends a password recovery email link to the user's email.",
+        "operationId": "recovery",
+        "parameters": [
+          {
+            "name": "Body",
+            "in": "body",
+            "schema": {
+              "$ref": "#/definitions/RecoverParams"
+            }
+          }
+        ],
+        "responses": {
+          "200": {
+            "$ref": "#/responses/recoveryResponse"
+          }
+        }
+      }
+    },
+    "/settings": {
+      "get": {
+        "tags": [
+          "settings"
+        ],
+        "summary": "Returns the configuration settings for the gotrue server.",
+        "operationId": "settings",
+        "responses": {
+          "200": {
+            "$ref": "#/responses/settingsResponse"
+          }
+        }
+      }
+    },
+    "/signup": {
+      "post": {
+        "tags": [
+          "signup"
+        ],
+        "summary": "Password-based signup with either email or phone.",
+        "operationId": "signup",
+        "parameters": [
+          {
+            "name": "Body",
+            "in": "body",
+            "schema": {
+              "$ref": "#/definitions/SignupParams"
+            }
+          }
+        ],
+        "responses": {
+          "200": {
+            "$ref": "#/responses/userResponse"
+          }
+        }
+      }
+    },
+    "/token?grant_type=password": {
+      "post": {
+        "tags": [
+          "token"
+        ],
+        "summary": "Signs in a user with a password.",
+        "operationId": "token-password",
+        "parameters": [
+          {
+            "name": "Body",
+            "in": "body",
+            "schema": {
+              "$ref": "#/definitions/PasswordGrantParams"
+            }
+          }
+        ],
+        "responses": {
+          "200": {
+            "$ref": "#/responses/tokenResponse"
+          }
+        }
+      }
+    },
+    "/token?grant_type=refresh_token": {
+      "post": {
+        "tags": [
+          "token"
+        ],
+        "summary": "Refreshes a user's refresh token.",
+        "operationId": "token-refresh",
+        "parameters": [
+          {
+            "name": "Body",
+            "in": "body",
+            "schema": {
+              "$ref": "#/definitions/RefreshTokenGrantParams"
+            }
+          }
+        ],
+        "responses": {
+          "200": {
+            "$ref": "#/responses/tokenResponse"
+          }
+        }
+      }
+    },
+    "/user": {
+      "get": {
+        "security": [
+          {
+            "bearer": []
+          }
+        ],
+        "tags": [
+          "user"
+        ],
+        "summary": "Get information for the logged-in user.",
+        "operationId": "user-get",
+        "responses": {
+          "200": {
+            "$ref": "#/responses/userResponse"
+          },
+          "401": {
+            "$ref": "#/responses/unauthorizedError"
+          }
+        }
+      },
+      "put": {
+        "security": [
+          {
+            "bearer": []
+          }
+        ],
+        "tags": [
+          "user"
+        ],
+        "summary": "Returns the updated user.",
+        "operationId": "user-put",
+        "parameters": [
+          {
+            "name": "Body",
+            "in": "body",
+            "schema": {
+              "$ref": "#/definitions/UserUpdateParams"
+            }
+          }
+        ],
+        "responses": {
+          "200": {
+            "$ref": "#/responses/userResponse"
+          },
+          "401": {
+            "$ref": "#/responses/unauthorizedError"
+          }
+        }
+      }
+    },
+    "/verify": {
+      "get": {
+        "tags": [
+          "verify"
+        ],
+        "summary": "Verifies a sign up.",
+        "operationId": "verify-get",
+        "parameters": [
+          {
+            "type": "string",
+            "x-go-name": "Type",
+            "name": "type",
+            "in": "query"
+          },
+          {
+            "type": "string",
+            "x-go-name": "Token",
+            "name": "token",
+            "in": "query"
+          },
+          {
+            "type": "string",
+            "x-go-name": "Email",
+            "name": "email",
+            "in": "query"
+          },
+          {
+            "type": "string",
+            "x-go-name": "Phone",
+            "name": "phone",
+            "in": "query"
+          },
+          {
+            "type": "string",
+            "x-go-name": "RedirectTo",
+            "name": "redirect_to",
+            "in": "query"
+          }
+        ]
+      },
+      "post": {
+        "tags": [
+          "verify"
+        ],
+        "summary": "Verifies a sign up.",
+        "operationId": "verify-post",
+        "parameters": [
+          {
+            "name": "Body",
+            "in": "body",
+            "schema": {
+              "$ref": "#/definitions/VerifyParams"
+            }
+          }
+        ]
+      }
+    }
+  },
+  "definitions": {
+    "AccessTokenResponse": {
+      "description": "AccessTokenResponse represents an OAuth2 success response",
+      "type": "object",
+      "properties": {
+        "access_token": {
+          "type": "string",
+          "x-go-name": "Token"
+        },
+        "expires_in": {
+          "type": "integer",
+          "format": "int64",
+          "x-go-name": "ExpiresIn"
+        },
+        "refresh_token": {
+          "type": "string",
+          "x-go-name": "RefreshToken"
+        },
+        "token_type": {
+          "type": "string",
+          "x-go-name": "TokenType"
+        },
+        "user": {
+          "$ref": "#/definitions/User"
+        }
+      },
+      "x-go-package": "github.com/netlify/gotrue/api"
+    },
+    "AdminListUsersResponse": {
+      "type": "object",
+      "properties": {
+        "aud": {
+          "type": "string",
+          "x-go-name": "Aud"
+        },
+        "users": {
+          "type": "array",
+          "items": {
+            "$ref": "#/definitions/User"
+          },
+          "x-go-name": "Users"
+        }
+      },
+      "x-go-package": "github.com/netlify/gotrue/api"
+    },
+    "AdminUserParams": {
+      "type": "object",
+      "properties": {
+        "app_metadata": {
+          "type": "object",
+          "additionalProperties": {},
+          "x-go-name": "AppMetaData"
+        },
+        "aud": {
+          "type": "string",
+          "x-go-name": "Aud"
+        },
+        "ban_duration": {
+          "type": "string",
+          "x-go-name": "BanDuration"
+        },
+        "email": {
+          "type": "string",
+          "x-go-name": "Email"
+        },
+        "email_confirm": {
+          "type": "boolean",
+          "x-go-name": "EmailConfirm"
+        },
+        "password": {
+          "type": "string",
+          "x-go-name": "Password"
+        },
+        "phone": {
+          "type": "string",
+          "x-go-name": "Phone"
+        },
+        "phone_confirm": {
+          "type": "boolean",
+          "x-go-name": "PhoneConfirm"
+        },
+        "role": {
+          "type": "string",
+          "x-go-name": "Role"
+        },
+        "user_metadata": {
+          "type": "object",
+          "additionalProperties": {},
+          "x-go-name": "UserMetaData"
+        }
+      },
+      "x-go-package": "github.com/netlify/gotrue/api"
+    },
+    "GenerateLinkParams": {
+      "type": "object",
+      "properties": {
+        "data": {
+          "type": "object",
+          "additionalProperties": {},
+          "x-go-name": "Data"
+        },
+        "email": {
+          "type": "string",
+          "x-go-name": "Email"
+        },
+        "new_email": {
+          "type": "string",
+          "x-go-name": "NewEmail"
+        },
+        "password": {
+          "type": "string",
+          "x-go-name": "Password"
+        },
+        "redirect_to": {
+          "type": "string",
+          "x-go-name": "RedirectTo"
+        },
+        "type": {
+          "type": "string",
+          "x-go-name": "Type"
+        }
+      },
+      "x-go-package": "github.com/netlify/gotrue/api"
+    },
+    "GenerateLinkResponse": {
+      "type": "object",
+      "properties": {
+        "action_link": {
+          "type": "string",
+          "x-go-name": "ActionLink"
+        },
+        "app_metadata": {
+          "$ref": "#/definitions/JSONMap"
+        },
+        "aud": {
+          "type": "string",
+          "x-go-name": "Aud"
+        },
+        "banned_until": {
+          "type": "string",
+          "format": "date-time",
+          "x-go-name": "BannedUntil"
+        },
+        "confirmation_sent_at": {
+          "type": "string",
+          "format": "date-time",
+          "x-go-name": "ConfirmationSentAt"
+        },
+        "confirmed_at": {
+          "description": "For backward compatibility only. Use EmailConfirmedAt or PhoneConfirmedAt instead.",
+          "type": "string",
+          "format": "date-time",
+          "x-go-name": "ConfirmedAt"
+        },
+        "created_at": {
+          "type": "string",
+          "format": "date-time",
+          "x-go-name": "CreatedAt"
+        },
+        "email": {
+          "$ref": "#/definitions/NullString"
+        },
+        "email_change_sent_at": {
+          "type": "string",
+          "format": "date-time",
+          "x-go-name": "EmailChangeSentAt"
+        },
+        "email_confirmed_at": {
+          "type": "string",
+          "format": "date-time",
+          "x-go-name": "EmailConfirmedAt"
+        },
+        "email_otp": {
+          "type": "string",
+          "x-go-name": "EmailOtp"
+        },
+        "hashed_token": {
+          "type": "string",
+          "x-go-name": "HashedToken"
+        },
+        "id": {
+          "type": "string",
+          "format": "uuid",
+          "x-go-name": "ID"
+        },
+        "identities": {
+          "type": "array",
+          "items": {
+            "$ref": "#/definitions/Identity"
+          },
+          "x-go-name": "Identities"
+        },
+        "invited_at": {
+          "type": "string",
+          "format": "date-time",
+          "x-go-name": "InvitedAt"
+        },
+        "last_sign_in_at": {
+          "type": "string",
+          "format": "date-time",
+          "x-go-name": "LastSignInAt"
+        },
+        "new_email": {
+          "type": "string",
+          "x-go-name": "EmailChange"
+        },
+        "new_phone": {
+          "type": "string",
+          "x-go-name": "PhoneChange"
+        },
+        "phone": {
+          "$ref": "#/definitions/NullString"
+        },
+        "phone_change_sent_at": {
+          "type": "string",
+          "format": "date-time",
+          "x-go-name": "PhoneChangeSentAt"
+        },
+        "phone_confirmed_at": {
+          "type": "string",
+          "format": "date-time",
+          "x-go-name": "PhoneConfirmedAt"
+        },
+        "reauthentication_sent_at": {
+          "type": "string",
+          "format": "date-time",
+          "x-go-name": "ReauthenticationSentAt"
+        },
+        "recovery_sent_at": {
+          "type": "string",
+          "format": "date-time",
+          "x-go-name": "RecoverySentAt"
+        },
+        "redirect_to": {
+          "type": "string",
+          "x-go-name": "RedirectTo"
+        },
+        "role": {
+          "type": "string",
+          "x-go-name": "Role"
+        },
+        "updated_at": {
+          "type": "string",
+          "format": "date-time",
+          "x-go-name": "UpdatedAt"
+        },
+        "user_metadata": {
+          "$ref": "#/definitions/JSONMap"
+        },
+        "verification_type": {
+          "type": "string",
+          "x-go-name": "VerificationType"
+        }
+      },
+      "x-go-package": "github.com/netlify/gotrue/api"
+    },
+    "HealthCheckResponse": {
+      "type": "object",
+      "properties": {
+        "description": {
+          "type": "string",
+          "x-go-name": "Description"
+        },
+        "name": {
+          "type": "string",
+          "x-go-name": "Name"
+        },
+        "version": {
+          "type": "string",
+          "x-go-name": "Version"
+        }
+      },
+      "x-go-package": "github.com/netlify/gotrue/api"
+    },
+    "Identity": {
+      "type": "object",
+      "properties": {
+        "created_at": {
+          "type": "string",
+          "format": "date-time",
+          "x-go-name": "CreatedAt"
+        },
+        "id": {
+          "type": "string",
+          "x-go-name": "ID"
+        },
+        "identity_data": {
+          "$ref": "#/definitions/JSONMap"
+        },
+        "last_sign_in_at": {
+          "type": "string",
+          "format": "date-time",
+          "x-go-name": "LastSignInAt"
+        },
+        "provider": {
+          "type": "string",
+          "x-go-name": "Provider"
+        },
+        "updated_at": {
+          "type": "string",
+          "format": "date-time",
+          "x-go-name": "UpdatedAt"
+        },
+        "user_id": {
+          "type": "string",
+          "format": "uuid",
+          "x-go-name": "UserID"
+        }
+      },
+      "x-go-package": "github.com/netlify/gotrue/models"
+    },
+    "InviteParams": {
+      "description": "InviteParams are the parameters the Signup endpoint accepts",
+      "type": "object",
+      "properties": {
+        "data": {
+          "type": "object",
+          "additionalProperties": {},
+          "x-go-name": "Data"
+        },
+        "email": {
+          "type": "string",
+          "x-go-name": "Email"
+        }
+      },
+      "x-go-package": "github.com/netlify/gotrue/api"
+    },
+    "JSONMap": {
+      "type": "object",
+      "additionalProperties": {},
+      "x-go-package": "github.com/netlify/gotrue/models"
+    },
+    "NullString": {
+      "type": "string",
+      "x-go-package": "github.com/netlify/gotrue/storage"
+    },
+    "OtpParams": {
+      "description": "OtpParams contains the request body params for the otp endpoint",
+      "type": "object",
+      "properties": {
+        "create_user": {
+          "type": "boolean",
+          "x-go-name": "CreateUser"
+        },
+        "data": {
+          "type": "object",
+          "additionalProperties": {},
+          "x-go-name": "Data"
+        },
+        "email": {
+          "type": "string",
+          "x-go-name": "Email"
+        },
+        "phone": {
+          "type": "string",
+          "x-go-name": "Phone"
+        }
+      },
+      "x-go-package": "github.com/netlify/gotrue/api"
+    },
+    "PasswordGrantParams": {
+      "description": "PasswordGrantParams are the parameters the ResourceOwnerPasswordGrant method accepts",
+      "type": "object",
+      "properties": {
+        "email": {
+          "type": "string",
+          "x-go-name": "Email"
+        },
+        "password": {
+          "type": "string",
+          "x-go-name": "Password"
+        },
+        "phone": {
+          "type": "string",
+          "x-go-name": "Phone"
+        }
+      },
+      "x-go-package": "github.com/netlify/gotrue/api"
+    },
+    "ProviderSettings": {
+      "type": "object",
+      "properties": {
+        "apple": {
+          "type": "boolean",
+          "x-go-name": "Apple"
+        },
+        "azure": {
+          "type": "boolean",
+          "x-go-name": "Azure"
+        },
+        "bitbucket": {
+          "type": "boolean",
+          "x-go-name": "Bitbucket"
+        },
+        "discord": {
+          "type": "boolean",
+          "x-go-name": "Discord"
+        },
+        "email": {
+          "type": "boolean",
+          "x-go-name": "Email"
+        },
+        "facebook": {
+          "type": "boolean",
+          "x-go-name": "Facebook"
+        },
+        "github": {
+          "type": "boolean",
+          "x-go-name": "GitHub"
+        },
+        "gitlab": {
+          "type": "boolean",
+          "x-go-name": "GitLab"
+        },
+        "google": {
+          "type": "boolean",
+          "x-go-name": "Google"
+        },
+        "keycloak": {
+          "type": "boolean",
+          "x-go-name": "Keycloak"
+        },
+        "linkedin": {
+          "type": "boolean",
+          "x-go-name": "Linkedin"
+        },
+        "notion": {
+          "type": "boolean",
+          "x-go-name": "Notion"
+        },
+        "phone": {
+          "type": "boolean",
+          "x-go-name": "Phone"
+        },
+        "saml": {
+          "type": "boolean",
+          "x-go-name": "SAML"
+        },
+        "slack": {
+          "type": "boolean",
+          "x-go-name": "Slack"
+        },
+        "spotify": {
+          "type": "boolean",
+          "x-go-name": "Spotify"
+        },
+        "twitch": {
+          "type": "boolean",
+          "x-go-name": "Twitch"
+        },
+        "twitter": {
+          "type": "boolean",
+          "x-go-name": "Twitter"
+        },
+        "workos": {
+          "type": "boolean",
+          "x-go-name": "WorkOS"
+        },
+        "zoom": {
+          "type": "boolean",
+          "x-go-name": "Zoom"
+        }
+      },
+      "x-go-package": "github.com/netlify/gotrue/api"
+    },
+    "RecoverParams": {
+      "description": "RecoverParams holds the parameters for a password recovery request",
+      "type": "object",
+      "properties": {
+        "email": {
+          "type": "string",
+          "x-go-name": "Email"
+        }
+      },
+      "x-go-package": "github.com/netlify/gotrue/api"
+    },
+    "RefreshTokenGrantParams": {
+      "description": "RefreshTokenGrantParams are the parameters the RefreshTokenGrant method accepts",
+      "type": "object",
+      "properties": {
+        "refresh_token": {
+          "type": "string",
+          "x-go-name": "RefreshToken"
+        }
+      },
+      "x-go-package": "github.com/netlify/gotrue/api"
+    },
+    "Settings": {
+      "type": "object",
+      "properties": {
+        "disable_signup": {
+          "type": "boolean",
+          "x-go-name": "DisableSignup"
+        },
+        "external": {
+          "$ref": "#/definitions/ProviderSettings"
+        },
+        "mailer_autoconfirm": {
+          "type": "boolean",
+          "x-go-name": "MailerAutoconfirm"
+        },
+        "phone_autoconfirm": {
+          "type": "boolean",
+          "x-go-name": "PhoneAutoconfirm"
+        },
+        "sms_provider": {
+          "type": "string",
+          "x-go-name": "SmsProvider"
+        }
+      },
+      "x-go-package": "github.com/netlify/gotrue/api"
+    },
+    "SignupParams": {
+      "description": "SignupParams are the parameters the Signup endpoint accepts",
+      "type": "object",
+      "properties": {
+        "data": {
+          "type": "object",
+          "additionalProperties": {},
+          "x-go-name": "Data"
+        },
+        "email": {
+          "type": "string",
+          "x-go-name": "Email"
+        },
+        "password": {
+          "type": "string",
+          "x-go-name": "Password"
+        },
+        "phone": {
+          "type": "string",
+          "x-go-name": "Phone"
+        }
+      },
+      "x-go-package": "github.com/netlify/gotrue/api"
+    },
+    "User": {
+      "description": "User respresents a registered user with email/password authentication",
+      "type": "object",
+      "properties": {
+        "app_metadata": {
+          "$ref": "#/definitions/JSONMap"
+        },
+        "aud": {
+          "type": "string",
+          "x-go-name": "Aud"
+        },
+        "banned_until": {
+          "type": "string",
+          "format": "date-time",
+          "x-go-name": "BannedUntil"
+        },
+        "confirmation_sent_at": {
+          "type": "string",
+          "format": "date-time",
+          "x-go-name": "ConfirmationSentAt"
+        },
+        "confirmed_at": {
+          "description": "For backward compatibility only. Use EmailConfirmedAt or PhoneConfirmedAt instead.",
+          "type": "string",
+          "format": "date-time",
+          "x-go-name": "ConfirmedAt"
+        },
+        "created_at": {
+          "type": "string",
+          "format": "date-time",
+          "x-go-name": "CreatedAt"
+        },
+        "email": {
+          "$ref": "#/definitions/NullString"
+        },
+        "email_change_sent_at": {
+          "type": "string",
+          "format": "date-time",
+          "x-go-name": "EmailChangeSentAt"
+        },
+        "email_confirmed_at": {
+          "type": "string",
+          "format": "date-time",
+          "x-go-name": "EmailConfirmedAt"
+        },
+        "id": {
+          "type": "string",
+          "format": "uuid",
+          "x-go-name": "ID"
+        },
+        "identities": {
+          "type": "array",
+          "items": {
+            "$ref": "#/definitions/Identity"
+          },
+          "x-go-name": "Identities"
+        },
+        "invited_at": {
+          "type": "string",
+          "format": "date-time",
+          "x-go-name": "InvitedAt"
+        },
+        "last_sign_in_at": {
+          "type": "string",
+          "format": "date-time",
+          "x-go-name": "LastSignInAt"
+        },
+        "new_email": {
+          "type": "string",
+          "x-go-name": "EmailChange"
+        },
+        "new_phone": {
+          "type": "string",
+          "x-go-name": "PhoneChange"
+        },
+        "phone": {
+          "$ref": "#/definitions/NullString"
+        },
+        "phone_change_sent_at": {
+          "type": "string",
+          "format": "date-time",
+          "x-go-name": "PhoneChangeSentAt"
+        },
+        "phone_confirmed_at": {
+          "type": "string",
+          "format": "date-time",
+          "x-go-name": "PhoneConfirmedAt"
+        },
+        "reauthentication_sent_at": {
+          "type": "string",
+          "format": "date-time",
+          "x-go-name": "ReauthenticationSentAt"
+        },
+        "recovery_sent_at": {
+          "type": "string",
+          "format": "date-time",
+          "x-go-name": "RecoverySentAt"
+        },
+        "role": {
+          "type": "string",
+          "x-go-name": "Role"
+        },
+        "updated_at": {
+          "type": "string",
+          "format": "date-time",
+          "x-go-name": "UpdatedAt"
+        },
+        "user_metadata": {
+          "$ref": "#/definitions/JSONMap"
+        }
+      },
+      "x-go-package": "github.com/netlify/gotrue/models"
+    },
+    "UserUpdateParams": {
+      "description": "UserUpdateParams parameters for updating a user",
+      "type": "object",
+      "properties": {
+        "app_metadata": {
+          "type": "object",
+          "additionalProperties": {},
+          "x-go-name": "AppData"
+        },
+        "data": {
+          "type": "object",
+          "additionalProperties": {},
+          "x-go-name": "Data"
+        },
+        "email": {
+          "type": "string",
+          "x-go-name": "Email"
+        },
+        "nonce": {
+          "type": "string",
+          "x-go-name": "Nonce"
+        },
+        "password": {
+          "type": "string",
+          "x-go-name": "Password"
+        },
+        "phone": {
+          "type": "string",
+          "x-go-name": "Phone"
+        }
+      },
+      "x-go-package": "github.com/netlify/gotrue/api"
+    },
+    "VerifyParams": {
+      "description": "VerifyParams are the parameters the Verify endpoint accepts",
+      "type": "object",
+      "properties": {
+        "email": {
+          "type": "string",
+          "x-go-name": "Email"
+        },
+        "phone": {
+          "type": "string",
+          "x-go-name": "Phone"
+        },
+        "redirect_to": {
+          "type": "string",
+          "x-go-name": "RedirectTo"
+        },
+        "token": {
+          "type": "string",
+          "x-go-name": "Token"
+        },
+        "type": {
+          "type": "string",
+          "x-go-name": "Type"
+        }
+      },
+      "x-go-package": "github.com/netlify/gotrue/api"
+    }
+  },
+  "responses": {
+    "adminListUserResponse": {
+      "description": "The list of users.",
+      "schema": {
+        "$ref": "#/definitions/AdminListUsersResponse"
+      }
+    },
+    "authorizeResponse": {
+      "description": "Redirects user to the 3rd-party OAuth provider"
+    },
+    "callbackResponse": {
+      "description": "Redirects user to the redirect url specified in `/authorize`. If no `redirect_url` is provided, the user will be redirected to the `SITE_URL`."
+    },
+    "deleteUserResponse": {
+      "description": "The updated user."
+    },
+    "generateLinkResponse": {
+      "description": "The response object for generate link.",
+      "schema": {
+        "$ref": "#/definitions/GenerateLinkResponse"
+      }
+    },
+    "healthCheckResponse": {
+      "description": "",
+      "schema": {
+        "$ref": "#/definitions/HealthCheckResponse"
+      }
+    },
+    "inviteResponse": {
+      "description": ""
+    },
+    "logoutResponse": {
+      "description": ""
+    },
+    "otpResponse": {
+      "description": ""
+    },
+    "recoveryResponse": {
+      "description": ""
+    },
+    "settingsResponse": {
+      "description": "",
+      "schema": {
+        "$ref": "#/definitions/Settings"
+      }
+    },
+    "tokenResponse": {
+      "description": "",
+      "schema": {
+        "$ref": "#/definitions/AccessTokenResponse"
+      }
+    },
+    "unauthorizedError": {
+      "description": "This endpoint requires a bearer token."
+    },
+    "userResponse": {
+      "description": "The current user.",
+      "schema": {
+        "$ref": "#/definitions/User"
+      }
+    }
+  },
+  "securityDefinitions": {
+    "bearer": {
+      "type": "apiKey",
+      "name": "Authentication",
+      "in": "header"
+    }
+  }
+}
\ No newline at end of file


Test patch:
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 6273caba..2387c390 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -5,7 +5,7 @@ on:
   push:
     branches:
       - master
-      - mfa_v1
+      - mfa
       - saml
       - zerosessionidfix
     tags: ['*']
diff --git a/api/admin_test.go b/api/admin_test.go
index d3552f8d..4cdd38e5 100644
--- a/api/admin_test.go
+++ b/api/admin_test.go
@@ -457,8 +457,8 @@ func (ts *AdminTestSuite) TestAdminUserCreateWithDisabledLogin() {
 		expected     int
 	}{
 		{
-			"Email Signups Disabled",
-			&conf.GlobalConfiguration{
+			desc: "Email Signups Disabled",
+			customConfig: &conf.GlobalConfiguration{
 				JWT: ts.Config.JWT,
 				External: conf.ProviderConfiguration{
 					Email: conf.EmailProviderConfiguration{
@@ -466,15 +466,15 @@ func (ts *AdminTestSuite) TestAdminUserCreateWithDisabledLogin() {
 					},
 				},
 			},
-			map[string]interface{}{
+			userData: map[string]interface{}{
 				"email":    "test1@example.com",
 				"password": "test1",
 			},
-			http.StatusOK,
+			expected: http.StatusOK,
 		},
 		{
-			"Phone Signups Disabled",
-			&conf.GlobalConfiguration{
+			desc: "Phone Signups Disabled",
+			customConfig: &conf.GlobalConfiguration{
 				JWT: ts.Config.JWT,
 				External: conf.ProviderConfiguration{
 					Phone: conf.PhoneProviderConfiguration{
@@ -482,23 +482,23 @@ func (ts *AdminTestSuite) TestAdminUserCreateWithDisabledLogin() {
 					},
 				},
 			},
-			map[string]interface{}{
+			userData: map[string]interface{}{
 				"phone":    "123456789",
 				"password": "test1",
 			},
-			http.StatusOK,
+			expected: http.StatusOK,
 		},
 		{
-			"All Signups Disabled",
-			&conf.GlobalConfiguration{
+			desc: "All Signups Disabled",
+			customConfig: &conf.GlobalConfiguration{
 				JWT:           ts.Config.JWT,
 				DisableSignup: true,
 			},
-			map[string]interface{}{
+			userData: map[string]interface{}{
 				"email":    "test2@example.com",
 				"password": "test2",
 			},
-			http.StatusOK,
+			expected: http.StatusOK,
 		},
 	}
 
@@ -519,3 +519,113 @@ func (ts *AdminTestSuite) TestAdminUserCreateWithDisabledLogin() {
 		})
 	}
 }
+
+// TestAdminUserDeleteFactor tests API /admin/users/<user_id>/factor/<factor_id>/
+func (ts *AdminTestSuite) TestAdminUserDeleteFactor() {
+	if !ts.API.config.MFA.Enabled {
+		return
+	}
+	u, err := models.NewUser("123456789", "test-delete@example.com", "test", ts.Config.JWT.Aud, nil)
+	require.NoError(ts.T(), err, "Error making new user")
+	require.NoError(ts.T(), ts.API.db.Create(u), "Error creating user")
+
+	f, err := models.NewFactor(u, "testSimpleName", models.TOTP, models.FactorStateVerified, "secretkey")
+
+	require.NoError(ts.T(), err, "Error creating test factor model")
+	require.NoError(ts.T(), ts.API.db.Create(f), "Error saving new test factor")
+
+	// Setup request
+	w := httptest.NewRecorder()
+	req := httptest.NewRequest(http.MethodDelete, fmt.Sprintf("/admin/users/%s/factor/%s/", u.ID, f.ID), nil)
+
+	req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", ts.token))
+
+	ts.API.handler.ServeHTTP(w, req)
+	require.Equal(ts.T(), http.StatusOK, w.Code)
+
+	_, err = models.FindFactorByFactorID(ts.API.db, f.ID)
+	require.EqualError(ts.T(), err, models.FactorNotFoundError{}.Error())
+
+}
+
+// TestAdminUserGetFactor tests API /admin/user/<user_id>/factors/
+func (ts *AdminTestSuite) TestAdminUserGetFactors() {
+	if !ts.API.config.MFA.Enabled {
+		return
+	}
+	u, err := models.NewUser("123456789", "test-delete@example.com", "test", ts.Config.JWT.Aud, nil)
+	require.NoError(ts.T(), err, "Error making new user")
+	require.NoError(ts.T(), ts.API.db.Create(u), "Error creating user")
+
+	f, err := models.NewFactor(u, "testSimpleName", models.TOTP, models.FactorStateUnverified, "secretkey")
+	require.NoError(ts.T(), err, "Error creating test factor model")
+	require.NoError(ts.T(), ts.API.db.Create(f), "Error saving new test factor")
+
+	// Setup request
+	w := httptest.NewRecorder()
+	req := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/admin/users/%s/factor/", u.ID), nil)
+
+	req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", ts.token))
+
+	ts.API.handler.ServeHTTP(w, req)
+	require.Equal(ts.T(), http.StatusOK, w.Code)
+	getFactorsResp := []*models.Factor{}
+	require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&getFactorsResp))
+	require.Equal(ts.T(), getFactorsResp[0].Secret, nil)
+}
+
+func (ts *AdminTestSuite) TestAdminUserUpdateFactor() {
+	if !ts.API.config.MFA.Enabled {
+		return
+	}
+	u, err := models.NewUser("123456789", "test-delete@example.com", "test", ts.Config.JWT.Aud, nil)
+	require.NoError(ts.T(), err, "Error making new user")
+	require.NoError(ts.T(), ts.API.db.Create(u), "Error creating user")
+
+	f, err := models.NewFactor(u, "testSimpleName", models.TOTP, models.FactorStateUnverified, "secretkey")
+	require.NoError(ts.T(), err, "Error creating test factor model")
+	require.NoError(ts.T(), ts.API.db.Create(f), "Error saving new test factor")
+
+	var cases = []struct {
+		Desc         string
+		FactorData   map[string]interface{}
+		ExpectedCode int
+	}{
+		{
+			Desc: "Update Factor friendly name",
+			FactorData: map[string]interface{}{
+				"friendly_name": "john",
+			},
+			ExpectedCode: http.StatusOK,
+		},
+		{
+			Desc: "Update factor: valid factor type",
+			FactorData: map[string]interface{}{
+				"friendly_name": "john",
+				"factor_type":   models.TOTP,
+			},
+			ExpectedCode: http.StatusOK,
+		},
+		{
+			Desc: "Update factor: invalid factor",
+			FactorData: map[string]interface{}{
+				"factor_type": "invalid_factor",
+			},
+			ExpectedCode: http.StatusBadRequest,
+		},
+	}
+
+	// Initialize factor data
+	for _, c := range cases {
+		ts.Run(c.Desc, func() {
+			var buffer bytes.Buffer
+			require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(c.FactorData))
+			w := httptest.NewRecorder()
+			req := httptest.NewRequest(http.MethodPut, fmt.Sprintf("/admin/users/%s/factor/%s/", u.ID, f.ID), &buffer)
+			req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", ts.token))
+			ts.API.handler.ServeHTTP(w, req)
+			require.Equal(ts.T(), c.ExpectedCode, w.Code)
+		})
+	}
+
+}
diff --git a/api/api_test.go b/api/api_test.go
index aae8c47b..d8faf948 100644
--- a/api/api_test.go
+++ b/api/api_test.go
@@ -4,7 +4,6 @@ import (
 	"context"
 	"testing"
 
-	"github.com/gofrs/uuid"
 	"github.com/netlify/gotrue/conf"
 	"github.com/netlify/gotrue/models"
 	"github.com/netlify/gotrue/storage"
@@ -20,7 +19,6 @@ const (
 
 func init() {
 	models.PasswordHashCost = bcrypt.MinCost
-
 }
 
 // setupAPIForTest creates a new API to run tests with.
@@ -30,17 +28,25 @@ func setupAPIForTest() (*API, *conf.GlobalConfiguration, error) {
 	return setupAPIForTestWithCallback(nil)
 }
 
-func setupAPIForTestWithCallback(cb func(*conf.GlobalConfiguration, *storage.Connection) (uuid.UUID, error)) (*API, *conf.GlobalConfiguration, error) {
+func setupAPIForTestWithCallback(cb func(*conf.GlobalConfiguration, *storage.Connection)) (*API, *conf.GlobalConfiguration, error) {
 	config, err := conf.LoadGlobal(apiTestConfig)
 	if err != nil {
 		return nil, nil, err
 	}
 
+	if cb != nil {
+		cb(config, nil)
+	}
+
 	conn, err := test.SetupDBConnection(config)
 	if err != nil {
 		return nil, nil, err
 	}
 
+	if cb != nil {
+		cb(nil, conn)
+	}
+
 	return NewAPIWithVersion(context.Background(), config, conn, apiTestVersion), config, nil
 }
 
diff --git a/api/audit_test.go b/api/audit_test.go
index 32f99ff4..d0cba113 100644
--- a/api/audit_test.go
+++ b/api/audit_test.go
@@ -48,7 +48,13 @@ func (ts *AuditTestSuite) makeSuperAdmin(email string) string {
 
 	u.Role = "supabase_admin"
 
-	token, err := generateAccessToken(u, nil, time.Second*time.Duration(ts.Config.JWT.Exp), ts.Config.JWT.Secret)
+	var token string
+	if ts.Config.MFA.Enabled {
+		token, err = MFA_generateAccessToken(ts.API.db, u, nil, time.Second*time.Duration(ts.Config.JWT.Exp), ts.Config.JWT.Secret)
+	} else {
+		token, err = generateAccessToken(u, nil, time.Second*time.Duration(ts.Config.JWT.Exp), ts.Config.JWT.Secret)
+
+	}
 	require.NoError(ts.T(), err, "Error generating access token")
 
 	p := jwt.Parser{ValidMethods: []string{jwt.SigningMethodHS256.Name}}
diff --git a/api/auth_test.go b/api/auth_test.go
index 24a9995f..dbfeae15 100644
--- a/api/auth_test.go
+++ b/api/auth_test.go
@@ -1,7 +1,6 @@
 package api
 
 import (
-	"errors"
 	"net/http"
 	"net/http/httptest"
 	"testing"
@@ -77,7 +76,13 @@ func (ts *AuthTestSuite) TestMaybeLoadUserOrSession() {
 	u, err := models.FindUserByEmailAndAudience(ts.API.db, "test@example.com", ts.Config.JWT.Aud)
 	require.NoError(ts.T(), err)
 
-	s, err := models.CreateSession(ts.API.db, u)
+	var s *models.Session
+	if ts.Config.MFA.Enabled {
+		s, err = models.MFA_CreateSession(ts.API.db, u, &uuid.Nil)
+	} else {
+		s, err = models.CreateSession(ts.API.db, u)
+
+	}
 	require.NoError(ts.T(), err)
 
 	cases := []struct {
@@ -95,7 +100,7 @@ func (ts *AuthTestSuite) TestMaybeLoadUserOrSession() {
 				},
 				Role: "authenticated",
 			},
-			ExpectedError: errors.New("invalid claim: subject missing"),
+			ExpectedError: unauthorizedError("invalid claim: missing sub claim"),
 			ExpectedUser:  nil,
 		},
 		{
@@ -109,6 +114,17 @@ func (ts *AuthTestSuite) TestMaybeLoadUserOrSession() {
 			ExpectedError: nil,
 			ExpectedUser:  u,
 		},
+		{
+			Desc: "Invalid Subject Claim",
+			UserJwtClaims: &GoTrueClaims{
+				StandardClaims: jwt.StandardClaims{
+					Subject: "invalid-subject-claim",
+				},
+				Role: "authenticated",
+			},
+			ExpectedError: badRequestError("invalid claim: sub claim must be a UUID"),
+			ExpectedUser:  nil,
+		},
 		{
 			Desc: "Empty Session ID Claim",
 			UserJwtClaims: &GoTrueClaims{
@@ -160,7 +176,11 @@ func (ts *AuthTestSuite) TestMaybeLoadUserOrSession() {
 			ctx, err := ts.API.parseJWTClaims(userJwt, req, w)
 			require.NoError(ts.T(), err)
 			ctx, err = ts.API.maybeLoadUserOrSession(ctx)
-			require.Equal(ts.T(), c.ExpectedError, err)
+			if c.ExpectedError != nil {
+				require.Equal(ts.T(), c.ExpectedError.Error(), err.Error())
+			} else {
+				require.Equal(ts.T(), c.ExpectedError, err)
+			}
 			require.Equal(ts.T(), c.ExpectedUser, getUser(ctx))
 			require.Equal(ts.T(), c.ExpectedSession, getSession(ctx))
 		})
diff --git a/api/invite_test.go b/api/invite_test.go
index 70cccea3..6f28b5a1 100644
--- a/api/invite_test.go
+++ b/api/invite_test.go
@@ -58,7 +58,12 @@ func (ts *InviteTestSuite) makeSuperAdmin(email string) string {
 
 	u.Role = "supabase_admin"
 
-	token, err := generateAccessToken(u, nil, time.Second*time.Duration(ts.Config.JWT.Exp), ts.Config.JWT.Secret)
+	var token string
+	if ts.API.config.MFA.Enabled {
+		token, err = MFA_generateAccessToken(ts.API.db, u, nil, time.Second*time.Duration(ts.Config.JWT.Exp), ts.Config.JWT.Secret)
+	} else {
+		token, err = generateAccessToken(u, nil, time.Second*time.Duration(ts.Config.JWT.Exp), ts.Config.JWT.Secret)
+	}
 	require.NoError(ts.T(), err, "Error generating access token")
 
 	p := jwt.Parser{ValidMethods: []string{jwt.SigningMethodHS256.Name}}
diff --git a/api/logout_test.go b/api/logout_test.go
index 30dc69a6..a32620f6 100644
--- a/api/logout_test.go
+++ b/api/logout_test.go
@@ -41,7 +41,12 @@ func (ts *LogoutTestSuite) SetupTest() {
 	require.NoError(ts.T(), ts.API.db.Create(u), "Error saving new test user")
 
 	// generate access token to use for logout
-	t, err := generateAccessToken(u, nil, time.Second*time.Duration(ts.Config.JWT.Exp), ts.Config.JWT.Secret)
+	var t string
+	if ts.Config.MFA.Enabled {
+		t, err = MFA_generateAccessToken(ts.API.db, u, nil, time.Second*time.Duration(ts.Config.JWT.Exp), ts.Config.JWT.Secret)
+	} else {
+		t, err = generateAccessToken(u, nil, time.Second*time.Duration(ts.Config.JWT.Exp), ts.Config.JWT.Secret)
+	}
 	require.NoError(ts.T(), err)
 	ts.token = t
 }
diff --git a/api/mfa_test.go b/api/mfa_test.go
new file mode 100644
index 00000000..d777bc67
--- /dev/null
+++ b/api/mfa_test.go
@@ -0,0 +1,517 @@
+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"
+	"github.com/netlify/gotrue/models"
+	"github.com/netlify/gotrue/utilities"
+	"github.com/pquerna/otp"
+
+	"github.com/jackc/pgx/v4"
+
+	"github.com/pquerna/otp/totp"
+	"github.com/stretchr/testify/require"
+	"github.com/stretchr/testify/suite"
+)
+
+type MFATestSuite struct {
+	suite.Suite
+	API        *API
+	Config     *conf.GlobalConfiguration
+	TestDomain string
+	TestEmail  string
+	TestOTPKey *otp.Key
+}
+
+func TestMFA(t *testing.T) {
+	api, config, err := setupAPIForTest()
+	require.NoError(t, err)
+	ts := &MFATestSuite{
+		API:    api,
+		Config: config,
+	}
+	defer api.db.Close()
+	if config.MFA.Enabled {
+		suite.Run(t, ts)
+	}
+}
+
+func (ts *MFATestSuite) SetupTest() {
+	models.TruncateAll(ts.API.db)
+	// Create user
+	u, err := models.NewUser("123456789", "test@example.com", "password", ts.Config.JWT.Aud, nil)
+	require.NoError(ts.T(), err, "Error creating test user model")
+	require.NoError(ts.T(), ts.API.db.Create(u), "Error saving new test user")
+	// Create Factor
+	f, err := models.NewFactor(u, "test_factor", models.TOTP, models.FactorStateUnverified, "secretkey")
+	require.NoError(ts.T(), err, "Error creating test factor model")
+	require.NoError(ts.T(), ts.API.db.Create(f), "Error saving new test factor")
+	// Create corresponding sessoin
+	s, err := models.NewSession(u, &f.ID)
+	require.NoError(ts.T(), err, "Error creating test session")
+	require.NoError(ts.T(), ts.API.db.Create(s), "Error saving test session")
+
+	// Generate TOTP related settings
+	emailValue, err := u.Email.Value()
+	require.NoError(ts.T(), err)
+	testEmail := emailValue.(string)
+	testDomain := strings.Split(testEmail, "@")[1]
+	ts.TestDomain = testDomain
+	ts.TestEmail = testEmail
+
+	key, err := totp.Generate(totp.GenerateOpts{
+		Issuer:      ts.TestDomain,
+		AccountName: ts.TestEmail,
+	})
+	require.NoError(ts.T(), err)
+	ts.TestOTPKey = key
+
+}
+
+func (ts *MFATestSuite) TestEnrollFactor() {
+	testFriendlyName := "bob"
+	alternativeFriendlyName := "john"
+	var cases = []struct {
+		desc         string
+		friendlyName string
+		factorType   string
+		issuer       string
+		expectedCode int
+	}{
+		{
+			desc:         "TOTP: No issuer",
+			friendlyName: alternativeFriendlyName,
+			factorType:   models.TOTP,
+			issuer:       "",
+			expectedCode: http.StatusOK,
+		},
+		{
+			desc:         "Invalid factor type",
+			friendlyName: testFriendlyName,
+			factorType:   "invalid_factor",
+			issuer:       ts.TestDomain,
+			expectedCode: http.StatusBadRequest,
+		},
+
+		{
+			desc:         "TOTP: Factor has friendly name",
+			friendlyName: testFriendlyName,
+			factorType:   models.TOTP,
+			issuer:       ts.TestDomain,
+			expectedCode: http.StatusOK,
+		},
+		{
+			desc:         "TOTP: Enrolling without friendly name",
+			friendlyName: "",
+			factorType:   models.TOTP,
+			issuer:       ts.TestDomain,
+			expectedCode: http.StatusOK,
+		},
+	}
+	for _, c := range cases {
+		ts.Run(c.desc, func() {
+			var buffer bytes.Buffer
+			require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]string{"friendly_name": c.friendlyName, "factor_type": c.factorType, "issuer": c.issuer}))
+			user, err := models.FindUserByEmailAndAudience(ts.API.db, "test@example.com", ts.Config.JWT.Aud)
+			ts.Require().NoError(err)
+
+			token, err := MFA_generateAccessToken(ts.API.db, user, nil, time.Second*time.Duration(ts.Config.JWT.Exp), ts.Config.JWT.Secret)
+			require.NoError(ts.T(), err)
+
+			w := httptest.NewRecorder()
+			req := httptest.NewRequest(http.MethodPost, "/factors", &buffer)
+			req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token))
+			req.Header.Set("Content-Type", "application/json")
+			ts.API.handler.ServeHTTP(w, req)
+			require.Equal(ts.T(), c.expectedCode, w.Code)
+
+			factors, err := models.FindFactorsByUser(ts.API.db, user)
+			ts.Require().NoError(err)
+			latestFactor := factors[len(factors)-1]
+			require.Equal(ts.T(), models.FactorStateUnverified, latestFactor.Status)
+			if c.friendlyName != "" && c.expectedCode == http.StatusOK {
+				require.Equal(ts.T(), c.friendlyName, latestFactor.FriendlyName)
+			}
+			if w.Code == http.StatusOK {
+				enrollResp := EnrollFactorResponse{}
+				require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&enrollResp))
+				qrCode := enrollResp.TOTP.QRCode
+				hasSVGStartAndEnd := strings.Contains(qrCode, "<svg") && strings.Contains(qrCode, "</svg>")
+				require.True(ts.T(), hasSVGStartAndEnd)
+			}
+		})
+	}
+
+}
+
+func (ts *MFATestSuite) TestChallengeFactor() {
+	u, err := models.FindUserByEmailAndAudience(ts.API.db, "test@example.com", ts.Config.JWT.Aud)
+	require.NoError(ts.T(), err)
+
+	factors, err := models.FindFactorsByUser(ts.API.db, u)
+	require.NoError(ts.T(), err)
+	f := factors[0]
+
+	token, err := MFA_generateAccessToken(ts.API.db, u, nil, time.Second*time.Duration(ts.Config.JWT.Exp), ts.Config.JWT.Secret)
+	require.NoError(ts.T(), err, "Error generating access token")
+
+	var buffer bytes.Buffer
+	req := httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost/factors/%s/challenge", f.ID), &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(), http.StatusOK, w.Code)
+}
+
+func (ts *MFATestSuite) TestMFAVerifyFactor() {
+	cases := []struct {
+		desc             string
+		validChallenge   bool
+		validCode        bool
+		expectedHTTPCode int
+	}{
+		{
+			desc:             "Invalid: Valid code and expired challenge",
+			validChallenge:   false,
+			validCode:        true,
+			expectedHTTPCode: http.StatusBadRequest,
+		},
+		{
+			desc:             "Invalid: Invalid code and valid challenge ",
+			validChallenge:   true,
+			validCode:        false,
+			expectedHTTPCode: http.StatusBadRequest,
+		},
+		{
+			desc:             "Valid /verify request",
+			validChallenge:   true,
+			validCode:        true,
+			expectedHTTPCode: http.StatusOK,
+		},
+	}
+	for _, v := range cases {
+		ts.Run(v.desc, func() {
+			// Authenticate users and set secret
+			user, err := models.FindUserByEmailAndAudience(ts.API.db, ts.TestEmail, ts.Config.JWT.Aud)
+			ts.Require().NoError(err)
+			var buffer bytes.Buffer
+			r, err := models.GrantAuthenticatedUser(ts.API.db, user, models.GrantParams{})
+			require.NoError(ts.T(), err)
+			sharedSecret := ts.TestOTPKey.Secret()
+			factors, err := models.FindFactorsByUser(ts.API.db, user)
+			f := factors[0]
+			f.Secret = sharedSecret
+			require.NoError(ts.T(), err)
+			require.NoError(ts.T(), ts.API.db.Update(f), "Error updating new test factor")
+
+			// Create session to be invalidated
+			secondarySession, err := models.NewSession(user, &f.ID)
+			require.NoError(ts.T(), err, "Error creating test session")
+			require.NoError(ts.T(), ts.API.db.Create(secondarySession), "Error saving test session")
+
+			token, err := MFA_generateAccessToken(ts.API.db, user, r.SessionId, time.Second*time.Duration(ts.Config.JWT.Exp), ts.Config.JWT.Secret)
+
+			require.NoError(ts.T(), err)
+
+			w := httptest.NewRecorder()
+			req := httptest.NewRequest(http.MethodPost, fmt.Sprintf("/factors/%s/verify", f.ID), &buffer)
+			testIPAddress := utilities.GetIPAddress(req)
+			req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token))
+			c, err := models.NewChallenge(f, testIPAddress)
+			require.NoError(ts.T(), err, "Error creating test Challenge model")
+			require.NoError(ts.T(), ts.API.db.Create(c), "Error saving new test challenge")
+			if !v.validChallenge {
+				// Set challenge creation so that it has expired in present time.
+				newCreatedAt := time.Now().UTC().Add(-1 * time.Second * time.Duration(ts.Config.MFA.ChallengeExpiryDuration+1))
+				// created_at is managed by buffalo(ORM) needs to be raw query to be updated
+				err := ts.API.db.RawQuery("UPDATE auth.mfa_challenges SET created_at = ? WHERE factor_id = ?", newCreatedAt, f.ID).Exec()
+				require.NoError(ts.T(), err, "Error updating new test challenge")
+			}
+
+			// Verify TOTP code
+			code, err := totp.GenerateCode(sharedSecret, time.Now().UTC())
+			if !v.validCode {
+				// Use an inaccurate time, resulting in an invalid code(usually)
+				code, err = totp.GenerateCode(sharedSecret, time.Now().UTC().Add(-1*time.Minute*time.Duration(1)))
+			}
+			require.NoError(ts.T(), err)
+			require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]interface{}{
+				"challenge_id": c.ID,
+				"code":         code,
+			}))
+
+			ts.API.handler.ServeHTTP(w, req)
+			require.Equal(ts.T(), v.expectedHTTPCode, w.Code)
+
+			if v.expectedHTTPCode == http.StatusOK {
+				// Ensure alternate session has been deleted
+				_, err = models.FindSessionById(ts.API.db, secondarySession.ID)
+				require.EqualError(ts.T(), err, models.SessionNotFoundError{}.Error())
+			}
+			if !v.validChallenge {
+				// Ensure invalid challenges are deleted
+				_, err := models.FindChallengeByChallengeID(ts.API.db, c.ID)
+				require.EqualError(ts.T(), err, models.ChallengeNotFoundError{}.Error())
+			}
+		})
+	}
+}
+
+func (ts *MFATestSuite) TestUnenrollVerifiedFactor() {
+	cases := []struct {
+		desc             string
+		isAAL2           bool
+		expectedHTTPCode int
+	}{
+		{
+			desc:             "Verified Factor: AAL1",
+			isAAL2:           false,
+			expectedHTTPCode: http.StatusBadRequest,
+		},
+		{
+			desc:             "Verified Factor: AAL2, Success",
+			isAAL2:           true,
+			expectedHTTPCode: http.StatusOK,
+		},
+	}
+	for _, v := range cases {
+
+		ts.Run(v.desc, func() {
+			// Create User
+			u, err := models.FindUserByEmailAndAudience(ts.API.db, "test@example.com", ts.Config.JWT.Aud)
+			require.NoError(ts.T(), err)
+			s, err := models.FindSessionByUserID(ts.API.db, u.ID)
+			require.NoError(ts.T(), err)
+			if v.isAAL2 {
+				s.UpdateAssociatedAAL(ts.API.db, models.AAL2.String())
+			}
+			var secondarySession *models.Session
+
+			// Create Session to test behaviour which downgrades other sessions
+			factors, err := models.FindFactorsByUser(ts.API.db, u)
+			require.NoError(ts.T(), err, "error finding factors")
+			f := factors[0]
+			secondarySession, err = models.NewSession(u, &f.ID)
+			require.NoError(ts.T(), err, "Error creating test session")
+			require.NoError(ts.T(), ts.API.db.Create(secondarySession), "Error saving test session")
+
+			sharedSecret := ts.TestOTPKey.Secret()
+			f.Secret = sharedSecret
+			err = f.UpdateStatus(ts.API.db, models.FactorStateVerified)
+			require.NoError(ts.T(), err)
+			require.NoError(ts.T(), ts.API.db.Update(f), "Error updating new test factor")
+
+			var buffer bytes.Buffer
+
+			token, err := MFA_generateAccessToken(ts.API.db, u, &s.ID, time.Second*time.Duration(ts.Config.JWT.Exp), ts.Config.JWT.Secret)
+			require.NoError(ts.T(), err)
+
+			w := httptest.NewRecorder()
+			req := httptest.NewRequest(http.MethodDelete, fmt.Sprintf("/factors/%s/", f.ID), &buffer)
+			req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token))
+			ts.API.handler.ServeHTTP(w, req)
+			require.Equal(ts.T(), v.expectedHTTPCode, w.Code)
+
+			if v.expectedHTTPCode == http.StatusOK {
+				_, err = models.FindFactorByFactorID(ts.API.db, f.ID)
+				require.EqualError(ts.T(), err, models.FactorNotFoundError{}.Error())
+				session, _ := models.FindSessionById(ts.API.db, secondarySession.ID)
+				require.Equal(ts.T(), models.AAL1.String(), session.AAL)
+				require.Equal(ts.T(), &uuid.Nil, session.FactorID)
+
+			}
+		})
+	}
+
+}
+
+func (ts *MFATestSuite) TestUnenrollUnverifiedFactor() {
+	u, err := models.FindUserByEmailAndAudience(ts.API.db, "test@example.com", ts.Config.JWT.Aud)
+	require.NoError(ts.T(), err)
+	s, err := models.FindSessionByUserID(ts.API.db, u.ID)
+	require.NoError(ts.T(), err)
+	var secondarySession *models.Session
+	factors, err := models.FindFactorsByUser(ts.API.db, u)
+	require.NoError(ts.T(), err, "error finding factors")
+	f := factors[0]
+	secondarySession, err = models.NewSession(u, &f.ID)
+	require.NoError(ts.T(), err, "Error creating test session")
+	require.NoError(ts.T(), ts.API.db.Create(secondarySession), "Error saving test session")
+
+	sharedSecret := ts.TestOTPKey.Secret()
+	f.Secret = sharedSecret
+
+	var buffer bytes.Buffer
+
+	token, err := MFA_generateAccessToken(ts.API.db, u, &s.ID, time.Second*time.Duration(ts.Config.JWT.Exp), ts.Config.JWT.Secret)
+	require.NoError(ts.T(), err)
+	require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]interface{}{
+		"factor_id": f.ID,
+	}))
+
+	w := httptest.NewRecorder()
+	req := httptest.NewRequest(http.MethodDelete, fmt.Sprintf("/factors/%s", f.ID), &buffer)
+	req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token))
+	ts.API.handler.ServeHTTP(w, req)
+	require.Equal(ts.T(), http.StatusOK, w.Code)
+	_, err = models.FindFactorByFactorID(ts.API.db, f.ID)
+	require.EqualError(ts.T(), err, models.FactorNotFoundError{}.Error())
+	session, _ := models.FindSessionById(ts.API.db, secondarySession.ID)
+	require.Equal(ts.T(), models.AAL1.String(), session.AAL)
+	require.Equal(ts.T(), &uuid.Nil, session.FactorID)
+
+}
+
+// Integration Tests
+func (ts *MFATestSuite) TestSessionsMaintainAALOnRefresh() {
+	email := "test1@example.com"
+	password := "test123"
+	token := signUpAndVerify(ts, email, password)
+	ts.Config.Security.RefreshTokenRotationEnabled = true
+	var buffer bytes.Buffer
+	require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]interface{}{
+		"refresh_token": token.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)
+	require.Equal(ts.T(), http.StatusOK, w.Code)
+
+	data := &AccessTokenResponse{}
+	require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&data))
+
+	ctx, err := ts.API.parseJWTClaims(data.Token, req, w)
+	require.NoError(ts.T(), err)
+	ctx, err = ts.API.maybeLoadUserOrSession(ctx)
+	require.NoError(ts.T(), err)
+	require.Equal(ts.T(), models.AAL2.String(), getSession(ctx).AAL)
+}
+
+// Performing MFA Verification followed by a sign in should return an AAL1 session and an AAL2 session
+func (ts *MFATestSuite) TestMFAFollowedByPasswordSignIn() {
+	email := "test1@example.com"
+	password := "test123"
+	token := signUpAndVerify(ts, email, password)
+	ts.Config.Security.RefreshTokenRotationEnabled = true
+	var buffer bytes.Buffer
+	require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]interface{}{
+		"email":    email,
+		"password": password,
+	}))
+	req := httptest.NewRequest(http.MethodPost, "http://localhost/token?grant_type=password", &buffer)
+	req.Header.Set("Content-Type", "application/json")
+	w := httptest.NewRecorder()
+	ts.API.handler.ServeHTTP(w, req)
+	require.Equal(ts.T(), http.StatusOK, w.Code)
+
+	data := &AccessTokenResponse{}
+	require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&data))
+	ctx, err := ts.API.parseJWTClaims(data.Token, req, w)
+	require.NoError(ts.T(), err)
+	ctx, err = ts.API.maybeLoadUserOrSession(ctx)
+	require.NoError(ts.T(), err)
+	require.Equal(ts.T(), models.AAL1.String(), getSession(ctx).AAL)
+	session, err := models.FindSessionByUserID(ts.API.db, token.User.ID)
+	require.NoError(ts.T(), err)
+	require.Equal(ts.T(), models.AAL2.String(), session.AAL)
+}
+
+func signUp(ts *MFATestSuite, email, password string) (signUpResp AccessTokenResponse) {
+	var buffer bytes.Buffer
+
+	require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]interface{}{
+		"email":    email,
+		"password": password,
+	}))
+
+	// Setup request
+	req := httptest.NewRequest(http.MethodPost, "http://localhost/signup", &buffer)
+	req.Header.Set("Content-Type", "application/json")
+	ts.API.config.Mailer.Autoconfirm = true
+	w := httptest.NewRecorder()
+	ts.API.handler.ServeHTTP(w, req)
+	require.Equal(ts.T(), http.StatusOK, w.Code)
+	data := AccessTokenResponse{}
+	require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&data))
+	return data
+}
+
+func signUpAndVerify(ts *MFATestSuite, email, password string) (verifyResp *AccessTokenResponse) {
+
+	signUpResp := signUp(ts, email, password)
+	verifyResp = enrollAndVerify(ts, signUpResp.User, signUpResp.Token)
+
+	return verifyResp
+
+}
+
+func enrollAndVerify(ts *MFATestSuite, user *models.User, token string) (verifyResp *AccessTokenResponse) {
+	var buffer bytes.Buffer
+	w := httptest.NewRecorder()
+	require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]string{"friendly_name": "john", "factor_type": models.TOTP, "issuer": ts.TestDomain}))
+
+	req := httptest.NewRequest(http.MethodPost, "http://localhost/factors/", &buffer)
+	req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token))
+	req.Header.Set("Content-Type", "application/json")
+
+	ts.API.handler.ServeHTTP(w, req)
+	require.Equal(ts.T(), http.StatusOK, w.Code)
+	enrollResp := EnrollFactorResponse{}
+	require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&enrollResp))
+	factorID := enrollResp.ID
+
+	// Challenge
+	var challengeBuffer bytes.Buffer
+	x := httptest.NewRecorder()
+	req = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost/factors/%s/challenge", factorID), &challengeBuffer)
+	req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token))
+	req.Header.Set("Content-Type", "application/json")
+	ts.API.handler.ServeHTTP(x, req)
+	require.Equal(ts.T(), http.StatusOK, x.Code)
+	challengeResp := EnrollFactorResponse{}
+	require.NoError(ts.T(), json.NewDecoder(x.Body).Decode(&challengeResp))
+	challengeID := challengeResp.ID
+
+	// Verify
+	var verifyBuffer bytes.Buffer
+	y := httptest.NewRecorder()
+
+	conn, err := pgx.Connect(context.Background(), ts.API.db.URL())
+	require.NoError(ts.T(), err)
+
+	defer conn.Close(context.Background())
+
+	var totpSecret string
+	err = conn.QueryRow(context.Background(), "select secret from mfa_factors where id=$1", factorID).Scan(&totpSecret)
+	require.NoError(ts.T(), err)
+
+	code, err := totp.GenerateCode(totpSecret, time.Now().UTC())
+	require.NoError(ts.T(), err)
+
+	require.NoError(ts.T(), json.NewEncoder(&verifyBuffer).Encode(map[string]interface{}{
+		"challenge_id": challengeID,
+		"code":         code,
+	}))
+	req = httptest.NewRequest(http.MethodPost, fmt.Sprintf("/factors/%s/verify", factorID), &verifyBuffer)
+	req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token))
+	req.Header.Set("Content-Type", "application/json")
+
+	ts.API.handler.ServeHTTP(y, req)
+	require.Equal(ts.T(), http.StatusOK, y.Code)
+	verifyResp = &AccessTokenResponse{}
+	require.NoError(ts.T(), json.NewDecoder(y.Body).Decode(&verifyResp))
+	return verifyResp
+}
diff --git a/api/opentelemetry-tracer_test.go b/api/opentelemetry-tracer_test.go
new file mode 100644
index 00000000..35a7309d
--- /dev/null
+++ b/api/opentelemetry-tracer_test.go
@@ -0,0 +1,91 @@
+package api
+
+import (
+	"net/http"
+	"net/http/httptest"
+	"testing"
+
+	"github.com/netlify/gotrue/conf"
+	"github.com/netlify/gotrue/storage"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+	"github.com/stretchr/testify/suite"
+	"go.opentelemetry.io/otel"
+	"go.opentelemetry.io/otel/attribute"
+	sdktrace "go.opentelemetry.io/otel/sdk/trace"
+	"go.opentelemetry.io/otel/sdk/trace/tracetest"
+	semconv "go.opentelemetry.io/otel/semconv/v1.10.0"
+)
+
+type OpenTelemetryTracerTestSuite struct {
+	suite.Suite
+	API    *API
+	Config *conf.GlobalConfiguration
+}
+
+func TestOpenTelemetryTracer(t *testing.T) {
+	api, config, err := setupAPIForTestWithCallback(func(config *conf.GlobalConfiguration, conn *storage.Connection) {
+		if config != nil {
+			config.Tracing.Enabled = true
+			config.Tracing.Exporter = conf.OpenTelemetryTracing
+		}
+	})
+
+	require.NoError(t, err)
+
+	ts := &OpenTelemetryTracerTestSuite{
+		API:    api,
+		Config: config,
+	}
+	defer api.db.Close()
+
+	suite.Run(t, ts)
+}
+
+func getAttribute(attributes []attribute.KeyValue, key attribute.Key) *attribute.Value {
+	for _, value := range attributes {
+		if value.Key == key {
+			return &value.Value
+		}
+	}
+
+	return nil
+}
+
+func (ts *OpenTelemetryTracerTestSuite) TestOpenTelemetryTracer_Spans() {
+	exporter := tracetest.NewInMemoryExporter()
+	bsp := sdktrace.NewSimpleSpanProcessor(exporter)
+	traceProvider := sdktrace.NewTracerProvider(
+		sdktrace.WithSampler(sdktrace.AlwaysSample()),
+		sdktrace.WithSpanProcessor(bsp),
+	)
+	otel.SetTracerProvider(traceProvider)
+
+	w := httptest.NewRecorder()
+	req := httptest.NewRequest(http.MethodPost, "http://localhost/something1", nil)
+	ts.API.handler.ServeHTTP(w, req)
+
+	req = httptest.NewRequest(http.MethodGet, "http://localhost/something2", nil)
+	ts.API.handler.ServeHTTP(w, req)
+
+	spanStubs := exporter.GetSpans()
+	spans := spanStubs.Snapshots()
+
+	if assert.Equal(ts.T(), 2, len(spans)) {
+		attributes1 := spans[0].Attributes()
+		method1 := getAttribute(attributes1, semconv.HTTPMethodKey)
+		assert.Equal(ts.T(), "POST", method1.AsString())
+		url1 := getAttribute(attributes1, semconv.HTTPTargetKey)
+		assert.Equal(ts.T(), "http://localhost/something1", url1.AsString())
+		statusCode1 := getAttribute(attributes1, semconv.HTTPStatusCodeKey)
+		assert.Equal(ts.T(), int64(404), statusCode1.AsInt64())
+
+		attributes2 := spans[1].Attributes()
+		method2 := getAttribute(attributes2, semconv.HTTPMethodKey)
+		assert.Equal(ts.T(), "GET", method2.AsString())
+		url2 := getAttribute(attributes2, semconv.HTTPTargetKey)
+		assert.Equal(ts.T(), "http://localhost/something2", url2.AsString())
+		statusCode2 := getAttribute(attributes2, semconv.HTTPStatusCodeKey)
+		assert.Equal(ts.T(), int64(404), statusCode2.AsInt64())
+	}
+}
diff --git a/api/tracer_test.go b/api/opentracer_test.go
similarity index 78%
rename from api/tracer_test.go
rename to api/opentracer_test.go
index 5bac0dd8..016b20c4 100644
--- a/api/tracer_test.go
+++ b/api/opentracer_test.go
@@ -6,6 +6,7 @@ import (
 	"testing"
 
 	"github.com/netlify/gotrue/conf"
+	"github.com/netlify/gotrue/storage"
 	"github.com/opentracing/opentracing-go"
 	"github.com/opentracing/opentracing-go/mocktracer"
 	"github.com/stretchr/testify/assert"
@@ -13,17 +14,23 @@ import (
 	"github.com/stretchr/testify/suite"
 )
 
-type TracerTestSuite struct {
+type OpenTracerTestSuite struct {
 	suite.Suite
 	API    *API
 	Config *conf.GlobalConfiguration
 }
 
-func TestTracer(t *testing.T) {
-	api, config, err := setupAPIForTest()
+func TestOpenTracer(t *testing.T) {
+	api, config, err := setupAPIForTestWithCallback(func(config *conf.GlobalConfiguration, conn *storage.Connection) {
+		if config != nil {
+			config.Tracing.Enabled = true
+			config.Tracing.Exporter = conf.OpenTracing
+		}
+	})
+
 	require.NoError(t, err)
 
-	ts := &TracerTestSuite{
+	ts := &OpenTracerTestSuite{
 		API:    api,
 		Config: config,
 	}
@@ -32,7 +39,7 @@ func TestTracer(t *testing.T) {
 	suite.Run(t, ts)
 }
 
-func (ts *TracerTestSuite) TestTracer_Spans() {
+func (ts *OpenTracerTestSuite) TestOpenTracer_Spans() {
 	mt := mocktracer.New()
 	opentracing.SetGlobalTracer(mt)
 
diff --git a/api/otp_test.go b/api/otp_test.go
index 8154459c..0ac8b6d9 100644
--- a/api/otp_test.go
+++ b/api/otp_test.go
@@ -47,15 +47,15 @@ func (ts *OtpTestSuite) TestOtp() {
 		}
 	}{
 		{
-			"Test Success Magiclink Otp",
-			OtpParams{
+			desc: "Test Success Magiclink Otp",
+			params: OtpParams{
 				Email:      "test@example.com",
 				CreateUser: true,
 				Data: map[string]interface{}{
 					"somedata": "metadata",
 				},
 			},
-			struct {
+			expected: struct {
 				code     int
 				response map[string]interface{}
 			}{
@@ -64,13 +64,13 @@ func (ts *OtpTestSuite) TestOtp() {
 			},
 		},
 		{
-			"Test Failure Pass Both Email & Phone",
-			OtpParams{
+			desc: "Test Failure Pass Both Email & Phone",
+			params: OtpParams{
 				Email:      "test@example.com",
 				Phone:      "123456789",
 				CreateUser: true,
 			},
-			struct {
+			expected: struct {
 				code     int
 				response map[string]interface{}
 			}{
diff --git a/api/phone_test.go b/api/phone_test.go
index 31434f97..2a30d1ba 100644
--- a/api/phone_test.go
+++ b/api/phone_test.go
@@ -75,24 +75,24 @@ func (ts *PhoneTestSuite) TestSendPhoneConfirmation() {
 		expected error
 	}{
 		{
-			"send confirmation otp",
-			phoneConfirmationOtp,
-			nil,
+			desc:     "send confirmation otp",
+			otpType:  phoneConfirmationOtp,
+			expected: nil,
 		},
 		{
-			"send phone_change otp",
-			phoneChangeVerification,
-			nil,
+			desc:     "send phone_change otp",
+			otpType:  phoneChangeVerification,
+			expected: nil,
 		},
 		{
-			"send recovery otp",
-			phoneReauthenticationOtp,
-			nil,
+			desc:     "send recovery otp",
+			otpType:  phoneReauthenticationOtp,
+			expected: nil,
 		},
 		{
-			"send invalid otp type ",
-			"invalid otp type",
-			internalServerError("invalid otp type"),
+			desc:     "send invalid otp type ",
+			otpType:  "invalid otp type",
+			expected: internalServerError("invalid otp type"),
 		},
 	}
 
@@ -126,7 +126,13 @@ func (ts *PhoneTestSuite) TestMissingSmsProviderConfig() {
 	u.PhoneConfirmedAt = &now
 	require.NoError(ts.T(), ts.API.db.Update(u), "Error updating new test user")
 
-	token, err := generateAccessToken(u, nil, time.Second*time.Duration(ts.Config.JWT.Exp), ts.Config.JWT.Secret)
+	var token string
+	if ts.Config.MFA.Enabled {
+		token, err = MFA_generateAccessToken(ts.API.db, u, nil, time.Second*time.Duration(ts.Config.JWT.Exp), ts.Config.JWT.Secret)
+	} else {
+		token, err = generateAccessToken(u, nil, time.Second*time.Duration(ts.Config.JWT.Exp), ts.Config.JWT.Secret)
+
+	}
 	require.NoError(ts.T(), err)
 
 	cases := []struct {
@@ -138,52 +144,52 @@ func (ts *PhoneTestSuite) TestMissingSmsProviderConfig() {
 		expected map[string]interface{}
 	}{
 		{
-			"Signup",
-			"/signup",
-			http.MethodPost,
-			"",
-			map[string]string{
+			desc:     "Signup",
+			endpoint: "/signup",
+			method:   http.MethodPost,
+			header:   "",
+			body: map[string]string{
 				"phone":    "1234567890",
 				"password": "testpassword",
 			},
-			map[string]interface{}{
+			expected: map[string]interface{}{
 				"code":    http.StatusBadRequest,
 				"message": "Error sending confirmation sms:",
 			},
 		},
 		{
-			"Sms OTP",
-			"/otp",
-			http.MethodPost,
-			"",
-			map[string]string{
+			desc:     "Sms OTP",
+			endpoint: "/otp",
+			method:   http.MethodPost,
+			header:   "",
+			body: map[string]string{
 				"phone": "123456789",
 			},
-			map[string]interface{}{
+			expected: map[string]interface{}{
 				"code":    http.StatusBadRequest,
 				"message": "Error sending sms:",
 			},
 		},
 		{
-			"Phone change",
-			"/user",
-			http.MethodPut,
-			token,
-			map[string]string{
+			desc:     "Phone change",
+			endpoint: "/user",
+			method:   http.MethodPut,
+			header:   token,
+			body: map[string]string{
 				"phone": "111111111",
 			},
-			map[string]interface{}{
+			expected: map[string]interface{}{
 				"code":    http.StatusBadRequest,
 				"message": "Error sending sms:",
 			},
 		},
 		{
-			"Reauthenticate",
-			"/reauthenticate",
-			http.MethodGet,
-			"",
-			nil,
-			map[string]interface{}{
+			desc:     "Reauthenticate",
+			endpoint: "/reauthenticate",
+			method:   http.MethodGet,
+			header:   "",
+			body:     nil,
+			expected: map[string]interface{}{
 				"code":    http.StatusBadRequest,
 				"message": "Error sending sms:",
 			},
diff --git a/api/settings_test.go b/api/settings_test.go
index 58398266..781fce46 100644
--- a/api/settings_test.go
+++ b/api/settings_test.go
@@ -24,6 +24,9 @@ func TestSettings_DefaultProviders(t *testing.T) {
 	resp := Settings{}
 	require.NoError(t, json.NewDecoder(w.Body).Decode(&resp))
 
+	// TODO(Joel): Reinstate once feature is stable
+	// require.False(t, resp.MFAEnabled)
+
 	p := resp.ExternalProviders
 
 	require.False(t, p.Phone)
diff --git a/api/signup_test.go b/api/signup_test.go
index 7dacebe0..03d9fb75 100644
--- a/api/signup_test.go
+++ b/api/signup_test.go
@@ -110,7 +110,6 @@ func (ts *SignupTestSuite) TestWebhookTriggered() {
 
 		u, ok := data["user"].(map[string]interface{})
 		require.True(ok)
-		assert.Len(u, 10)
 		assert.Equal("authenticated", u["aud"])
 		assert.Equal("authenticated", u["role"])
 		assert.Equal("test@example.com", u["email"])
diff --git a/api/token_test.go b/api/token_test.go
index c8d36377..0fefca40 100644
--- a/api/token_test.go
+++ b/api/token_test.go
@@ -153,6 +153,7 @@ func (ts *TokenTestSuite) TestTokenRefreshTokenRotation() {
 	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, models.GrantParams{})
 	require.NoError(ts.T(), err)
 	second, err := models.GrantRefreshTokenSwap(&http.Request{}, ts.API.db, u, first)
@@ -169,44 +170,44 @@ func (ts *TokenTestSuite) TestTokenRefreshTokenRotation() {
 		expectedBody                map[string]interface{}
 	}{
 		{
-			"Valid refresh within reuse interval",
-			true,
-			30,
-			second.Token,
-			http.StatusOK,
-			map[string]interface{}{
+			desc:                        "Valid refresh within reuse interval",
+			refreshTokenRotationEnabled: true,
+			reuseInterval:               30,
+			refreshToken:                second.Token,
+			expectedCode:                http.StatusOK,
+			expectedBody: 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{}{
+			desc:                        "Invalid refresh, first token is not the previous revoked token",
+			refreshTokenRotationEnabled: true,
+			reuseInterval:               0,
+			refreshToken:                first.Token,
+			expectedCode:                http.StatusBadRequest,
+			expectedBody: 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{}{
+			desc:                        "Invalid refresh, revoked third token",
+			refreshTokenRotationEnabled: true,
+			reuseInterval:               0,
+			refreshToken:                second.Token,
+			expectedCode:                http.StatusBadRequest,
+			expectedBody: 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{}{
+			desc:                        "Invalid refresh, third token revoked by previous case",
+			refreshTokenRotationEnabled: true,
+			reuseInterval:               30,
+			refreshToken:                third.Token,
+			expectedCode:                http.StatusBadRequest,
+			expectedBody: map[string]interface{}{
 				"error":             "invalid_grant",
 				"error_description": "Invalid Refresh Token",
 			},
diff --git a/api/user_test.go b/api/user_test.go
index 8a0d5fc9..e08fc8c9 100644
--- a/api/user_test.go
+++ b/api/user_test.go
@@ -47,7 +47,12 @@ func (ts *UserTestSuite) SetupTest() {
 func (ts *UserTestSuite) TestUserGet() {
 	u, err := models.FindUserByEmailAndAudience(ts.API.db, "test@example.com", ts.Config.JWT.Aud)
 	require.NoError(ts.T(), err, "Error finding user")
-	token, err := generateAccessToken(u, nil, time.Second*time.Duration(ts.Config.JWT.Exp), ts.Config.JWT.Secret)
+	var token string
+	if ts.API.config.MFA.Enabled {
+		token, err = MFA_generateAccessToken(ts.API.db, u, nil, time.Second*time.Duration(ts.Config.JWT.Exp), ts.Config.JWT.Secret)
+	} else {
+		token, err = generateAccessToken(u, nil, time.Second*time.Duration(ts.Config.JWT.Exp), ts.Config.JWT.Secret)
+	}
 	require.NoError(ts.T(), err, "Error generating access token")
 
 	req := httptest.NewRequest(http.MethodGet, "http://localhost/user", nil)
@@ -66,40 +71,40 @@ func (ts *UserTestSuite) TestUserUpdateEmail() {
 		expectedCode               int
 	}{
 		{
-			"User doesn't have an existing email",
-			map[string]string{
+			desc: "User doesn't have an existing email",
+			userData: map[string]string{
 				"email": "",
 				"phone": "",
 			},
-			false,
-			http.StatusOK,
+			isSecureEmailChangeEnabled: false,
+			expectedCode:               http.StatusOK,
 		},
 		{
-			"User doesn't have an existing email and double email confirmation required",
-			map[string]string{
+			desc: "User doesn't have an existing email and double email confirmation required",
+			userData: map[string]string{
 				"email": "",
 				"phone": "234567890",
 			},
-			true,
-			http.StatusOK,
+			isSecureEmailChangeEnabled: true,
+			expectedCode:               http.StatusOK,
 		},
 		{
-			"User has an existing email",
-			map[string]string{
+			desc: "User has an existing email",
+			userData: map[string]string{
 				"email": "foo@example.com",
 				"phone": "",
 			},
-			false,
-			http.StatusOK,
+			isSecureEmailChangeEnabled: false,
+			expectedCode:               http.StatusOK,
 		},
 		{
-			"User has an existing email and double email confirmation required",
-			map[string]string{
+			desc: "User has an existing email and double email confirmation required",
+			userData: map[string]string{
 				"email": "bar@example.com",
 				"phone": "",
 			},
-			true,
-			http.StatusOK,
+			isSecureEmailChangeEnabled: true,
+			expectedCode:               http.StatusOK,
 		},
 	}
 
@@ -111,7 +116,13 @@ func (ts *UserTestSuite) TestUserUpdateEmail() {
 			require.NoError(ts.T(), u.SetPhone(ts.API.db, c.userData["phone"]), "Error setting user phone")
 			require.NoError(ts.T(), ts.API.db.Create(u), "Error saving test user")
 
-			token, err := generateAccessToken(u, nil, time.Second*time.Duration(ts.Config.JWT.Exp), ts.Config.JWT.Secret)
+			var token string
+			if ts.API.config.MFA.Enabled {
+				token, err = MFA_generateAccessToken(ts.API.db, u, nil, time.Second*time.Duration(ts.Config.JWT.Exp), ts.Config.JWT.Secret)
+			} else {
+				token, err = generateAccessToken(u, nil, time.Second*time.Duration(ts.Config.JWT.Exp), ts.Config.JWT.Secret)
+
+			}
 			require.NoError(ts.T(), err, "Error generating access token")
 
 			var buffer bytes.Buffer
@@ -144,25 +155,25 @@ func (ts *UserTestSuite) TestUserUpdatePhoneAutoconfirmEnabled() {
 		expectedCode int
 	}{
 		{
-			"New phone number is the same as current phone number",
-			map[string]string{
+			desc: "New phone number is the same as current phone number",
+			userData: map[string]string{
 				"phone": "123456789",
 			},
-			http.StatusOK,
+			expectedCode: http.StatusOK,
 		},
 		{
-			"New phone number exists already",
-			map[string]string{
+			desc: "New phone number exists already",
+			userData: map[string]string{
 				"phone": "22222222",
 			},
-			http.StatusUnprocessableEntity,
+			expectedCode: http.StatusUnprocessableEntity,
 		},
 		{
-			"New phone number is different from current phone number",
-			map[string]string{
+			desc: "New phone number is different from current phone number",
+			userData: map[string]string{
 				"phone": "234567890",
 			},
-			http.StatusOK,
+			expectedCode: http.StatusOK,
 		},
 	}
 
@@ -170,7 +181,13 @@ func (ts *UserTestSuite) TestUserUpdatePhoneAutoconfirmEnabled() {
 
 	for _, c := range cases {
 		ts.Run(c.desc, func() {
-			token, err := generateAccessToken(u, nil, time.Second*time.Duration(ts.Config.JWT.Exp), ts.Config.JWT.Secret)
+			var token string
+			if ts.API.config.MFA.Enabled {
+				token, err = MFA_generateAccessToken(ts.API.db, u, nil, time.Second*time.Duration(ts.Config.JWT.Exp), ts.Config.JWT.Secret)
+			} else {
+				token, err = generateAccessToken(u, nil, time.Second*time.Duration(ts.Config.JWT.Exp), ts.Config.JWT.Secret)
+
+			}
 			require.NoError(ts.T(), err, "Error generating access token")
 
 			var buffer bytes.Buffer
@@ -206,32 +223,32 @@ func (ts *UserTestSuite) TestUserUpdatePassword() {
 		expected                expected
 	}{
 		{
-			"Valid password length",
-			"newpassword",
-			"",
-			false,
-			expected{code: http.StatusOK, isAuthenticated: true},
+			desc:                    "Valid password length",
+			newPassword:             "newpassword",
+			nonce:                   "",
+			requireReauthentication: false,
+			expected:                expected{code: http.StatusOK, isAuthenticated: true},
 		},
 		{
-			"Invalid password length",
-			"",
-			"",
-			false,
-			expected{code: http.StatusUnprocessableEntity, isAuthenticated: false},
+			desc:                    "Invalid password length",
+			newPassword:             "",
+			nonce:                   "",
+			requireReauthentication: false,
+			expected:                expected{code: http.StatusUnprocessableEntity, isAuthenticated: false},
 		},
 		{
-			"No reauthentication provided",
-			"newpassword123",
-			"",
-			true,
-			expected{code: http.StatusUnauthorized, isAuthenticated: false},
+			desc:                    "No reauthentication provided",
+			newPassword:             "newpassword123",
+			nonce:                   "",
+			requireReauthentication: true,
+			expected:                expected{code: http.StatusUnauthorized, isAuthenticated: false},
 		},
 		{
-			"Invalid nonce",
-			"newpassword123",
-			"123456",
-			true,
-			expected{code: http.StatusBadRequest, isAuthenticated: false},
+			desc:                    "Invalid nonce",
+			newPassword:             "newpassword123",
+			nonce:                   "123456",
+			requireReauthentication: true,
+			expected:                expected{code: http.StatusBadRequest, isAuthenticated: false},
 		},
 	}
 
@@ -244,7 +261,13 @@ func (ts *UserTestSuite) TestUserUpdatePassword() {
 			req := httptest.NewRequest(http.MethodPut, "http://localhost/user", &buffer)
 			req.Header.Set("Content-Type", "application/json")
 
-			token, err := generateAccessToken(u, nil, time.Second*time.Duration(ts.Config.JWT.Exp), ts.Config.JWT.Secret)
+			var token string
+			if ts.API.config.MFA.Enabled {
+				token, err = MFA_generateAccessToken(ts.API.db, u, nil, time.Second*time.Duration(ts.Config.JWT.Exp), ts.Config.JWT.Secret)
+			} else {
+				token, err = generateAccessToken(u, nil, time.Second*time.Duration(ts.Config.JWT.Exp), ts.Config.JWT.Secret)
+
+			}
 			require.NoError(ts.T(), err)
 			req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token))
 
@@ -272,7 +295,13 @@ func (ts *UserTestSuite) TestUserUpdatePasswordReauthentication() {
 	u.EmailConfirmedAt = &now
 	require.NoError(ts.T(), ts.API.db.Update(u), "Error updating new test user")
 
-	token, err := generateAccessToken(u, nil, time.Second*time.Duration(ts.Config.JWT.Exp), ts.Config.JWT.Secret)
+	var token string
+	if ts.API.config.MFA.Enabled {
+		token, err = MFA_generateAccessToken(ts.API.db, u, nil, time.Second*time.Duration(ts.Config.JWT.Exp), ts.Config.JWT.Secret)
+	} else {
+		token, err = generateAccessToken(u, nil, time.Second*time.Duration(ts.Config.JWT.Exp), ts.Config.JWT.Secret)
+
+	}
 	require.NoError(ts.T(), err)
 
 	// request for reauthentication nonce
diff --git a/api/verify_test.go b/api/verify_test.go
index 8229cbfb..346e8ce2 100644
--- a/api/verify_test.go
+++ b/api/verify_test.go
@@ -103,7 +103,13 @@ func (ts *VerifyTestSuite) TestVerifySecureEmailChange() {
 	req.Header.Set("Content-Type", "application/json")
 
 	// Generate access token for request
-	token, err := generateAccessToken(u, nil, time.Second*time.Duration(ts.Config.JWT.Exp), ts.Config.JWT.Secret)
+	var token string
+	if ts.API.config.MFA.Enabled {
+		token, err = MFA_generateAccessToken(ts.API.db, u, nil, time.Second*time.Duration(ts.Config.JWT.Exp), ts.Config.JWT.Secret)
+	} else {
+		token, err = generateAccessToken(u, nil, time.Second*time.Duration(ts.Config.JWT.Exp), ts.Config.JWT.Secret)
+
+	}
 	require.NoError(ts.T(), err)
 	req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token))
 
@@ -450,12 +456,15 @@ func (ts *VerifyTestSuite) TestVerifySignupWithredirectURLContainedPath() {
 			requestredirectURL:  "twitter://timeline",
 			expectedredirectURL: "twitter://timeline",
 		},
+		// previously the below example was not allowed and with good
+		// reason, however users do want flexibility in the redirect
+		// URL after the scheme, which is why the example is now corrected
 		{
 			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/#/",
+			uriAllowList:        []string{"com.example.app://**"},
+			requestredirectURL:  "com.example.app://sign-in/v2",
+			expectedredirectURL: "com.example.app://sign-in/v2",
 		},
 		{
 			desc:                "redirect respects . separator",
@@ -540,36 +549,36 @@ func (ts *VerifyTestSuite) TestVerifyBannedUser() {
 		payload *VerifyParams
 	}{
 		{
-			"Verify banned user on signup",
-			&VerifyParams{
+			desc: "Verify banned user on signup",
+			payload: &VerifyParams{
 				Type:  "signup",
 				Token: u.ConfirmationToken,
 			},
 		},
 		{
-			"Verify banned user on invite",
-			&VerifyParams{
+			desc: "Verify banned user on invite",
+			payload: &VerifyParams{
 				Type:  "invite",
 				Token: u.ConfirmationToken,
 			},
 		},
 		{
-			"Verify banned user on recover",
-			&VerifyParams{
+			desc: "Verify banned user on recover",
+			payload: &VerifyParams{
 				Type:  "recovery",
 				Token: u.RecoveryToken,
 			},
 		},
 		{
-			"Verify banned user on magiclink",
-			&VerifyParams{
+			desc: "Verify banned user on magiclink",
+			payload: &VerifyParams{
 				Type:  "magiclink",
 				Token: u.RecoveryToken,
 			},
 		},
 		{
-			"Verify banned user on email change",
-			&VerifyParams{
+			desc: "Verify banned user on email change",
+			payload: &VerifyParams{
 				Type:  "email_change",
 				Token: u.EmailChangeTokenCurrent,
 			},
diff --git a/conf/configuration_test.go b/conf/configuration_test.go
index 0e7aa1b1..c9a3947c 100644
--- a/conf/configuration_test.go
+++ b/conf/configuration_test.go
@@ -4,7 +4,6 @@ import (
 	"os"
 	"testing"
 
-	"github.com/opentracing/opentracing-go"
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
 )
@@ -26,30 +25,3 @@ func TestGlobal(t *testing.T) {
 	require.NotNil(t, gc)
 	assert.Equal(t, "X-Request-ID", gc.API.RequestIDHeader)
 }
-
-func TestTracing(t *testing.T) {
-	os.Setenv("GOTRUE_SITE_URL", "http://localhost:8080")
-	os.Setenv("GOTRUE_DB_DRIVER", "mysql")
-	os.Setenv("GOTRUE_DB_DATABASE_URL", "fake")
-	os.Setenv("GOTRUE_OPERATOR_TOKEN", "token")
-	os.Setenv("GOTRUE_API_REQUEST_ID_HEADER", "X-Request-ID")
-	os.Setenv("GOTRUE_JWT_SECRET", "secret")
-	os.Setenv("GOTRUE_DB_DRIVER", "mysql")
-	os.Setenv("GOTRUE_DB_DATABASE_URL", "fake")
-	os.Setenv("GOTRUE_OPERATOR_TOKEN", "token")
-	os.Setenv("GOTRUE_TRACING_SERVICE_NAME", "identity")
-	os.Setenv("GOTRUE_TRACING_PORT", "8126")
-	os.Setenv("GOTRUE_TRACING_HOST", "127.0.0.1")
-	os.Setenv("GOTRUE_TRACING_TAGS", "tag1:value1,tag2:value2")
-
-	gc, err := LoadGlobal("")
-	require.NoError(t, err)
-	tc := opentracing.GlobalTracer()
-
-	assert.Equal(t, opentracing.NoopTracer{}, tc)
-	assert.Equal(t, false, gc.Tracing.Enabled)
-	assert.Equal(t, "identity", gc.Tracing.ServiceName)
-	assert.Equal(t, "8126", gc.Tracing.Port)
-	assert.Equal(t, "127.0.0.1", gc.Tracing.Host)
-	assert.Equal(t, map[string]string{"tag1": "value1", "tag2": "value2"}, gc.Tracing.Tags)
-}
diff --git a/hack/test.env b/hack/test.env
index f076a5a8..28ba56ef 100644
--- a/hack/test.env
+++ b/hack/test.env
@@ -89,7 +89,8 @@ GOTRUE_EXTERNAL_SAML_API_BASE=http://localhost
 GOTRUE_EXTERNAL_SAML_NAME=TestSamlName
 GOTRUE_RATE_LIMIT_VERIFY="100000"
 GOTRUE_RATE_LIMIT_TOKEN_REFRESH="30"
-GOTRUE_TRACING_ENABLED=false
+GOTRUE_TRACING_ENABLED=true
+GOTRUE_TRACING_EXPORTER=default
 GOTRUE_TRACING_HOST=127.0.0.1
 GOTRUE_TRACING_PORT=8126
 GOTRUE_TRACING_TAGS="env:test"
@@ -97,3 +98,4 @@ GOTRUE_SECURITY_CAPTCHA_ENABLED="false"
 GOTRUE_SECURITY_CAPTCHA_PROVIDER="hcaptcha"
 GOTRUE_SECURITY_CAPTCHA_SECRET="0x0000000000000000000000000000000000000000"
 GOTRUE_SECURITY_CAPTCHA_TIMEOUT="10s"
+GOTRUE_MFA_ENABLED="true"
diff --git a/models/factor_test.go b/models/factor_test.go
new file mode 100644
index 00000000..8c6eabfb
--- /dev/null
+++ b/models/factor_test.go
@@ -0,0 +1,95 @@
+package models
+
+import (
+	"encoding/json"
+	"testing"
+
+	"github.com/gofrs/uuid"
+	"github.com/netlify/gotrue/conf"
+	"github.com/netlify/gotrue/storage"
+	"github.com/netlify/gotrue/storage/test"
+	"github.com/stretchr/testify/require"
+	"github.com/stretchr/testify/suite"
+)
+
+type FactorTestSuite struct {
+	suite.Suite
+	db *storage.Connection
+}
+
+func TestFactor(t *testing.T) {
+	globalConfig, err := conf.LoadGlobal(modelsTestConfig)
+	require.NoError(t, err)
+	conn, err := test.SetupDBConnection(globalConfig)
+	require.NoError(t, err)
+	ts := &FactorTestSuite{
+		db: conn,
+	}
+	defer ts.db.Close()
+	if globalConfig.MFA.Enabled {
+		suite.Run(t, ts)
+	}
+}
+
+func (ts *FactorTestSuite) SetupTest() {
+	TruncateAll(ts.db)
+}
+
+func (ts *FactorTestSuite) TestFindFactorByFactorID() {
+	f := ts.createFactor()
+	n, err := FindFactorByFactorID(ts.db, f.ID)
+	require.NoError(ts.T(), err)
+	require.Equal(ts.T(), f.ID, n.ID)
+	_, err = FindFactorByFactorID(ts.db, uuid.Nil)
+	require.EqualError(ts.T(), err, FactorNotFoundError{}.Error())
+}
+
+func (ts *FactorTestSuite) createFactor() *Factor {
+	user, err := NewUser("", "agenericemail@gmail.com", "secret", "test", nil)
+	require.NoError(ts.T(), err)
+
+	err = ts.db.Create(user)
+	require.NoError(ts.T(), err)
+
+	factor, err := NewFactor(user, "asimplename", TOTP, FactorStateUnverified, "topsecret")
+	require.NoError(ts.T(), err)
+
+	err = ts.db.Create(factor)
+	require.NoError(ts.T(), err)
+
+	return factor
+}
+func (ts *FactorTestSuite) TestUpdateStatus() {
+	newFactorStatus := FactorStateVerified
+	u, err := NewUser("", "", "", "", nil)
+	require.NoError(ts.T(), err)
+
+	f, err := NewFactor(u, "", TOTP, FactorStateUnverified, "some-secret")
+	require.NoError(ts.T(), err)
+	require.NoError(ts.T(), f.UpdateStatus(ts.db, newFactorStatus))
+	require.Equal(ts.T(), newFactorStatus, f.Status)
+}
+
+func (ts *FactorTestSuite) TestUpdateFriendlyName() {
+	newSimpleName := "newFactorName"
+	u, err := NewUser("", "", "", "", nil)
+	require.NoError(ts.T(), err)
+
+	f, err := NewFactor(u, "A1B2C3", TOTP, FactorStateUnverified, "some-secret")
+	require.NoError(ts.T(), err)
+	require.NoError(ts.T(), f.UpdateFriendlyName(ts.db, newSimpleName))
+	require.Equal(ts.T(), newSimpleName, f.FriendlyName)
+}
+
+func (ts *FactorTestSuite) TestEncodedFactorDoesNotLeakSecret() {
+	u, err := NewUser("", "", "", "", nil)
+	require.NoError(ts.T(), err)
+
+	f, err := NewFactor(u, "A1B2C3", TOTP, FactorStateUnverified, "some-secret")
+	require.NoError(ts.T(), err)
+	encodedFactor, err := json.Marshal(f)
+	require.NoError(ts.T(), err)
+	decodedFactor := Factor{}
+	json.Unmarshal(encodedFactor, &decodedFactor)
+	require.Equal(ts.T(), decodedFactor.Secret, "")
+}
diff --git a/models/sessions_test.go b/models/sessions_test.go
new file mode 100644
index 00000000..2da9f825
--- /dev/null
+++ b/models/sessions_test.go
@@ -0,0 +1,85 @@
+package models
+
+import (
+	"github.com/gofrs/uuid"
+	"github.com/netlify/gotrue/conf"
+	"github.com/netlify/gotrue/storage"
+	"github.com/netlify/gotrue/storage/test"
+	"github.com/stretchr/testify/require"
+	"github.com/stretchr/testify/suite"
+	"testing"
+	"time"
+)
+
+type SessionsTestSuite struct {
+	suite.Suite
+	db     *storage.Connection
+	Config *conf.GlobalConfiguration
+}
+
+func (ts *SessionsTestSuite) SetupTest() {
+	TruncateAll(ts.db)
+	email := "test@example.com"
+	user, err := NewUser("", email, "secret", ts.Config.JWT.Aud, nil)
+	require.NoError(ts.T(), err)
+
+	err = ts.db.Create(user)
+	require.NoError(ts.T(), err)
+}
+
+func TestSession(t *testing.T) {
+	globalConfig, err := conf.LoadGlobal(modelsTestConfig)
+	require.NoError(t, err)
+	conn, err := test.SetupDBConnection(globalConfig)
+	require.NoError(t, err)
+	ts := &SessionsTestSuite{
+		db:     conn,
+		Config: globalConfig,
+	}
+	defer ts.db.Close()
+	if globalConfig.MFA.Enabled {
+		suite.Run(t, ts)
+	}
+}
+
+func (ts *SessionsTestSuite) TestCalculateAALAndAMR() {
+	totalDistinctClaims := 2
+	u, err := FindUserByEmailAndAudience(ts.db, "test@example.com", ts.Config.JWT.Aud)
+	require.NoError(ts.T(), err)
+	// TODO(Joel): Replace with CreateSession once MFA feature flag is lifted
+	session, err := MFA_CreateSession(ts.db, u, &uuid.Nil)
+	require.NoError(ts.T(), err)
+
+	err = AddClaimToSession(ts.db, session, PasswordGrant)
+	require.NoError(ts.T(), err)
+
+	firstClaimAddedTime := time.Now()
+	err = AddClaimToSession(ts.db, session, TOTPSignIn)
+	require.NoError(ts.T(), err)
+	session, err = FindSessionById(ts.db, session.ID)
+	require.NoError(ts.T(), err)
+
+	aal, amr := session.CalculateAALAndAMR()
+	require.Equal(ts.T(), AAL2.String(), aal)
+	require.Equal(ts.T(), totalDistinctClaims, len(amr))
+
+	err = AddClaimToSession(ts.db, session, TOTPSignIn)
+	require.NoError(ts.T(), err)
+
+	session, err = FindSessionById(ts.db, session.ID)
+	require.NoError(ts.T(), err)
+
+	aal, amr = session.CalculateAALAndAMR()
+
+	require.Equal(ts.T(), AAL2.String(), aal)
+	require.Equal(ts.T(), totalDistinctClaims, len(amr))
+	found := false
+	for _, claim := range session.AMRClaims {
+		if claim.AuthenticationMethod == TOTPSignIn.String() {
+			require.True(ts.T(), firstClaimAddedTime.Before(claim.UpdatedAt))
+			found = true
+		}
+	}
+	require.True(ts.T(), found)
+
+}