Repository: supabase/gotrue
Pull request #1452 (2024-02-26 04:05:50)

feat: clean up test setup in MFA tests
## What kind of change does this PR introduce?

Remove redundant test setup, particularly around `TestSecondarySession`. 
- Changes `generateToken` into `generateAAL1Token`
- Abstracts adding the claim and reloading a session into a single method. Was probably a mistake to add [AMREntry](https://github.com/supabase/gotrue/blob/e9f38e76d8a7b93c5c2bb0de918a9b156155f018/internal/models/sessions.go#L38). Should sync to use [AMRClaim](https://github.com/supabase/gotrue/blob/e9f38e76d8a7b93c5c2bb0de918a9b156155f018/internal/models/amr.go) at some point
- Add additional check that a provider fields must exist if there's an SSO Claim on the last entry


Patch:
diff --git a/internal/api/anonymous.go b/internal/api/anonymous.go
new file mode 100644
index 00000000..11412639
--- /dev/null
+++ b/internal/api/anonymous.go
@@ -0,0 +1,58 @@
+package api
+
+import (
+	"net/http"
+
+	"github.com/supabase/auth/internal/metering"
+	"github.com/supabase/auth/internal/models"
+	"github.com/supabase/auth/internal/storage"
+)
+
+func (a *API) SignupAnonymously(w http.ResponseWriter, r *http.Request) error {
+	ctx := r.Context()
+	config := a.config
+	db := a.db.WithContext(ctx)
+	aud := a.requestAud(ctx, r)
+
+	if config.DisableSignup {
+		return forbiddenError("Signups not allowed for this instance")
+	}
+
+	params, err := retrieveSignupParams(r)
+	if err != nil {
+		return err
+	}
+	params.Aud = aud
+	params.Provider = "anonymous"
+
+	newUser, err := params.ToUserModel(false /* <- isSSOUser */)
+	if err != nil {
+		return err
+	}
+
+	var grantParams models.GrantParams
+	grantParams.FillGrantParams(r)
+
+	var token *AccessTokenResponse
+	err = db.Transaction(func(tx *storage.Connection) error {
+		var terr error
+		newUser, terr = a.signupNewUser(tx, newUser)
+		if terr != nil {
+			return terr
+		}
+		token, terr = a.issueRefreshToken(ctx, tx, newUser, models.Anonymous, grantParams)
+		if terr != nil {
+			return terr
+		}
+		if terr := a.setCookieTokens(config, token, false, w); terr != nil {
+			return terr
+		}
+		return nil
+	})
+	if err != nil {
+		return internalServerError("Database error creating anonymous user").WithInternalError(err)
+	}
+
+	metering.RecordLogin("anonymous", newUser.ID)
+	return sendJSON(w, http.StatusOK, token)
+}
diff --git a/internal/api/api.go b/internal/api/api.go
index e592a23d..eb27c4dc 100644
--- a/internal/api/api.go
+++ b/internal/api/api.go
@@ -54,7 +54,7 @@ func NewAPI(globalConfig *conf.GlobalConfiguration, db *storage.Connection) *API
 	return NewAPIWithVersion(context.Background(), globalConfig, db, defaultVersion)
 }
 
-func (a *API) deprecationNotices(ctx context.Context) {
+func (a *API) deprecationNotices() {
 	config := a.config
 
 	log := logrus.WithField("component", "api")
@@ -92,7 +92,7 @@ func NewAPIWithVersion(ctx context.Context, globalConfig *conf.GlobalConfigurati
 		}
 	}
 
-	api.deprecationNotices(ctx)
+	api.deprecationNotices()
 
 	xffmw, _ := xff.Default()
 	logger := observability.NewStructuredLogger(logrus.StandardLogger(), globalConfig)
@@ -143,7 +143,28 @@ func NewAPIWithVersion(ctx context.Context, globalConfig *conf.GlobalConfigurati
 
 		sharedLimiter := api.limitEmailOrPhoneSentHandler()
 		r.With(sharedLimiter).With(api.requireAdminCredentials).Post("/invite", api.Invite)
-		r.With(sharedLimiter).With(api.verifyCaptcha).Post("/signup", api.Signup)
+		r.With(sharedLimiter).With(api.verifyCaptcha).Route("/signup", func(r *router) {
+			// rate limit per hour
+			limiter := tollbooth.NewLimiter(api.config.RateLimitAnonymousUsers/(60*60), &limiter.ExpirableOptions{
+				DefaultExpirationTTL: time.Hour,
+			}).SetBurst(int(api.config.RateLimitAnonymousUsers)).SetMethods([]string{"POST"})
+			r.Post("/", func(w http.ResponseWriter, r *http.Request) error {
+				params, err := retrieveSignupParams(r)
+				if err != nil {
+					return err
+				}
+				if params.Email == "" && params.Phone == "" {
+					if !api.config.External.AnonymousUsers.Enabled {
+						return unprocessableEntityError("Anonymous sign-ins are disabled")
+					}
+					if _, err := api.limitHandler(limiter)(w, r); err != nil {
+						return err
+					}
+					return api.SignupAnonymously(w, r)
+				}
+				return api.Signup(w, r)
+			})
+		})
 		r.With(sharedLimiter).With(api.verifyCaptcha).With(api.requireEmailProvider).Post("/recover", api.Recover)
 		r.With(sharedLimiter).With(api.verifyCaptcha).Post("/resend", api.Resend)
 		r.With(sharedLimiter).With(api.verifyCaptcha).Post("/magiclink", api.MagicLink)
@@ -185,6 +206,7 @@ func NewAPIWithVersion(ctx context.Context, globalConfig *conf.GlobalConfigurati
 		})
 
 		r.With(api.requireAuthentication).Route("/factors", func(r *router) {
+			r.Use(api.requireNotAnonymous)
 			r.Post("/", api.EnrollFactor)
 			r.Route("/{factor_id}", func(r *router) {
 				r.Use(api.loadFactor)
diff --git a/internal/api/auth.go b/internal/api/auth.go
index c1f43d51..dbd0278b 100644
--- a/internal/api/auth.go
+++ b/internal/api/auth.go
@@ -35,7 +35,16 @@ func (a *API) requireAuthentication(w http.ResponseWriter, r *http.Request) (con
 	return ctx, err
 }
 
-func (a *API) requireAdmin(ctx context.Context, w http.ResponseWriter, r *http.Request) (context.Context, error) {
+func (a *API) requireNotAnonymous(w http.ResponseWriter, r *http.Request) (context.Context, error) {
+	ctx := r.Context()
+	claims := getClaims(ctx)
+	if claims.IsAnonymous {
+		return nil, forbiddenError("Anonymous user not allowed to perform these actions")
+	}
+	return ctx, nil
+}
+
+func (a *API) requireAdmin(ctx context.Context, r *http.Request) (context.Context, error) {
 	// Find the administrative user
 	claims := getClaims(ctx)
 	if claims == nil {
diff --git a/internal/api/external.go b/internal/api/external.go
index a47d201d..6982f2c6 100644
--- a/internal/api/external.go
+++ b/internal/api/external.go
@@ -148,7 +148,7 @@ func (a *API) ExternalProviderCallback(w http.ResponseWriter, r *http.Request) e
 	return nil
 }
 
-func (a *API) handleOAuthCallback(w http.ResponseWriter, r *http.Request) (*OAuthProviderData, error) {
+func (a *API) handleOAuthCallback(r *http.Request) (*OAuthProviderData, error) {
 	ctx := r.Context()
 	providerType := getExternalProviderType(ctx)
 
@@ -157,7 +157,7 @@ func (a *API) handleOAuthCallback(w http.ResponseWriter, r *http.Request) (*OAut
 	switch providerType {
 	case "twitter":
 		// future OAuth1.0 providers will use this method
-		oAuthResponseData, err = a.oAuth1Callback(ctx, r, providerType)
+		oAuthResponseData, err = a.oAuth1Callback(ctx, providerType)
 	default:
 		oAuthResponseData, err = a.oAuthCallback(ctx, r, providerType)
 	}
@@ -176,7 +176,7 @@ func (a *API) internalExternalProviderCallback(w http.ResponseWriter, r *http.Re
 	grantParams.FillGrantParams(r)
 
 	providerType := getExternalProviderType(ctx)
-	data, err := a.handleOAuthCallback(w, r)
+	data, err := a.handleOAuthCallback(r)
 	if err != nil {
 		return err
 	}
@@ -213,11 +213,11 @@ func (a *API) internalExternalProviderCallback(w http.ResponseWriter, r *http.Re
 	err = db.Transaction(func(tx *storage.Connection) error {
 		var terr error
 		if targetUser := getTargetUser(ctx); targetUser != nil {
-			if user, terr = a.linkIdentityToUser(ctx, tx, userData, providerType); terr != nil {
+			if user, terr = a.linkIdentityToUser(r, ctx, tx, userData, providerType); terr != nil {
 				return terr
 			}
 		} else if inviteToken := getInviteToken(ctx); inviteToken != "" {
-			if user, terr = a.processInvite(r, ctx, tx, userData, inviteToken, providerType); terr != nil {
+			if user, terr = a.processInvite(r, tx, userData, inviteToken, providerType); terr != nil {
 				return terr
 			}
 		} else {
@@ -327,7 +327,7 @@ func (a *API) createAccountFromExternalIdentity(tx *storage.Connection, r *http.
 			return nil, terr
 		}
 
-		if user, terr = a.signupNewUser(ctx, tx, user); terr != nil {
+		if user, terr = a.signupNewUser(tx, user); terr != nil {
 			return nil, terr
 		}
 
@@ -411,7 +411,7 @@ func (a *API) createAccountFromExternalIdentity(tx *storage.Connection, r *http.
 	return user, nil
 }
 
-func (a *API) processInvite(r *http.Request, ctx context.Context, tx *storage.Connection, userData *provider.UserProvidedData, inviteToken, providerType string) (*models.User, error) {
+func (a *API) processInvite(r *http.Request, tx *storage.Connection, userData *provider.UserProvidedData, inviteToken, providerType string) (*models.User, error) {
 	user, err := models.FindUserByConfirmationToken(tx, inviteToken)
 	if err != nil {
 		if models.IsNotFoundError(err) {
diff --git a/internal/api/external_oauth.go b/internal/api/external_oauth.go
index e37b7d68..5352299a 100644
--- a/internal/api/external_oauth.go
+++ b/internal/api/external_oauth.go
@@ -104,7 +104,7 @@ func (a *API) oAuthCallback(ctx context.Context, r *http.Request, providerType s
 	}, nil
 }
 
-func (a *API) oAuth1Callback(ctx context.Context, r *http.Request, providerType string) (*OAuthProviderData, error) {
+func (a *API) oAuth1Callback(ctx context.Context, providerType string) (*OAuthProviderData, error) {
 	oAuthProvider, err := a.OAuthProvider(ctx, providerType)
 	if err != nil {
 		return nil, badRequestError("Unsupported provider: %+v", err).WithInternalError(err)
diff --git a/internal/api/identity.go b/internal/api/identity.go
index 14f2c167..4db43d8c 100644
--- a/internal/api/identity.go
+++ b/internal/api/identity.go
@@ -2,14 +2,17 @@ package api
 
 import (
 	"context"
+	"fmt"
 	"net/http"
 
 	"github.com/fatih/structs"
 	"github.com/go-chi/chi"
 	"github.com/gofrs/uuid"
+	"github.com/pkg/errors"
 	"github.com/supabase/auth/internal/api/provider"
 	"github.com/supabase/auth/internal/models"
 	"github.com/supabase/auth/internal/storage"
+	"github.com/supabase/auth/internal/utilities"
 )
 
 func (a *API) DeleteIdentity(w http.ResponseWriter, r *http.Request) error {
@@ -57,7 +60,7 @@ func (a *API) DeleteIdentity(w http.ResponseWriter, r *http.Request) error {
 		if terr := tx.Destroy(identityToBeDeleted); terr != nil {
 			return internalServerError("Database error deleting identity").WithInternalError(terr)
 		}
-		if terr := user.UpdateUserEmail(tx); terr != nil {
+		if terr := user.UpdateUserEmailFromIdentities(tx); terr != nil {
 			if models.IsUniqueConstraintViolatedError(terr) {
 				return forbiddenError("Unable to unlink identity due to email conflict").WithInternalError(terr)
 			}
@@ -92,7 +95,7 @@ func (a *API) LinkIdentity(w http.ResponseWriter, r *http.Request) error {
 	return nil
 }
 
-func (a *API) linkIdentityToUser(ctx context.Context, tx *storage.Connection, userData *provider.UserProvidedData, providerType string) (*models.User, error) {
+func (a *API) linkIdentityToUser(r *http.Request, ctx context.Context, tx *storage.Connection, userData *provider.UserProvidedData, providerType string) (*models.User, error) {
 	targetUser := getTargetUser(ctx)
 	identity, terr := models.FindIdentityByIdAndProvider(tx, userData.Metadata.Subject, providerType)
 	if terr != nil {
@@ -109,6 +112,37 @@ func (a *API) linkIdentityToUser(ctx context.Context, tx *storage.Connection, us
 	if _, terr := a.createNewIdentity(tx, targetUser, providerType, structs.Map(userData.Metadata)); terr != nil {
 		return nil, terr
 	}
+
+	if targetUser.GetEmail() == "" {
+		if terr := targetUser.UpdateUserEmailFromIdentities(tx); terr != nil {
+			if models.IsUniqueConstraintViolatedError(terr) {
+				return nil, badRequestError(DuplicateEmailMsg)
+			}
+			return nil, terr
+		}
+		if !userData.Metadata.EmailVerified {
+			mailer := a.Mailer(ctx)
+			referrer := utilities.GetReferrer(r, a.config)
+			externalURL := getExternalHost(ctx)
+			if terr := sendConfirmation(tx, targetUser, mailer, a.config.SMTP.MaxFrequency, referrer, externalURL, a.config.Mailer.OtpLength, models.ImplicitFlow); terr != nil {
+				if errors.Is(terr, MaxFrequencyLimitError) {
+					return nil, tooManyRequestsError("For security purposes, you can only request this once every minute")
+				}
+			}
+			return nil, storage.NewCommitWithError(unauthorizedError(fmt.Sprintf("Unverified email with %v. A confirmation email has been sent to your %v email", providerType, providerType)))
+		}
+		if terr := targetUser.Confirm(tx); terr != nil {
+			return nil, terr
+		}
+
+		if targetUser.IsAnonymous {
+			targetUser.IsAnonymous = false
+			if terr := tx.UpdateOnly(targetUser, "is_anonymous"); terr != nil {
+				return nil, terr
+			}
+		}
+	}
+
 	if terr := targetUser.UpdateAppMetaDataProviders(tx); terr != nil {
 		return nil, terr
 	}
diff --git a/internal/api/invite.go b/internal/api/invite.go
index 2a0aeb51..65a65198 100644
--- a/internal/api/invite.go
+++ b/internal/api/invite.go
@@ -66,7 +66,7 @@ func (a *API) Invite(w http.ResponseWriter, r *http.Request) error {
 				return err
 			}
 
-			user, err = a.signupNewUser(ctx, tx, user)
+			user, err = a.signupNewUser(tx, user)
 			if err != nil {
 				return err
 			}
diff --git a/internal/api/mail.go b/internal/api/mail.go
index 6d4bd081..057c451b 100644
--- a/internal/api/mail.go
+++ b/internal/api/mail.go
@@ -146,7 +146,7 @@ func (a *API) adminGenerateLink(w http.ResponseWriter, r *http.Request) error {
 					return terr
 				}
 
-				user, terr = a.signupNewUser(ctx, tx, user)
+				user, terr = a.signupNewUser(tx, user)
 				if terr != nil {
 					return terr
 				}
@@ -182,7 +182,7 @@ func (a *API) adminGenerateLink(w http.ResponseWriter, r *http.Request) error {
 				// password here to generate a new user, use
 				// signupUser which is a model generated from
 				// SignupParams above
-				user, terr = a.signupNewUser(ctx, tx, signupUser)
+				user, terr = a.signupNewUser(tx, signupUser)
 				if terr != nil {
 					return terr
 				}
@@ -326,9 +326,6 @@ func (a *API) sendReauthenticationOtp(tx *storage.Connection, u *models.User, ma
 		return err
 	}
 	u.ReauthenticationToken = crypto.GenerateTokenHash(u.GetEmail(), otp)
-	if err != nil {
-		return err
-	}
 	now := time.Now()
 	if err := mailer.ReauthenticateMail(u, otp); err != nil {
 		u.ReauthenticationToken = oldToken
@@ -384,9 +381,6 @@ func (a *API) sendEmailChange(tx *storage.Connection, config *conf.GlobalConfigu
 		}
 		currentToken := crypto.GenerateTokenHash(u.GetEmail(), otpCurrent)
 		u.EmailChangeTokenCurrent = addFlowPrefixToToken(currentToken, flowType)
-		if err != nil {
-			return err
-		}
 	}
 
 	u.EmailChangeConfirmStatus = zeroConfirmation
diff --git a/internal/api/middleware.go b/internal/api/middleware.go
index c0f3d857..cf83f662 100644
--- a/internal/api/middleware.go
+++ b/internal/api/middleware.go
@@ -148,7 +148,7 @@ func (a *API) requireAdminCredentials(w http.ResponseWriter, req *http.Request)
 		return nil, err
 	}
 
-	return a.requireAdmin(ctx, w, req)
+	return a.requireAdmin(ctx, req)
 }
 
 func (a *API) requireEmailProvider(w http.ResponseWriter, req *http.Request) (context.Context, error) {
diff --git a/internal/api/otp.go b/internal/api/otp.go
index 0700f097..752437bb 100644
--- a/internal/api/otp.go
+++ b/internal/api/otp.go
@@ -203,7 +203,7 @@ func (a *API) SmsOtp(w http.ResponseWriter, r *http.Request) error {
 		if terr != nil {
 			return badRequestError("Error sending sms: %v", terr)
 		}
-		mID, serr := a.sendPhoneConfirmation(ctx, tx, user, params.Phone, phoneConfirmationOtp, smsProvider, params.Channel)
+		mID, serr := a.sendPhoneConfirmation(tx, user, params.Phone, phoneConfirmationOtp, smsProvider, params.Channel)
 		if serr != nil {
 			return badRequestError("Error sending sms OTP: %v", serr)
 		}
diff --git a/internal/api/phone.go b/internal/api/phone.go
index 5d6e9bda..cea94a94 100644
--- a/internal/api/phone.go
+++ b/internal/api/phone.go
@@ -2,7 +2,6 @@ package api
 
 import (
 	"bytes"
-	"context"
 	"regexp"
 	"strings"
 	"text/template"
@@ -41,7 +40,7 @@ func formatPhoneNumber(phone string) string {
 }
 
 // sendPhoneConfirmation sends an otp to the user's phone number
-func (a *API) sendPhoneConfirmation(ctx context.Context, tx *storage.Connection, user *models.User, phone, otpType string, smsProvider sms_provider.SmsProvider, channel string) (string, error) {
+func (a *API) sendPhoneConfirmation(tx *storage.Connection, user *models.User, phone, otpType string, smsProvider sms_provider.SmsProvider, channel string) (string, error) {
 	config := a.config
 
 	var token *string
diff --git a/internal/api/pkce.go b/internal/api/pkce.go
index a186aa46..c5ed5e54 100644
--- a/internal/api/pkce.go
+++ b/internal/api/pkce.go
@@ -2,7 +2,6 @@ package api
 
 import (
 	"regexp"
-	"time"
 
 	"github.com/supabase/auth/internal/models"
 	"github.com/supabase/auth/internal/storage"
@@ -38,7 +37,7 @@ func addFlowPrefixToToken(token string, flowType models.FlowType) string {
 	return token
 }
 
-func issueAuthCode(tx *storage.Connection, user *models.User, expiryDuration time.Duration, authenticationMethod models.AuthenticationMethod) (string, error) {
+func issueAuthCode(tx *storage.Connection, user *models.User, authenticationMethod models.AuthenticationMethod) (string, error) {
 	flowState, err := models.FindFlowStateByUserID(tx, user.ID.String(), authenticationMethod)
 	if err != nil && models.IsNotFoundError(err) {
 		return "", badRequestError("No valid flow state found for user.")
diff --git a/internal/api/reauthenticate.go b/internal/api/reauthenticate.go
index e29f2f7e..b62a51fc 100644
--- a/internal/api/reauthenticate.go
+++ b/internal/api/reauthenticate.go
@@ -49,7 +49,7 @@ func (a *API) Reauthenticate(w http.ResponseWriter, r *http.Request) error {
 			if terr != nil {
 				return badRequestError("Error sending sms: %v", terr)
 			}
-			mID, err := a.sendPhoneConfirmation(ctx, tx, user, phone, phoneReauthenticationOtp, smsProvider, sms_provider.SMSProvider)
+			mID, err := a.sendPhoneConfirmation(tx, user, phone, phoneReauthenticationOtp, smsProvider, sms_provider.SMSProvider)
 			if err != nil {
 				return err
 			}
diff --git a/internal/api/resend.go b/internal/api/resend.go
index a2fb4a52..a49a55e7 100644
--- a/internal/api/resend.go
+++ b/internal/api/resend.go
@@ -140,7 +140,7 @@ func (a *API) Resend(w http.ResponseWriter, r *http.Request) error {
 			if terr != nil {
 				return terr
 			}
-			mID, terr := a.sendPhoneConfirmation(ctx, tx, user, params.Phone, phoneConfirmationOtp, smsProvider, sms_provider.SMSProvider)
+			mID, terr := a.sendPhoneConfirmation(tx, user, params.Phone, phoneConfirmationOtp, smsProvider, sms_provider.SMSProvider)
 			if terr != nil {
 				return terr
 			}
@@ -152,7 +152,7 @@ func (a *API) Resend(w http.ResponseWriter, r *http.Request) error {
 			if terr != nil {
 				return terr
 			}
-			mID, terr := a.sendPhoneConfirmation(ctx, tx, user, user.PhoneChange, phoneChangeVerification, smsProvider, sms_provider.SMSProvider)
+			mID, terr := a.sendPhoneConfirmation(tx, user, user.PhoneChange, phoneChangeVerification, smsProvider, sms_provider.SMSProvider)
 			if terr != nil {
 				return terr
 			}
diff --git a/internal/api/settings.go b/internal/api/settings.go
index 43070a05..9ea93edb 100644
--- a/internal/api/settings.go
+++ b/internal/api/settings.go
@@ -3,29 +3,30 @@ package api
 import "net/http"
 
 type ProviderSettings struct {
-	Apple        bool `json:"apple"`
-	Azure        bool `json:"azure"`
-	Bitbucket    bool `json:"bitbucket"`
-	Discord      bool `json:"discord"`
-	Facebook     bool `json:"facebook"`
-	Figma        bool `json:"figma"`
-	Fly          bool `json:"fly"`
-	GitHub       bool `json:"github"`
-	GitLab       bool `json:"gitlab"`
-	Google       bool `json:"google"`
-	Keycloak     bool `json:"keycloak"`
-	Kakao        bool `json:"kakao"`
-	Linkedin     bool `json:"linkedin"`
-	LinkedinOIDC bool `json:"linkedin_oidc"`
-	Notion       bool `json:"notion"`
-	Spotify      bool `json:"spotify"`
-	Slack        bool `json:"slack"`
-	WorkOS       bool `json:"workos"`
-	Twitch       bool `json:"twitch"`
-	Twitter      bool `json:"twitter"`
-	Email        bool `json:"email"`
-	Phone        bool `json:"phone"`
-	Zoom         bool `json:"zoom"`
+	AnonymousUsers bool `json:"anonymous_users"`
+	Apple          bool `json:"apple"`
+	Azure          bool `json:"azure"`
+	Bitbucket      bool `json:"bitbucket"`
+	Discord        bool `json:"discord"`
+	Facebook       bool `json:"facebook"`
+	Figma          bool `json:"figma"`
+	Fly            bool `json:"fly"`
+	GitHub         bool `json:"github"`
+	GitLab         bool `json:"gitlab"`
+	Google         bool `json:"google"`
+	Keycloak       bool `json:"keycloak"`
+	Kakao          bool `json:"kakao"`
+	Linkedin       bool `json:"linkedin"`
+	LinkedinOIDC   bool `json:"linkedin_oidc"`
+	Notion         bool `json:"notion"`
+	Spotify        bool `json:"spotify"`
+	Slack          bool `json:"slack"`
+	WorkOS         bool `json:"workos"`
+	Twitch         bool `json:"twitch"`
+	Twitter        bool `json:"twitter"`
+	Email          bool `json:"email"`
+	Phone          bool `json:"phone"`
+	Zoom           bool `json:"zoom"`
 }
 
 type Settings struct {
@@ -43,29 +44,30 @@ func (a *API) Settings(w http.ResponseWriter, r *http.Request) error {
 
 	return sendJSON(w, http.StatusOK, &Settings{
 		ExternalProviders: ProviderSettings{
-			Apple:        config.External.Apple.Enabled,
-			Azure:        config.External.Azure.Enabled,
-			Bitbucket:    config.External.Bitbucket.Enabled,
-			Discord:      config.External.Discord.Enabled,
-			Facebook:     config.External.Facebook.Enabled,
-			Figma:        config.External.Figma.Enabled,
-			Fly:          config.External.Fly.Enabled,
-			GitHub:       config.External.Github.Enabled,
-			GitLab:       config.External.Gitlab.Enabled,
-			Google:       config.External.Google.Enabled,
-			Kakao:        config.External.Kakao.Enabled,
-			Keycloak:     config.External.Keycloak.Enabled,
-			Linkedin:     config.External.Linkedin.Enabled,
-			LinkedinOIDC: config.External.LinkedinOIDC.Enabled,
-			Notion:       config.External.Notion.Enabled,
-			Spotify:      config.External.Spotify.Enabled,
-			Slack:        config.External.Slack.Enabled,
-			Twitch:       config.External.Twitch.Enabled,
-			Twitter:      config.External.Twitter.Enabled,
-			WorkOS:       config.External.WorkOS.Enabled,
-			Email:        config.External.Email.Enabled,
-			Phone:        config.External.Phone.Enabled,
-			Zoom:         config.External.Zoom.Enabled,
+			AnonymousUsers: config.External.AnonymousUsers.Enabled,
+			Apple:          config.External.Apple.Enabled,
+			Azure:          config.External.Azure.Enabled,
+			Bitbucket:      config.External.Bitbucket.Enabled,
+			Discord:        config.External.Discord.Enabled,
+			Facebook:       config.External.Facebook.Enabled,
+			Figma:          config.External.Figma.Enabled,
+			Fly:            config.External.Fly.Enabled,
+			GitHub:         config.External.Github.Enabled,
+			GitLab:         config.External.Gitlab.Enabled,
+			Google:         config.External.Google.Enabled,
+			Kakao:          config.External.Kakao.Enabled,
+			Keycloak:       config.External.Keycloak.Enabled,
+			Linkedin:       config.External.Linkedin.Enabled,
+			LinkedinOIDC:   config.External.LinkedinOIDC.Enabled,
+			Notion:         config.External.Notion.Enabled,
+			Spotify:        config.External.Spotify.Enabled,
+			Slack:          config.External.Slack.Enabled,
+			Twitch:         config.External.Twitch.Enabled,
+			Twitter:        config.External.Twitter.Enabled,
+			WorkOS:         config.External.WorkOS.Enabled,
+			Email:          config.External.Email.Enabled,
+			Phone:          config.External.Phone.Enabled,
+			Zoom:           config.External.Zoom.Enabled,
 		},
 		DisableSignup:     config.DisableSignup,
 		MailerAutoconfirm: config.Mailer.Autoconfirm,
diff --git a/internal/api/signup.go b/internal/api/signup.go
index d7f28990..e2c858f1 100644
--- a/internal/api/signup.go
+++ b/internal/api/signup.go
@@ -80,6 +80,9 @@ func (params *SignupParams) ToUserModel(isSSOUser bool) (user *models.User, err
 		user, err = models.NewUser("", params.Email, params.Password, params.Aud, params.Data)
 	case "phone":
 		user, err = models.NewUser(params.Phone, "", params.Password, params.Aud, params.Data)
+	case "anonymous":
+		user, err = models.NewUser("", "", "", params.Aud, params.Data)
+		user.IsAnonymous = true
 	default:
 		// handles external provider case
 		user, err = models.NewUser("", params.Email, params.Password, params.Aud, params.Data)
@@ -95,15 +98,26 @@ func (params *SignupParams) ToUserModel(isSSOUser bool) (user *models.User, err
 
 	user.Identities = make([]models.Identity, 0)
 
-	// TODO: Deprecate "provider" field
-	user.AppMetaData["provider"] = params.Provider
+	if params.Provider != "anonymous" {
+		// TODO: Deprecate "provider" field
+		user.AppMetaData["provider"] = params.Provider
 
-	user.AppMetaData["providers"] = []string{params.Provider}
-	if params.Password == "" {
-		user.EncryptedPassword = ""
+		user.AppMetaData["providers"] = []string{params.Provider}
 	}
 
-	return
+	return user, nil
+}
+
+func retrieveSignupParams(r *http.Request) (*SignupParams, error) {
+	params := &SignupParams{}
+	body, err := getBodyBytes(r)
+	if err != nil {
+		return nil, internalServerError("Could not read body").WithInternalError(err)
+	}
+	if err := json.Unmarshal(body, params); err != nil {
+		return nil, badRequestError("Could not read Signup params: %v", err)
+	}
+	return params, nil
 }
 
 // Signup is the endpoint for registering a new user
@@ -116,15 +130,9 @@ func (a *API) Signup(w http.ResponseWriter, r *http.Request) error {
 		return forbiddenError("Signups not allowed for this instance")
 	}
 
-	params := &SignupParams{}
-
-	body, err := getBodyBytes(r)
+	params, err := retrieveSignupParams(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 Signup params: %v", err)
+		return err
 	}
 
 	params.ConfigureDefaults()
@@ -192,22 +200,36 @@ func (a *API) Signup(w http.ResponseWriter, r *http.Request) error {
 			if (params.Provider == "email" && user.IsConfirmed()) || (params.Provider == "phone" && user.IsPhoneConfirmed()) {
 				return UserExistsError
 			}
-
 			// do not update the user because we can't be sure of their claimed identity
 		} else {
-			user, terr = a.signupNewUser(ctx, tx, signupUser)
+			user, terr = a.signupNewUser(tx, signupUser)
 			if terr != nil {
 				return terr
 			}
-			identity, terr := a.createNewIdentity(tx, user, params.Provider, structs.Map(provider.Claims{
+		}
+		identity, terr := models.FindIdentityByIdAndProvider(tx, user.ID.String(), "email")
+		if terr != nil {
+			if !models.IsNotFoundError(terr) {
+				return terr
+			}
+			identityData := structs.Map(provider.Claims{
 				Subject: user.ID.String(),
 				Email:   user.GetEmail(),
-			}))
+			})
+			for k, v := range params.Data {
+				if _, ok := identityData[k]; !ok {
+					identityData[k] = v
+				}
+			}
+			identity, terr = a.createNewIdentity(tx, user, params.Provider, identityData)
 			if terr != nil {
 				return terr
 			}
-			user.Identities = []models.Identity{*identity}
+			if terr := user.RemoveUnconfirmedIdentities(tx, identity); terr != nil {
+				return terr
+			}
 		}
+		user.Identities = []models.Identity{*identity}
 
 		if params.Provider == "email" && !user.IsConfirmed() {
 			if config.Mailer.Autoconfirm {
@@ -263,7 +285,7 @@ func (a *API) Signup(w http.ResponseWriter, r *http.Request) error {
 				if terr != nil {
 					return badRequestError("Error sending confirmation sms: %v", terr)
 				}
-				if _, terr := a.sendPhoneConfirmation(ctx, tx, user, params.Phone, phoneConfirmationOtp, smsProvider, params.Channel); terr != nil {
+				if _, terr := a.sendPhoneConfirmation(tx, user, params.Phone, phoneConfirmationOtp, smsProvider, params.Channel); terr != nil {
 					return badRequestError("Error sending confirmation sms: %v", terr)
 				}
 			}
@@ -368,7 +390,7 @@ func sanitizeUser(u *models.User, params *SignupParams) (*models.User, error) {
 	return u, nil
 }
 
-func (a *API) signupNewUser(ctx context.Context, conn *storage.Connection, user *models.User) (*models.User, error) {
+func (a *API) signupNewUser(conn *storage.Connection, user *models.User) (*models.User, error) {
 	config := a.config
 
 	err := conn.Transaction(func(tx *storage.Connection) error {
@@ -385,11 +407,10 @@ func (a *API) signupNewUser(ctx context.Context, conn *storage.Connection, user
 		return nil, err
 	}
 
-	// sometimes there may be triggers in the database that will modify the
+	// there may be triggers or generated column values in the database that will modify the
 	// user data as it is being inserted. thus we load the user object
 	// again to fetch those changes.
-	err = conn.Eager().Load(user)
-	if err != nil {
+	if err := conn.Reload(user); err != nil {
 		return nil, internalServerError("Database error loading user after sign-up").WithInternalError(err)
 	}
 
diff --git a/internal/api/token.go b/internal/api/token.go
index b85959b3..1675a657 100644
--- a/internal/api/token.go
+++ b/internal/api/token.go
@@ -34,6 +34,7 @@ type AccessTokenClaims struct {
 	AuthenticatorAssuranceLevel   string                 `json:"aal,omitempty"`
 	AuthenticationMethodReference []models.AMREntry      `json:"amr,omitempty"`
 	SessionId                     string                 `json:"session_id,omitempty"`
+	IsAnonymous                   bool                   `json:"is_anonymous"`
 }
 
 // AccessTokenResponse represents an OAuth2 success response
@@ -336,6 +337,7 @@ func (a *API) generateAccessToken(ctx context.Context, tx *storage.Connection, u
 		SessionId:                     sid,
 		AuthenticatorAssuranceLevel:   aal,
 		AuthenticationMethodReference: amr,
+		IsAnonymous:                   user.IsAnonymous,
 	}
 
 	var token *jwt.Token
diff --git a/internal/api/token_refresh.go b/internal/api/token_refresh.go
index 1cd66534..ebe4b5f2 100644
--- a/internal/api/token_refresh.go
+++ b/internal/api/token_refresh.go
@@ -261,12 +261,6 @@ func (a *API) RefreshTokenGrant(ctx context.Context, w http.ResponseWriter, r *h
 
 			return nil
 		})
-		if err == nil {
-			// success
-			metering.RecordLogin("token", user.ID)
-			return sendJSON(w, http.StatusOK, newTokenResponse)
-		}
-
 		if err != nil {
 			if retry && models.IsNotFoundError(err) {
 				// refresh token and session row were likely locked, so
@@ -278,6 +272,8 @@ func (a *API) RefreshTokenGrant(ctx context.Context, w http.ResponseWriter, r *h
 				return err
 			}
 		}
+		metering.RecordLogin("token", user.ID)
+		return sendJSON(w, http.StatusOK, newTokenResponse)
 	}
 
 	return conflictError("Too many concurrent token refresh requests on the same session or refresh token")
diff --git a/internal/api/user.go b/internal/api/user.go
index 73991b46..e31a3ece 100644
--- a/internal/api/user.go
+++ b/internal/api/user.go
@@ -107,6 +107,14 @@ func (a *API) UserUpdate(w http.ResponseWriter, r *http.Request) error {
 		}
 	}
 
+	if user.IsAnonymous {
+		updatingForbiddenFields := false
+		updatingForbiddenFields = updatingForbiddenFields || (params.Password != nil && *params.Password != "")
+		if updatingForbiddenFields {
+			return unprocessableEntityError("Updating password of an anonymous user is not possible")
+		}
+	}
+
 	if user.IsSSOUser {
 		updatingForbiddenFields := false
 
@@ -216,7 +224,7 @@ func (a *API) UserUpdate(w http.ResponseWriter, r *http.Request) error {
 		if params.Phone != "" && params.Phone != user.GetPhone() {
 			if config.Sms.Autoconfirm {
 				user.PhoneChange = params.Phone
-				if _, terr := a.smsVerify(r, ctx, tx, user, &VerifyParams{
+				if _, terr := a.smsVerify(r, tx, user, &VerifyParams{
 					Type:  phoneChangeVerification,
 					Phone: params.Phone,
 				}); terr != nil {
@@ -227,7 +235,7 @@ func (a *API) UserUpdate(w http.ResponseWriter, r *http.Request) error {
 				if terr != nil {
 					return badRequestError("Error sending sms: %v", terr)
 				}
-				if _, terr := a.sendPhoneConfirmation(ctx, tx, user, params.Phone, phoneChangeVerification, smsProvider, params.Channel); terr != nil {
+				if _, terr := a.sendPhoneConfirmation(tx, user, params.Phone, phoneChangeVerification, smsProvider, params.Channel); terr != nil {
 					return internalServerError("Error sending phone change otp").WithInternalError(terr)
 				}
 			}
diff --git a/internal/api/verify.go b/internal/api/verify.go
index 7ad5f127..35f8253e 100644
--- a/internal/api/verify.go
+++ b/internal/api/verify.go
@@ -149,8 +149,7 @@ func (a *API) verifyGet(w http.ResponseWriter, r *http.Request, params *VerifyPa
 	}
 	err = db.Transaction(func(tx *storage.Connection) error {
 		var terr error
-		aud := a.requestAud(ctx, r)
-		user, terr = a.verifyTokenHash(ctx, tx, params, aud)
+		user, terr = a.verifyTokenHash(tx, params)
 		if terr != nil {
 			return terr
 		}
@@ -158,9 +157,9 @@ func (a *API) verifyGet(w http.ResponseWriter, r *http.Request, params *VerifyPa
 		case signupVerification, inviteVerification:
 			user, terr = a.signupVerify(r, ctx, tx, user)
 		case recoveryVerification, magicLinkVerification:
-			user, terr = a.recoverVerify(r, ctx, tx, user)
+			user, terr = a.recoverVerify(r, tx, user)
 		case emailChangeVerification:
-			user, terr = a.emailChangeVerify(r, ctx, tx, params, user)
+			user, terr = a.emailChangeVerify(r, tx, params, user)
 			if user == nil && terr == nil {
 				// when double confirmation is required
 				rurl, err := a.prepRedirectURL(singleConfirmationAccepted, params.RedirectTo, flowType)
@@ -177,6 +176,16 @@ func (a *API) verifyGet(w http.ResponseWriter, r *http.Request, params *VerifyPa
 		if terr != nil {
 			return terr
 		}
+
+		if terr := user.UpdateAppMetaDataProviders(tx); terr != nil {
+			return terr
+		}
+
+		// Reload user model from db.
+		// This is important for refreshing the data in any generated columns like IsAnonymous.
+		if terr := tx.Reload(user); err != nil {
+			return terr
+		}
 		if isImplicitFlow(flowType) {
 			token, terr = a.issueRefreshToken(ctx, tx, user, models.OTP, grantParams)
 
@@ -188,7 +197,7 @@ func (a *API) verifyGet(w http.ResponseWriter, r *http.Request, params *VerifyPa
 				return internalServerError("Failed to set JWT cookie. %s", terr)
 			}
 		} else if isPKCEFlow(flowType) {
-			if authCode, terr = issueAuthCode(tx, user, a.config.External.FlowStateExpiryDuration, authenticationMethod); terr != nil {
+			if authCode, terr = issueAuthCode(tx, user, authenticationMethod); terr != nil {
 				return badRequestError("No associated flow state found. %s", terr)
 			}
 		}
@@ -198,7 +207,7 @@ func (a *API) verifyGet(w http.ResponseWriter, r *http.Request, params *VerifyPa
 	if err != nil {
 		var herr *HTTPError
 		if errors.As(err, &herr) {
-			rurl, err := a.prepErrorRedirectURL(herr, w, r, params.RedirectTo, flowType)
+			rurl, err := a.prepErrorRedirectURL(herr, r, params.RedirectTo, flowType)
 			if err != nil {
 				return err
 			}
@@ -240,9 +249,9 @@ func (a *API) verifyPost(w http.ResponseWriter, r *http.Request, params *VerifyP
 		aud := a.requestAud(ctx, r)
 
 		if isUsingTokenHash(params) {
-			user, terr = a.verifyTokenHash(ctx, tx, params, aud)
+			user, terr = a.verifyTokenHash(tx, params)
 		} else {
-			user, terr = a.verifyUserAndToken(ctx, tx, params, aud)
+			user, terr = a.verifyUserAndToken(tx, params, aud)
 		}
 		if terr != nil {
 			return terr
@@ -252,15 +261,15 @@ func (a *API) verifyPost(w http.ResponseWriter, r *http.Request, params *VerifyP
 		case signupVerification, inviteVerification:
 			user, terr = a.signupVerify(r, ctx, tx, user)
 		case recoveryVerification, magicLinkVerification:
-			user, terr = a.recoverVerify(r, ctx, tx, user)
+			user, terr = a.recoverVerify(r, tx, user)
 		case emailChangeVerification:
-			user, terr = a.emailChangeVerify(r, ctx, tx, params, user)
+			user, terr = a.emailChangeVerify(r, tx, params, user)
 			if user == nil && terr == nil {
 				isSingleConfirmationResponse = true
 				return nil
 			}
 		case smsVerification, phoneChangeVerification:
-			user, terr = a.smsVerify(r, ctx, tx, user, params)
+			user, terr = a.smsVerify(r, tx, user, params)
 		default:
 			return unprocessableEntityError("Unsupported verification type")
 		}
@@ -268,6 +277,16 @@ func (a *API) verifyPost(w http.ResponseWriter, r *http.Request, params *VerifyP
 		if terr != nil {
 			return terr
 		}
+
+		if terr := user.UpdateAppMetaDataProviders(tx); terr != nil {
+			return terr
+		}
+
+		// Reload user model from db.
+		// This is important for refreshing the data in any generated columns like IsAnonymous.
+		if terr := tx.Reload(user); terr != nil {
+			return terr
+		}
 		token, terr = a.issueRefreshToken(ctx, tx, user, models.OTP, grantParams)
 		if terr != nil {
 			return terr
@@ -327,7 +346,7 @@ func (a *API) signupVerify(r *http.Request, ctx context.Context, conn *storage.C
 	return user, nil
 }
 
-func (a *API) recoverVerify(r *http.Request, ctx context.Context, conn *storage.Connection, user *models.User) (*models.User, error) {
+func (a *API) recoverVerify(r *http.Request, conn *storage.Connection, user *models.User) (*models.User, error) {
 	err := conn.Transaction(func(tx *storage.Connection) error {
 		var terr error
 		if terr = user.Recover(tx); terr != nil {
@@ -355,7 +374,7 @@ func (a *API) recoverVerify(r *http.Request, ctx context.Context, conn *storage.
 	return user, nil
 }
 
-func (a *API) smsVerify(r *http.Request, ctx context.Context, conn *storage.Connection, user *models.User, params *VerifyParams) (*models.User, error) {
+func (a *API) smsVerify(r *http.Request, conn *storage.Connection, user *models.User, params *VerifyParams) (*models.User, error) {
 
 	err := conn.Transaction(func(tx *storage.Connection) error {
 
@@ -406,7 +425,7 @@ func (a *API) smsVerify(r *http.Request, ctx context.Context, conn *storage.Conn
 	return user, nil
 }
 
-func (a *API) prepErrorRedirectURL(err *HTTPError, w http.ResponseWriter, r *http.Request, rurl string, flowType models.FlowType) (string, error) {
+func (a *API) prepErrorRedirectURL(err *HTTPError, r *http.Request, rurl string, flowType models.FlowType) (string, error) {
 	u, perr := url.Parse(rurl)
 	if perr != nil {
 		return "", err
@@ -464,7 +483,7 @@ func (a *API) prepPKCERedirectURL(rurl, code string) (string, error) {
 	return u.String(), nil
 }
 
-func (a *API) emailChangeVerify(r *http.Request, ctx context.Context, conn *storage.Connection, params *VerifyParams, user *models.User) (*models.User, error) {
+func (a *API) emailChangeVerify(r *http.Request, conn *storage.Connection, params *VerifyParams, user *models.User) (*models.User, error) {
 	config := a.config
 	if config.Mailer.SecureEmailChangeEnabled && user.EmailChangeConfirmStatus == zeroConfirmation && user.GetEmail() != "" {
 		err := conn.Transaction(func(tx *storage.Connection) error {
@@ -511,6 +530,12 @@ func (a *API) emailChangeVerify(r *http.Request, ctx context.Context, conn *stor
 				return terr
 			}
 		}
+		if user.IsAnonymous {
+			user.IsAnonymous = false
+			if terr := tx.UpdateOnly(user, "is_anonymous"); terr != nil {
+				return terr
+			}
+		}
 		if terr := tx.Load(user, "Identities"); terr != nil {
 			return internalServerError("Error refetching identities").WithInternalError(terr)
 		}
@@ -527,7 +552,7 @@ func (a *API) emailChangeVerify(r *http.Request, ctx context.Context, conn *stor
 	return user, nil
 }
 
-func (a *API) verifyTokenHash(ctx context.Context, conn *storage.Connection, params *VerifyParams, aud string) (*models.User, error) {
+func (a *API) verifyTokenHash(conn *storage.Connection, params *VerifyParams) (*models.User, error) {
 	config := a.config
 
 	var user *models.User
@@ -583,7 +608,7 @@ func (a *API) verifyTokenHash(ctx context.Context, conn *storage.Connection, par
 }
 
 // verifyUserAndToken verifies the token associated to the user based on the verify type
-func (a *API) verifyUserAndToken(ctx context.Context, conn *storage.Connection, params *VerifyParams, aud string) (*models.User, error) {
+func (a *API) verifyUserAndToken(conn *storage.Connection, params *VerifyParams, aud string) (*models.User, error) {
 	config := a.config
 
 	var user *models.User
diff --git a/internal/conf/configuration.go b/internal/conf/configuration.go
index 7057472e..b3a5b961 100644
--- a/internal/conf/configuration.go
+++ b/internal/conf/configuration.go
@@ -61,6 +61,10 @@ type OAuthProviderConfiguration struct {
 	SkipNonceCheck bool     `json:"skip_nonce_check" split_words:"true"`
 }
 
+type AnonymousProviderConfiguration struct {
+	Enabled bool `json:"enabled" default:"false"`
+}
+
 type EmailProviderConfiguration struct {
 	Enabled bool `json:"enabled" default:"true"`
 }
@@ -199,21 +203,22 @@ type PasswordConfiguration struct {
 
 // GlobalConfiguration holds all the configuration that applies to all instances.
 type GlobalConfiguration struct {
-	API                   APIConfiguration
-	DB                    DBConfiguration
-	External              ProviderConfiguration
-	Logging               LoggingConfig  `envconfig:"LOG"`
-	Profiler              ProfilerConfig `envconfig:"PROFILER"`
-	OperatorToken         string         `split_words:"true" required:"false"`
-	Tracing               TracingConfig
-	Metrics               MetricsConfig
-	SMTP                  SMTPConfiguration
-	RateLimitHeader       string  `split_words:"true"`
-	RateLimitEmailSent    float64 `split_words:"true" default:"30"`
-	RateLimitSmsSent      float64 `split_words:"true" default:"30"`
-	RateLimitVerify       float64 `split_words:"true" default:"30"`
-	RateLimitTokenRefresh float64 `split_words:"true" default:"150"`
-	RateLimitSso          float64 `split_words:"true" default:"30"`
+	API                     APIConfiguration
+	DB                      DBConfiguration
+	External                ProviderConfiguration
+	Logging                 LoggingConfig  `envconfig:"LOG"`
+	Profiler                ProfilerConfig `envconfig:"PROFILER"`
+	OperatorToken           string         `split_words:"true" required:"false"`
+	Tracing                 TracingConfig
+	Metrics                 MetricsConfig
+	SMTP                    SMTPConfiguration
+	RateLimitHeader         string  `split_words:"true"`
+	RateLimitEmailSent      float64 `split_words:"true" default:"30"`
+	RateLimitSmsSent        float64 `split_words:"true" default:"30"`
+	RateLimitVerify         float64 `split_words:"true" default:"30"`
+	RateLimitTokenRefresh   float64 `split_words:"true" default:"150"`
+	RateLimitSso            float64 `split_words:"true" default:"30"`
+	RateLimitAnonymousUsers float64 `split_words:"true" default:"30"`
 
 	SiteURL         string   `json:"site_url" split_words:"true" required:"true"`
 	URIAllowList    []string `json:"uri_allow_list" split_words:"true"`
@@ -271,33 +276,34 @@ type EmailContentConfiguration struct {
 }
 
 type ProviderConfiguration struct {
-	Apple                   OAuthProviderConfiguration `json:"apple"`
-	Azure                   OAuthProviderConfiguration `json:"azure"`
-	Bitbucket               OAuthProviderConfiguration `json:"bitbucket"`
-	Discord                 OAuthProviderConfiguration `json:"discord"`
-	Facebook                OAuthProviderConfiguration `json:"facebook"`
-	Figma                   OAuthProviderConfiguration `json:"figma"`
-	Fly                     OAuthProviderConfiguration `json:"fly"`
-	Github                  OAuthProviderConfiguration `json:"github"`
-	Gitlab                  OAuthProviderConfiguration `json:"gitlab"`
-	Google                  OAuthProviderConfiguration `json:"google"`
-	Kakao                   OAuthProviderConfiguration `json:"kakao"`
-	Notion                  OAuthProviderConfiguration `json:"notion"`
-	Keycloak                OAuthProviderConfiguration `json:"keycloak"`
-	Linkedin                OAuthProviderConfiguration `json:"linkedin"`
-	LinkedinOIDC            OAuthProviderConfiguration `json:"linkedin_oidc" envconfig:"LINKEDIN_OIDC"`
-	Spotify                 OAuthProviderConfiguration `json:"spotify"`
-	Slack                   OAuthProviderConfiguration `json:"slack"`
-	Twitter                 OAuthProviderConfiguration `json:"twitter"`
-	Twitch                  OAuthProviderConfiguration `json:"twitch"`
-	WorkOS                  OAuthProviderConfiguration `json:"workos"`
-	Email                   EmailProviderConfiguration `json:"email"`
-	Phone                   PhoneProviderConfiguration `json:"phone"`
-	Zoom                    OAuthProviderConfiguration `json:"zoom"`
-	IosBundleId             string                     `json:"ios_bundle_id" split_words:"true"`
-	RedirectURL             string                     `json:"redirect_url"`
-	AllowedIdTokenIssuers   []string                   `json:"allowed_id_token_issuers" split_words:"true"`
-	FlowStateExpiryDuration time.Duration              `json:"flow_state_expiry_duration" split_words:"true"`
+	AnonymousUsers          AnonymousProviderConfiguration `json:"anonymous_users" split_words:"true"`
+	Apple                   OAuthProviderConfiguration     `json:"apple"`
+	Azure                   OAuthProviderConfiguration     `json:"azure"`
+	Bitbucket               OAuthProviderConfiguration     `json:"bitbucket"`
+	Discord                 OAuthProviderConfiguration     `json:"discord"`
+	Facebook                OAuthProviderConfiguration     `json:"facebook"`
+	Figma                   OAuthProviderConfiguration     `json:"figma"`
+	Fly                     OAuthProviderConfiguration     `json:"fly"`
+	Github                  OAuthProviderConfiguration     `json:"github"`
+	Gitlab                  OAuthProviderConfiguration     `json:"gitlab"`
+	Google                  OAuthProviderConfiguration     `json:"google"`
+	Kakao                   OAuthProviderConfiguration     `json:"kakao"`
+	Notion                  OAuthProviderConfiguration     `json:"notion"`
+	Keycloak                OAuthProviderConfiguration     `json:"keycloak"`
+	Linkedin                OAuthProviderConfiguration     `json:"linkedin"`
+	LinkedinOIDC            OAuthProviderConfiguration     `json:"linkedin_oidc" envconfig:"LINKEDIN_OIDC"`
+	Spotify                 OAuthProviderConfiguration     `json:"spotify"`
+	Slack                   OAuthProviderConfiguration     `json:"slack"`
+	Twitter                 OAuthProviderConfiguration     `json:"twitter"`
+	Twitch                  OAuthProviderConfiguration     `json:"twitch"`
+	WorkOS                  OAuthProviderConfiguration     `json:"workos"`
+	Email                   EmailProviderConfiguration     `json:"email"`
+	Phone                   PhoneProviderConfiguration     `json:"phone"`
+	Zoom                    OAuthProviderConfiguration     `json:"zoom"`
+	IosBundleId             string                         `json:"ios_bundle_id" split_words:"true"`
+	RedirectURL             string                         `json:"redirect_url"`
+	AllowedIdTokenIssuers   []string                       `json:"allowed_id_token_issuers" split_words:"true"`
+	FlowStateExpiryDuration time.Duration                  `json:"flow_state_expiry_duration" split_words:"true"`
 }
 
 type SMTPConfiguration struct {
diff --git a/internal/hooks/auth_hooks.go b/internal/hooks/auth_hooks.go
index 9b75ab5f..bd316308 100644
--- a/internal/hooks/auth_hooks.go
+++ b/internal/hooks/auth_hooks.go
@@ -97,6 +97,7 @@ type AccessTokenClaims struct {
 	AuthenticatorAssuranceLevel   string                 `json:"aal,omitempty"`
 	AuthenticationMethodReference []models.AMREntry      `json:"amr,omitempty"`
 	SessionId                     string                 `json:"session_id,omitempty"`
+	IsAnonymous                   bool                   `json:"is_anonymous"`
 }
 
 type MFAVerificationAttemptInput struct {
diff --git a/internal/models/factor.go b/internal/models/factor.go
index 5af45772..b9941098 100644
--- a/internal/models/factor.go
+++ b/internal/models/factor.go
@@ -45,6 +45,7 @@ const (
 	EmailSignup
 	EmailChange
 	TokenRefresh
+	Anonymous
 )
 
 func (authMethod AuthenticationMethod) String() string {
@@ -71,6 +72,8 @@ func (authMethod AuthenticationMethod) String() string {
 		return "email_change"
 	case TokenRefresh:
 		return "token_refresh"
+	case Anonymous:
+		return "anonymous"
 	}
 	return ""
 }
diff --git a/internal/models/user.go b/internal/models/user.go
index 5a899654..6111fa7a 100644
--- a/internal/models/user.go
+++ b/internal/models/user.go
@@ -66,6 +66,7 @@ type User struct {
 	UpdatedAt   time.Time  `json:"updated_at" db:"updated_at"`
 	BannedUntil *time.Time `json:"banned_until,omitempty" db:"banned_until"`
 	DeletedAt   *time.Time `json:"deleted_at,omitempty" db:"deleted_at"`
+	IsAnonymous bool       `json:"is_anonymous" db:"is_anonymous"`
 
 	DONTUSEINSTANCEID uuid.UUID `json:"-" db:"instance_id"`
 }
@@ -229,7 +230,7 @@ func (u *User) UpdateAppMetaDataProviders(tx *storage.Connection) error {
 
 // UpdateUserEmail updates the user's email to one of the identity's email
 // if the current email used doesn't match any of the identities email
-func (u *User) UpdateUserEmail(tx *storage.Connection) error {
+func (u *User) UpdateUserEmailFromIdentities(tx *storage.Connection) error {
 	identities, terr := FindIdentitiesByUserID(tx, u.ID)
 	if terr != nil {
 		return terr
@@ -246,6 +247,8 @@ func (u *User) UpdateUserEmail(tx *storage.Connection) error {
 	for _, i := range identities {
 		if _, terr := FindUserByEmailAndAudience(tx, i.GetEmail(), u.Aud); terr != nil {
 			if IsNotFoundError(terr) {
+				// the identity's email is not used by another user
+				// so we can set it as the primary identity
 				primaryIdentity = i
 				break
 			}
@@ -700,10 +703,12 @@ func (u *User) UpdateBannedUntil(tx *storage.Connection) error {
 
 // RemoveUnconfirmedIdentities removes potentially malicious unconfirmed identities from a user (if any)
 func (u *User) RemoveUnconfirmedIdentities(tx *storage.Connection, identity *Identity) error {
-	// user is unconfirmed so the password should be reset
-	u.EncryptedPassword = ""
-	if terr := tx.UpdateOnly(u, "encrypted_password"); terr != nil {
-		return terr
+	if identity.Provider != "email" && identity.Provider != "phone" {
+		// user is unconfirmed so the password should be reset
+		u.EncryptedPassword = ""
+		if terr := tx.UpdateOnly(u, "encrypted_password"); terr != nil {
+			return terr
+		}
 	}
 
 	// user is unconfirmed so existing user_metadata should be overwritten
@@ -713,16 +718,6 @@ func (u *User) RemoveUnconfirmedIdentities(tx *storage.Connection, identity *Ide
 		return terr
 	}
 
-	// user is unconfirmed so none of the providers associated to it are verified yet
-	// only the current provider should be kept
-	if _, ok := u.AppMetaData["providers"].([]string); ok {
-		u.AppMetaData["providers"] = []string{identity.Provider}
-		u.AppMetaData["provider"] = identity.Provider
-		if terr := u.UpdateAppMetaData(tx, u.AppMetaData); terr != nil {
-			return terr
-		}
-	}
-
 	// finally, remove all identities except the current identity being authenticated
 	for i := range u.Identities {
 		if u.Identities[i].ID != identity.ID {
@@ -731,6 +726,12 @@ func (u *User) RemoveUnconfirmedIdentities(tx *storage.Connection, identity *Ide
 			}
 		}
 	}
+
+	// user is unconfirmed so none of the providers associated to it are verified yet
+	// only the current provider should be kept
+	if terr := u.UpdateAppMetaDataProviders(tx); terr != nil {
+		return terr
+	}
 	return nil
 }
 
diff --git a/migrations/20240214120130_add_is_anonymous_column.up.sql b/migrations/20240214120130_add_is_anonymous_column.up.sql
new file mode 100644
index 00000000..6ef963f5
--- /dev/null
+++ b/migrations/20240214120130_add_is_anonymous_column.up.sql
@@ -0,0 +1,8 @@
+do $$
+begin
+   alter table {{ index .Options "Namespace" }}.users 
+   add column if not exists is_anonymous boolean not null default false;
+
+   create index if not exists users_is_anonymous_idx  on {{ index .Options "Namespace" }}.users using btree (is_anonymous);
+end
+$$;


Test patch:
diff --git a/hack/test.env b/hack/test.env
index 1aad710f..f4f3d0e6 100644
--- a/hack/test.env
+++ b/hack/test.env
@@ -99,6 +99,8 @@ GOTRUE_EXTERNAL_ZOOM_REDIRECT_URI=https://identity.services.netlify.com/callback
 GOTRUE_EXTERNAL_FLOW_STATE_EXPIRY_DURATION="300s"
 GOTRUE_RATE_LIMIT_VERIFY="100000"
 GOTRUE_RATE_LIMIT_TOKEN_REFRESH="30"
+GOTRUE_RATE_LIMIT_ANONYMOUS_USERS="5"
+GOTRUE_RATE_LIMIT_HEADER="My-Custom-Header"
 GOTRUE_TRACING_ENABLED=true
 GOTRUE_TRACING_EXPORTER=default
 GOTRUE_TRACING_HOST=127.0.0.1
diff --git a/internal/api/admin_test.go b/internal/api/admin_test.go
index 35447bcf..615bba6c 100644
--- a/internal/api/admin_test.go
+++ b/internal/api/admin_test.go
@@ -558,7 +558,7 @@ func (ts *AdminTestSuite) TestAdminUserDelete() {
 			require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(c.body))
 			u, err := signupParams.ToUserModel(false /* <- isSSOUser */)
 			require.NoError(ts.T(), err)
-			u, err = ts.API.signupNewUser(context.Background(), ts.API.db, u)
+			u, err = ts.API.signupNewUser(ts.API.db, u)
 			require.NoError(ts.T(), err)
 
 			// Setup request
diff --git a/internal/api/anonymous_test.go b/internal/api/anonymous_test.go
new file mode 100644
index 00000000..1260774e
--- /dev/null
+++ b/internal/api/anonymous_test.go
@@ -0,0 +1,177 @@
+package api
+
+import (
+	"bytes"
+	"encoding/json"
+	"fmt"
+	"net/http"
+	"net/http/httptest"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+	"github.com/stretchr/testify/suite"
+	"github.com/supabase/auth/internal/conf"
+	"github.com/supabase/auth/internal/models"
+)
+
+type AnonymousTestSuite struct {
+	suite.Suite
+	API    *API
+	Config *conf.GlobalConfiguration
+}
+
+func TestAnonymous(t *testing.T) {
+	api, config, err := setupAPIForTest()
+	require.NoError(t, err)
+
+	ts := &AnonymousTestSuite{
+		API:    api,
+		Config: config,
+	}
+	defer api.db.Close()
+
+	suite.Run(t, ts)
+}
+
+func (ts *AnonymousTestSuite) SetupTest() {
+	models.TruncateAll(ts.API.db)
+
+	// Create anonymous user
+	params := &SignupParams{
+		Aud:      ts.Config.JWT.Aud,
+		Provider: "anonymous",
+	}
+	u, err := params.ToUserModel(false)
+	require.NoError(ts.T(), err, "Error creating test user model")
+	require.NoError(ts.T(), ts.API.db.Create(u), "Error saving new anonymous test user")
+}
+
+func (ts *AnonymousTestSuite) TestAnonymousLogins() {
+	ts.Config.External.AnonymousUsers.Enabled = true
+	// Request body
+	var buffer bytes.Buffer
+	require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]interface{}{
+		"data": map[string]interface{}{
+			"field": "foo",
+		},
+	}))
+
+	req := httptest.NewRequest(http.MethodPost, "/signup", &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))
+	assert.NotEmpty(ts.T(), data.User.ID)
+	assert.Equal(ts.T(), ts.Config.JWT.Aud, data.User.Aud)
+	assert.Empty(ts.T(), data.User.GetEmail())
+	assert.Empty(ts.T(), data.User.GetPhone())
+	assert.True(ts.T(), data.User.IsAnonymous)
+	assert.Equal(ts.T(), models.JSONMap(models.JSONMap{"field": "foo"}), data.User.UserMetaData)
+}
+
+func (ts *AnonymousTestSuite) TestConvertAnonymousUserToPermanent() {
+	ts.Config.External.AnonymousUsers.Enabled = true
+	// Request body
+	var buffer bytes.Buffer
+	require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]interface{}{}))
+
+	req := httptest.NewRequest(http.MethodPost, "/signup", &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)
+
+	signupResponse := &AccessTokenResponse{}
+	require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&signupResponse))
+
+	// Add email to anonymous user
+	require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]interface{}{
+		"email": "test@example.com",
+	}))
+
+	req = httptest.NewRequest(http.MethodPut, "/user", &buffer)
+	req.Header.Set("Content-Type", "application/json")
+	req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", signupResponse.Token))
+
+	w = httptest.NewRecorder()
+	ts.API.handler.ServeHTTP(w, req)
+	require.Equal(ts.T(), http.StatusOK, w.Code)
+
+	// Check if anonymous user is still anonymous
+	user, err := models.FindUserByID(ts.API.db, signupResponse.User.ID)
+	require.NoError(ts.T(), err)
+	require.NotEmpty(ts.T(), user)
+	require.True(ts.T(), user.IsAnonymous)
+
+	// Verify email change
+	require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]interface{}{
+		"token_hash": user.EmailChangeTokenNew,
+		"type":       "email_change",
+	}))
+
+	req = httptest.NewRequest(http.MethodPost, "/verify", &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))
+
+	// User is a permanent user and not anonymous anymore
+	assert.Equal(ts.T(), signupResponse.User.ID, data.User.ID)
+	assert.Equal(ts.T(), ts.Config.JWT.Aud, data.User.Aud)
+	assert.Equal(ts.T(), "test@example.com", data.User.GetEmail())
+	assert.Equal(ts.T(), models.JSONMap(models.JSONMap{"provider": "email", "providers": []interface{}{"email"}}), data.User.AppMetaData)
+	assert.False(ts.T(), data.User.IsAnonymous)
+	assert.NotEmpty(ts.T(), data.User.EmailConfirmedAt)
+
+	// User should have an email identity
+	assert.Len(ts.T(), data.User.Identities, 1)
+}
+
+func (ts *AnonymousTestSuite) TestRateLimitAnonymousSignups() {
+	var buffer bytes.Buffer
+	ts.Config.External.AnonymousUsers.Enabled = true
+
+	// It rate limits after 30 requests
+	for i := 0; i < int(ts.Config.RateLimitAnonymousUsers); i++ {
+		require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]interface{}{}))
+		req := httptest.NewRequest(http.MethodPost, "http://localhost/signup", &buffer)
+		req.Header.Set("Content-Type", "application/json")
+		req.Header.Set("My-Custom-Header", "1.2.3.4")
+		w := httptest.NewRecorder()
+		ts.API.handler.ServeHTTP(w, req)
+		assert.Equal(ts.T(), http.StatusOK, w.Code)
+	}
+
+	require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]interface{}{}))
+	req := httptest.NewRequest(http.MethodPost, "http://localhost/signup", &buffer)
+	req.Header.Set("Content-Type", "application/json")
+	req.Header.Set("My-Custom-Header", "1.2.3.4")
+	w := httptest.NewRecorder()
+	ts.API.handler.ServeHTTP(w, req)
+	assert.Equal(ts.T(), http.StatusTooManyRequests, w.Code)
+
+	// It ignores X-Forwarded-For by default
+	require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]interface{}{}))
+	req.Header.Set("X-Forwarded-For", "1.1.1.1")
+	w = httptest.NewRecorder()
+	ts.API.handler.ServeHTTP(w, req)
+	assert.Equal(ts.T(), http.StatusTooManyRequests, w.Code)
+
+	// It doesn't rate limit a new value for the limited header
+	req.Header.Set("My-Custom-Header", "5.6.7.8")
+	w = httptest.NewRecorder()
+	ts.API.handler.ServeHTTP(w, req)
+	assert.Equal(ts.T(), http.StatusBadRequest, w.Code)
+}
diff --git a/internal/api/identity_test.go b/internal/api/identity_test.go
index 8238bf6c..a71c612d 100644
--- a/internal/api/identity_test.go
+++ b/internal/api/identity_test.go
@@ -2,6 +2,8 @@ package api
 
 import (
 	"context"
+	"net/http"
+	"net/http/httptest"
 	"testing"
 
 	"github.com/stretchr/testify/require"
@@ -56,7 +58,9 @@ func (ts *IdentityTestSuite) TestLinkIdentityToUser() {
 			Subject: "test_subject",
 		},
 	}
-	u, err = ts.API.linkIdentityToUser(ctx, ts.API.db, testValidUserData, "test")
+	// request is just used as a placeholder in the function
+	r := httptest.NewRequest(http.MethodGet, "/identities", nil)
+	u, err = ts.API.linkIdentityToUser(r, ctx, ts.API.db, testValidUserData, "test")
 	require.NoError(ts.T(), err)
 
 	// load associated identities for the user
@@ -71,7 +75,7 @@ func (ts *IdentityTestSuite) TestLinkIdentityToUser() {
 			Subject: u.ID.String(),
 		},
 	}
-	u, err = ts.API.linkIdentityToUser(ctx, ts.API.db, testExistingUserData, "email")
+	u, err = ts.API.linkIdentityToUser(r, ctx, ts.API.db, testExistingUserData, "email")
 	require.ErrorIs(ts.T(), err, badRequestError("Identity is already linked"))
 	require.Nil(ts.T(), u)
 }
diff --git a/internal/api/mfa_test.go b/internal/api/mfa_test.go
index 6a72c248..df7b1921 100644
--- a/internal/api/mfa_test.go
+++ b/internal/api/mfa_test.go
@@ -5,13 +5,14 @@ import (
 	"context"
 	"encoding/json"
 	"fmt"
-	"github.com/gofrs/uuid"
 	"net/http"
 	"net/http/httptest"
 	"strings"
 	"testing"
 	"time"
 
+	"github.com/gofrs/uuid"
+
 	"github.com/pquerna/otp"
 	"github.com/supabase/auth/internal/conf"
 	"github.com/supabase/auth/internal/models"
@@ -26,14 +27,15 @@ import (
 
 type MFATestSuite struct {
 	suite.Suite
-	API          *API
-	Config       *conf.GlobalConfiguration
-	TestDomain   string
-	TestEmail    string
-	TestOTPKey   *otp.Key
-	TestPassword string
-	TestUser     *models.User
-	TestSession  *models.Session
+	API                  *API
+	Config               *conf.GlobalConfiguration
+	TestDomain           string
+	TestEmail            string
+	TestOTPKey           *otp.Key
+	TestPassword         string
+	TestUser             *models.User
+	TestSession          *models.Session
+	TestSecondarySession *models.Session
 }
 
 func TestMFA(t *testing.T) {
@@ -70,6 +72,12 @@ func (ts *MFATestSuite) SetupTest() {
 	ts.TestUser = u
 	ts.TestSession = s
 
+	secondarySession, err := models.NewSession(ts.TestUser.ID, &f.ID)
+	require.NoError(ts.T(), err, "Error creating test session")
+	require.NoError(ts.T(), ts.API.db.Create(secondarySession), "Error saving test session")
+
+	ts.TestSecondarySession = secondarySession
+
 	// Generate TOTP related settings
 	testDomain := strings.Split(ts.TestEmail, "@")[1]
 	ts.TestDomain = testDomain
@@ -83,7 +91,7 @@ func (ts *MFATestSuite) SetupTest() {
 
 }
 
-func (ts *MFATestSuite) generateToken(user *models.User, sessionId *uuid.UUID) string {
+func (ts *MFATestSuite) generateAAL1Token(user *models.User, sessionId *uuid.UUID) string {
 	token, _, err := ts.API.generateAccessToken(context.Background(), ts.API.db, user, sessionId, models.TOTPSignIn)
 	require.NoError(ts.T(), err, "Error generating access token")
 	return token
@@ -93,8 +101,7 @@ func (ts *MFATestSuite) TestEnrollFactor() {
 	testFriendlyName := "bob"
 	alternativeFriendlyName := "john"
 
-	token, _, err := ts.API.generateAccessToken(context.Background(), ts.API.db, ts.TestUser, nil, models.TOTPSignIn)
-	require.NoError(ts.T(), err)
+	token := ts.generateAAL1Token(ts.TestUser, &ts.TestSession.ID)
 
 	var cases = []struct {
 		desc         string
@@ -134,15 +141,14 @@ func (ts *MFATestSuite) TestEnrollFactor() {
 	}
 	for _, c := range cases {
 		ts.Run(c.desc, func() {
-
 			w := performEnrollFlow(ts, token, c.friendlyName, c.factorType, c.issuer, c.expectedCode)
 
 			factors, err := models.FindFactorsByUser(ts.API.db, ts.TestUser)
 			ts.Require().NoError(err)
-			latestFactor := factors[len(factors)-1]
-			require.False(ts.T(), latestFactor.IsVerified())
+			addedFactor := factors[len(factors)-1]
+			require.False(ts.T(), addedFactor.IsVerified())
 			if c.friendlyName != "" && c.expectedCode == http.StatusOK {
-				require.Equal(ts.T(), c.friendlyName, latestFactor.FriendlyName)
+				require.Equal(ts.T(), c.friendlyName, addedFactor.FriendlyName)
 			}
 			if w.Code == http.StatusOK {
 				enrollResp := EnrollFactorResponse{}
@@ -158,13 +164,13 @@ func (ts *MFATestSuite) TestEnrollFactor() {
 
 func (ts *MFATestSuite) TestDuplicateEnrollsReturnExpectedMessage() {
 	friendlyName := "mary"
-	token, _, err := ts.API.generateAccessToken(context.Background(), ts.API.db, ts.TestUser, nil, models.TOTPSignIn)
-	require.NoError(ts.T(), err)
-	_ = performEnrollFlow(ts, token, friendlyName, models.TOTP, "https://issuer.com", http.StatusOK)
-	response := performEnrollFlow(ts, token, friendlyName, models.TOTP, "https://issuer.com", http.StatusBadRequest)
+	issuer := "https://issuer.com"
+	token := ts.generateAAL1Token(ts.TestUser, &ts.TestSession.ID)
+	_ = performEnrollFlow(ts, token, friendlyName, models.TOTP, issuer, http.StatusOK)
+	response := performEnrollFlow(ts, token, friendlyName, models.TOTP, issuer, http.StatusBadRequest)
 
 	var errorResponse HTTPError
-	err = json.NewDecoder(response.Body).Decode(&errorResponse)
+	err := json.NewDecoder(response.Body).Decode(&errorResponse)
 	require.NoError(ts.T(), err)
 
 	// Convert the response body to a string and check for the expected error message
@@ -175,7 +181,7 @@ func (ts *MFATestSuite) TestDuplicateEnrollsReturnExpectedMessage() {
 
 func (ts *MFATestSuite) TestChallengeFactor() {
 	f := ts.TestUser.Factors[0]
-	token := ts.generateToken(ts.TestUser, nil)
+	token := ts.generateAAL1Token(ts.TestUser, &ts.TestSession.ID)
 	w := performChallengeFlow(ts, f.ID, token)
 	require.Equal(ts.T(), http.StatusOK, w.Code)
 }
@@ -209,7 +215,6 @@ func (ts *MFATestSuite) TestMFAVerifyFactor() {
 	for _, v := range cases {
 		ts.Run(v.desc, func() {
 			// Authenticate users and set secret
-
 			var buffer bytes.Buffer
 			r, err := models.GrantAuthenticatedUser(ts.API.db, ts.TestUser, models.GrantParams{})
 			require.NoError(ts.T(), err)
@@ -220,12 +225,7 @@ func (ts *MFATestSuite) TestMFAVerifyFactor() {
 			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(ts.TestUser.ID, &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 := ts.generateToken(ts.TestUser, r.SessionId)
+			token := ts.generateAAL1Token(ts.TestUser, r.SessionId)
 
 			w := httptest.NewRecorder()
 			req := httptest.NewRequest(http.MethodPost, fmt.Sprintf("/factors/%s/verify", f.ID), &buffer)
@@ -258,7 +258,7 @@ func (ts *MFATestSuite) TestMFAVerifyFactor() {
 
 			if v.expectedHTTPCode == http.StatusOK {
 				// Ensure alternate session has been deleted
-				_, err = models.FindSessionByID(ts.API.db, secondarySession.ID, false)
+				_, err = models.FindSessionByID(ts.API.db, ts.TestSecondarySession.ID, false)
 				require.EqualError(ts.T(), err, models.SessionNotFoundError{}.Error())
 			}
 			if !v.validChallenge {
@@ -271,7 +271,6 @@ func (ts *MFATestSuite) TestMFAVerifyFactor() {
 }
 
 func (ts *MFATestSuite) TestUnenrollVerifiedFactor() {
-
 	cases := []struct {
 		desc             string
 		isAAL2           bool
@@ -290,29 +289,20 @@ func (ts *MFATestSuite) TestUnenrollVerifiedFactor() {
 	}
 	for _, v := range cases {
 		ts.Run(v.desc, func() {
-			// Create User
+			var buffer bytes.Buffer
 			if v.isAAL2 {
 				ts.TestSession.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, ts.TestUser)
 			require.NoError(ts.T(), err, "error finding factors")
 			f := factors[0]
-			secondarySession, err = models.NewSession(ts.TestUser.ID, &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)
+			f.Secret = ts.TestOTPKey.Secret()
+			require.NoError(ts.T(), f.UpdateStatus(ts.API.db, models.FactorStateVerified))
 			require.NoError(ts.T(), ts.API.db.Update(f), "Error updating new test factor")
 
-			var buffer bytes.Buffer
-
-			token := ts.generateToken(ts.TestUser, &ts.TestSession.ID)
+			token := ts.generateAAL1Token(ts.TestUser, &ts.TestSession.ID)
 
 			w := httptest.NewRecorder()
 			req := httptest.NewRequest(http.MethodDelete, fmt.Sprintf("/factors/%s/", f.ID), &buffer)
@@ -323,7 +313,7 @@ func (ts *MFATestSuite) TestUnenrollVerifiedFactor() {
 			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, false)
+				session, _ := models.FindSessionByID(ts.API.db, ts.TestSecondarySession.ID, false)
 				require.Equal(ts.T(), models.AAL1.String(), session.GetAAL())
 				require.Nil(ts.T(), session.FactorID)
 
@@ -334,19 +324,11 @@ func (ts *MFATestSuite) TestUnenrollVerifiedFactor() {
 }
 
 func (ts *MFATestSuite) TestUnenrollUnverifiedFactor() {
-	var secondarySession *models.Session
-	f := ts.TestUser.Factors[0]
-	secondarySession, err := models.NewSession(ts.TestUser.ID, &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
+	f := ts.TestUser.Factors[0]
+	f.Secret = ts.TestOTPKey.Secret()
 
-	token := ts.generateToken(ts.TestUser, &ts.TestSession.ID)
-	require.NoError(ts.T(), err)
+	token := ts.generateAAL1Token(ts.TestUser, &ts.TestSession.ID)
 	require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]interface{}{
 		"factor_id": f.ID,
 	}))
@@ -356,9 +338,10 @@ func (ts *MFATestSuite) TestUnenrollUnverifiedFactor() {
 	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)
+
+	_, err := models.FindFactorByFactorID(ts.API.db, f.ID)
 	require.EqualError(ts.T(), err, models.FactorNotFoundError{}.Error())
-	session, _ := models.FindSessionByID(ts.API.db, secondarySession.ID, false)
+	session, _ := models.FindSessionByID(ts.API.db, ts.TestSecondarySession.ID, false)
 	require.Equal(ts.T(), models.AAL1.String(), session.GetAAL())
 	require.Nil(ts.T(), session.FactorID)
 
@@ -366,11 +349,11 @@ func (ts *MFATestSuite) TestUnenrollUnverifiedFactor() {
 
 // Integration Tests
 func (ts *MFATestSuite) TestSessionsMaintainAALOnRefresh() {
+	ts.Config.Security.RefreshTokenRotationEnabled = true
 	resp := performTestSignupAndVerify(ts, ts.TestEmail, ts.TestPassword, true /* <- requireStatusOK */)
 	accessTokenResp := &AccessTokenResponse{}
 	require.NoError(ts.T(), json.NewDecoder(resp.Body).Decode(&accessTokenResp))
 
-	ts.Config.Security.RefreshTokenRotationEnabled = true
 	var buffer bytes.Buffer
 	require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]interface{}{
 		"refresh_token": accessTokenResp.RefreshToken,
@@ -394,11 +377,11 @@ func (ts *MFATestSuite) TestSessionsMaintainAALOnRefresh() {
 
 // Performing MFA Verification followed by a sign in should return an AAL1 session and an AAL2 session
 func (ts *MFATestSuite) TestMFAFollowedByPasswordSignIn() {
+	ts.Config.Security.RefreshTokenRotationEnabled = true
 	resp := performTestSignupAndVerify(ts, ts.TestEmail, ts.TestPassword, true /* <- requireStatusOK */)
 	accessTokenResp := &AccessTokenResponse{}
 	require.NoError(ts.T(), json.NewDecoder(resp.Body).Decode(&accessTokenResp))
 
-	ts.Config.Security.RefreshTokenRotationEnabled = true
 	var buffer bytes.Buffer
 	require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]interface{}{
 		"email":    ts.TestEmail,
@@ -414,8 +397,10 @@ func (ts *MFATestSuite) TestMFAFollowedByPasswordSignIn() {
 	require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&data))
 	ctx, err := ts.API.parseJWTClaims(data.Token, req)
 	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).GetAAL())
 	session, err := models.FindSessionByUserID(ts.API.db, accessTokenResp.User.ID)
 	require.NoError(ts.T(), err)
@@ -423,6 +408,7 @@ func (ts *MFATestSuite) TestMFAFollowedByPasswordSignIn() {
 }
 
 func signUp(ts *MFATestSuite, email, password string) (signUpResp AccessTokenResponse) {
+	ts.API.config.Mailer.Autoconfirm = true
 	var buffer bytes.Buffer
 
 	require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]interface{}{
@@ -433,7 +419,6 @@ func signUp(ts *MFATestSuite, email, password string) (signUpResp AccessTokenRes
 	// 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)
@@ -444,7 +429,7 @@ func signUp(ts *MFATestSuite, email, password string) (signUpResp AccessTokenRes
 
 func performTestSignupAndVerify(ts *MFATestSuite, email, password string, requireStatusOK bool) *httptest.ResponseRecorder {
 	signUpResp := signUp(ts, email, password)
-	resp := performEnrollAndVerify(ts, signUpResp.User, signUpResp.Token, requireStatusOK)
+	resp := performEnrollAndVerify(ts, signUpResp.Token, requireStatusOK)
 
 	return resp
 
@@ -464,7 +449,7 @@ func performEnrollFlow(ts *MFATestSuite, token, friendlyName, factorType, issuer
 	return w
 }
 
-func performVerifyFlow(ts *MFATestSuite, challengeID, factorID uuid.UUID, token string, expectedCode int, requireStatusOK bool) *httptest.ResponseRecorder {
+func performVerifyFlow(ts *MFATestSuite, challengeID, factorID uuid.UUID, token string, requireStatusOK bool) *httptest.ResponseRecorder {
 	var verifyBuffer bytes.Buffer
 	y := httptest.NewRecorder()
 
@@ -507,7 +492,7 @@ func performChallengeFlow(ts *MFATestSuite, factorID uuid.UUID, token string) *h
 
 }
 
-func performEnrollAndVerify(ts *MFATestSuite, user *models.User, token string, requireStatusOK bool) *httptest.ResponseRecorder {
+func performEnrollAndVerify(ts *MFATestSuite, token string, requireStatusOK bool) *httptest.ResponseRecorder {
 	w := performEnrollFlow(ts, token, "", models.TOTP, ts.TestDomain, http.StatusOK)
 	enrollResp := EnrollFactorResponse{}
 	require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&enrollResp))
@@ -521,7 +506,7 @@ func performEnrollAndVerify(ts *MFATestSuite, user *models.User, token string, r
 	challengeID := challengeResp.ID
 
 	// Verify
-	y := performVerifyFlow(ts, challengeID, factorID, token, http.StatusOK, requireStatusOK)
+	y := performVerifyFlow(ts, challengeID, factorID, token, requireStatusOK)
 
 	return y
 }
diff --git a/internal/api/phone_test.go b/internal/api/phone_test.go
index 911cb2c7..3c543634 100644
--- a/internal/api/phone_test.go
+++ b/internal/api/phone_test.go
@@ -72,7 +72,6 @@ func (ts *PhoneTestSuite) TestFormatPhoneNumber() {
 func doTestSendPhoneConfirmation(ts *PhoneTestSuite, useTestOTP bool) {
 	u, err := models.FindUserByPhoneAndAudience(ts.API.db, "123456789", ts.Config.JWT.Aud)
 	require.NoError(ts.T(), err)
-	ctx := context.Background()
 	cases := []struct {
 		desc     string
 		otpType  string
@@ -112,7 +111,7 @@ func doTestSendPhoneConfirmation(ts *PhoneTestSuite, useTestOTP bool) {
 		ts.Run(c.desc, func() {
 			provider := &TestSmsProvider{}
 
-			_, err = ts.API.sendPhoneConfirmation(ctx, ts.API.db, u, "123456789", c.otpType, provider, sms_provider.SMSProvider)
+			_, err = ts.API.sendPhoneConfirmation(ts.API.db, u, "123456789", c.otpType, provider, sms_provider.SMSProvider)
 			require.Equal(ts.T(), c.expected, err)
 			u, err = models.FindUserByPhoneAndAudience(ts.API.db, "123456789", ts.Config.JWT.Aud)
 			require.NoError(ts.T(), err)
diff --git a/internal/api/verify_test.go b/internal/api/verify_test.go
index 1cdb43ba..51118f35 100644
--- a/internal/api/verify_test.go
+++ b/internal/api/verify_test.go
@@ -1101,9 +1101,8 @@ func (ts *VerifyTestSuite) TestPrepErrorRedirectURL() {
 	}
 	for _, c := range cases {
 		ts.Run(c.desc, func() {
-			w := httptest.NewRecorder()
 			req := httptest.NewRequest(http.MethodGet, "http://localhost", nil)
-			rurl, err := ts.API.prepErrorRedirectURL(badRequestError(DefaultError), w, req, c.rurl, c.flowType)
+			rurl, err := ts.API.prepErrorRedirectURL(badRequestError(DefaultError), req, c.rurl, c.flowType)
 			require.NoError(ts.T(), err)
 			require.Equal(ts.T(), c.expected, rurl)
 		})
diff --git a/internal/models/sessions_test.go b/internal/models/sessions_test.go
index 438b071a..158c4de7 100644
--- a/internal/models/sessions_test.go
+++ b/internal/models/sessions_test.go
@@ -53,39 +53,40 @@ func (ts *SessionsTestSuite) TestFindBySessionIDWithForUpdate() {
 	require.Equal(ts.T(), session.ID, found.ID)
 }
 
+func (ts *SessionsTestSuite) AddClaimAndReloadSession(session *Session, claim AuthenticationMethod) *Session {
+	err := AddClaimToSession(ts.db, session.ID, claim)
+	require.NoError(ts.T(), err)
+	session, err = FindSessionByID(ts.db, session.ID, false)
+	require.NoError(ts.T(), err)
+	return session
+}
+
 func (ts *SessionsTestSuite) TestCalculateAALAndAMR() {
-	totalDistinctClaims := 2
+	totalDistinctClaims := 3
 	u, err := FindUserByEmailAndAudience(ts.db, "test@example.com", ts.Config.JWT.Aud)
 	require.NoError(ts.T(), err)
 	session, err := NewSession(u.ID, nil)
 	require.NoError(ts.T(), err)
 	require.NoError(ts.T(), ts.db.Create(session))
 
-	err = AddClaimToSession(ts.db, session.ID, PasswordGrant)
-	require.NoError(ts.T(), err)
+	session = ts.AddClaimAndReloadSession(session, PasswordGrant)
 
 	firstClaimAddedTime := time.Now()
-	err = AddClaimToSession(ts.db, session.ID, TOTPSignIn)
-	require.NoError(ts.T(), err)
-	session, err = FindSessionByID(ts.db, session.ID, false)
-	require.NoError(ts.T(), err)
+	session = ts.AddClaimAndReloadSession(session, TOTPSignIn)
 
-	aal, amr, err := session.CalculateAALAndAMR(u)
+	_, _, err = session.CalculateAALAndAMR(u)
 	require.NoError(ts.T(), err)
-	require.Equal(ts.T(), AAL2.String(), aal)
-	require.Equal(ts.T(), totalDistinctClaims, len(amr))
 
-	err = AddClaimToSession(ts.db, session.ID, TOTPSignIn)
-	require.NoError(ts.T(), err)
+	session = ts.AddClaimAndReloadSession(session, TOTPSignIn)
 
-	session, err = FindSessionByID(ts.db, session.ID, false)
-	require.NoError(ts.T(), err)
+	session = ts.AddClaimAndReloadSession(session, SSOSAML)
 
-	aal, amr, err = session.CalculateAALAndAMR(u)
+	aal, amr, err := session.CalculateAALAndAMR(u)
 	require.NoError(ts.T(), err)
 
 	require.Equal(ts.T(), AAL2.String(), aal)
 	require.Equal(ts.T(), totalDistinctClaims, len(amr))
+
 	found := false
 	for _, claim := range session.AMRClaims {
 		if claim.GetAuthenticationMethod() == TOTPSignIn.String() {
@@ -93,5 +94,11 @@ func (ts *SessionsTestSuite) TestCalculateAALAndAMR() {
 			found = true
 		}
 	}
+
+	for _, claim := range amr {
+		if claim.Method == SSOSAML.String() {
+			require.NotNil(ts.T(), claim.Provider)
+		}
+	}
 	require.True(ts.T(), found)
 }
diff --git a/internal/models/user_test.go b/internal/models/user_test.go
index 4371a690..3b343860 100644
--- a/internal/models/user_test.go
+++ b/internal/models/user_test.go
@@ -325,14 +325,14 @@ func (ts *UserTestSuite) TestUpdateUserEmailSuccess() {
 	require.NoError(ts.T(), ts.db.Create(secondaryIdentity))
 
 	// UpdateUserEmail should not do anything and the user's email should still use the primaryIdentity
-	require.NoError(ts.T(), userA.UpdateUserEmail(ts.db))
+	require.NoError(ts.T(), userA.UpdateUserEmailFromIdentities(ts.db))
 	require.Equal(ts.T(), primaryIdentity.GetEmail(), userA.GetEmail())
 
 	// remove primary identity
 	require.NoError(ts.T(), ts.db.Destroy(primaryIdentity))
 
 	// UpdateUserEmail should update the user to use the secondary identity's email
-	require.NoError(ts.T(), userA.UpdateUserEmail(ts.db))
+	require.NoError(ts.T(), userA.UpdateUserEmailFromIdentities(ts.db))
 	require.Equal(ts.T(), secondaryIdentity.GetEmail(), userA.GetEmail())
 }
 
@@ -364,7 +364,7 @@ func (ts *UserTestSuite) TestUpdateUserEmailFailure() {
 
 	// UpdateUserEmail should fail with the email unique constraint violation error
 	//  since userB is using the secondary identity's email
-	require.ErrorIs(ts.T(), userA.UpdateUserEmail(ts.db), UserEmailUniqueConflictError{})
+	require.ErrorIs(ts.T(), userA.UpdateUserEmailFromIdentities(ts.db), UserEmailUniqueConflictError{})
 	require.Equal(ts.T(), primaryIdentity.GetEmail(), userA.GetEmail())
 }