Repository: supabase/gotrue
Pull request #736 (2022-10-05 08:50:06)

feat: add MFA support (disabled by default)
## What kind of change does this PR introduce?

Adds support for TOTP MFA. Summary of changes:

- Adds enrollment, challenge and verify APIs.
- Adds management APIs.
- Exposes `aal` claim in JWTs that contain `aal1`, `aal2` levels.
- Exposes `amr` claim in JWTs that contains a list of all authentication methods used by the user on that particular browsing session.

PRs involved:
- #493 
- #496 
- #499 
- #501 
- #508 
- #565 
- #566 
- #568 
- #570 
- #588 
- #595 
- #643 
- #645 
- #650 
- #652
- #659 
- #660 
- #662 
- #670 
- #682 
- #691 
- #692 
- #697 
- #698 
- #722 
- #727 
- #729 
- #730 
- #731 
- #737 
- #740 
- #742 
- #743 
- #745 
- #746 
- #747 
- #751
- #753 
- #755


Patch:
diff --git a/api/admin.go b/api/admin.go
index a9d8f0ba..dd860121 100644
--- a/api/admin.go
+++ b/api/admin.go
@@ -29,6 +29,11 @@ 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"`
@@ -56,6 +61,24 @@ func (a *API) loadUser(w http.ResponseWriter, r *http.Request) (context.Context,
 	return withUser(ctx, u), nil
 }
 
+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{}
 
@@ -378,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 cf3fa16c..74d3b721 100644
--- a/api/api.go
+++ b/api/api.go
@@ -130,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)
 
@@ -143,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)
@@ -156,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,
 	})
 
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/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/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 af80475e..4b32bc3b 100644
--- a/api/signup.go
+++ b/api/signup.go
@@ -221,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
 			}
diff --git a/api/token.go b/api/token.go
index d79051a2..581458e9 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
@@ -221,8 +223,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 +335,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 +522,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 +570,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 +637,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/verify.go b/api/verify.go
index 72b5b481..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
 		}
diff --git a/conf/configuration.go b/conf/configuration.go
index afc918fd..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,6 +52,14 @@ 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            string `envconfig:"PORT" default:"8081"`
@@ -97,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"`
@@ -163,7 +173,7 @@ type MailerConfiguration struct {
 }
 
 type PhoneProviderConfiguration struct {
-	Enabled bool `json:"enabled"`
+	Enabled bool `json:"enabled" default:"false"`
 }
 
 type SmsProviderConfiguration struct {
@@ -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
 }
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/go.mod b/go.mod
index dbdf0d6a..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
@@ -18,10 +23,13 @@ require (
 	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/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
@@ -33,6 +41,8 @@ require (
 	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
@@ -55,8 +65,9 @@ require (
 	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
@@ -73,12 +84,10 @@ require (
 	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.5.2 // indirect
 	github.com/gorilla/context v1.1.1 // indirect
 	github.com/gorilla/css v1.0.0 // indirect
@@ -90,16 +99,13 @@ 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/matttproud/golang_protobuf_extensions v1.0.1 // indirect
-	github.com/microcosm-cc/bluemonday v1.0.16 // 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
diff --git a/go.sum b/go.sum
index 8e4d41a8..5d3701eb 100644
--- a/go.sum
+++ b/go.sum
@@ -41,6 +41,12 @@ github.com/Masterminds/semver/v3 v3.1.1/go.mod h1:VPu/7SZ7ePZ3QOrcuXROw5FAcLl4a0
 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=
@@ -67,6 +73,8 @@ github.com/bketelsen/crypt v0.0.3-0.20200106085610-5cbc8cc4026c/go.mod h1:MKsuJm
 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=
@@ -454,8 +462,8 @@ github.com/mattn/go-sqlite3 v2.0.3+incompatible/go.mod h1:FPy6KqzDD04eiIsT53CuJW
 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=
@@ -509,6 +517,8 @@ 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=
@@ -626,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=
@@ -633,8 +644,6 @@ 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.35.0 h1:Ajldaqhxqw/gNzQA45IKFWLdG7jZuXX/wBW1d5qvbUI=
-go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.35.0/go.mod h1:9NiG9I2aHTKkcxqCILhjtyNA1QEiCjdBACv4IvrFQ+c=
 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=
@@ -657,8 +666,6 @@ go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.10.0 h1:S8Ded
 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.31.0 h1:6SiklT+gfWAwWUR0meEMxQBtihpiEs4c+vL9spDTqUs=
-go.opentelemetry.io/otel/metric v0.31.0/go.mod h1:ohmwj9KTSIeBnDBm/ZwH2PSZxZzoOaG2xZeekTRzL5A=
 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=
@@ -766,7 +773,6 @@ golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81R
 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-20210614182718-04defd469f4e/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=
@@ -785,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=
@@ -909,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=
@@ -1048,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/migrations/20221011041400_add_mfa_indexes.up.sql b/migrations/20221011041400_add_mfa_indexes.up.sql
index dfabaee4..cecdcba5 100644
--- a/migrations/20221011041400_add_mfa_indexes.up.sql
+++ b/migrations/20221011041400_add_mfa_indexes.up.sql
@@ -6,3 +6,4 @@ alter table {{ index .Options "Namespace" }}.mfa_amr_claims
 
 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"`


Test patch:
diff --git a/api/admin_test.go b/api/admin_test.go
index 89244d5e..4cdd38e5 100644
--- a/api/admin_test.go
+++ b/api/admin_test.go
@@ -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/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 32f36a8a..dbfeae15 100644
--- a/api/auth_test.go
+++ b/api/auth_test.go
@@ -76,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 {
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/phone_test.go b/api/phone_test.go
index 65683d42..2a30d1ba 100644
--- a/api/phone_test.go
+++ b/api/phone_test.go
@@ -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 {
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 4c05a143..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)
diff --git a/api/user_test.go b/api/user_test.go
index e7fd607d..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)
@@ -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
@@ -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
@@ -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 d6020091..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))
 
diff --git a/hack/test.env b/hack/test.env
index 9535f897..28ba56ef 100644
--- a/hack/test.env
+++ b/hack/test.env
@@ -98,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)
+
+}