Repository: supabase/gotrue
Pull request #1460 (2024-03-01 06:52:11)

feat: anonymous sign-ins 
## What kind of change does this PR introduce?
* Implements #68 
* An anonymous user is defined as a user that doesn't have an email or phone in the `auth.users` table. This is tracked by using a generated column called `auth.users.is_anonymous`
* When an anonymous user signs-in, the JWT payload will contain an `is_anonymous` claim which can be used in RLS policies as mentioned in [Option 3](https://github.com/supabase/gotrue/issues/68#issuecomment-1836671954). 
```json
{
  ...
  "is_anonymous": true
}
```
* Allows anonymous sign-ins if `GOTRUE_EXTERNAL_ANONYMOUS_USERS_ENABLED` is enabled
* Anonymous sign-ins are rate limited on a per hourly basis and controlled by `GOTRUE_RATE_LIMIT_ANONYMOUS_USERS`. This is an ip-based rate limit. 
* You can also configure silent captcha / turnstile to prevent abuse
* There are 2 ways to upgrade an anonymous user to a permanent user:
  1. Link an email / phone identity to an anonymous user `PUT /user` 
  2. Link an oauth identity using `GET /user/identities/authorize?provider=xxx`

## Example
```bash
# Sign in as an anonymous user
curl -X POST 'http://localhost:9999/signup' \
-H 'Content-Type: application/json' \
-d '{}'

# Upgrade an anonymous user to a permanent user with an email identity
curl -X PUT 'http://localhost:9999/user' \
-H 'Content-Type: application/json' \
-H 'Authorization: Bearer <access_token_of_anonymous_user>' \
-d '{"email": "user@example.com"}'

# Upgrade an anonymous to a permanent user with an oauth identity
curl -X GET 'http://localhost:9999/user/identities/authorize?provider=google' \
-H 'Authorization: Bearer <access_token_of_anonymous_user>
```

## Follow-ups
* Cleanup logic for anonymous users will be made in a separate PR


Patch:
diff --git a/internal/api/anonymous.go b/internal/api/anonymous.go
new file mode 100644
index 00000000..a3dfa93b
--- /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(ctx, 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..2c5a9a72 100644
--- a/internal/api/api.go
+++ b/internal/api/api.go
@@ -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..dc40391c 100644
--- a/internal/api/auth.go
+++ b/internal/api/auth.go
@@ -35,6 +35,15 @@ func (a *API) requireAuthentication(w http.ResponseWriter, r *http.Request) (con
 	return ctx, err
 }
 
+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, w http.ResponseWriter, r *http.Request) (context.Context, error) {
 	// Find the administrative user
 	claims := getClaims(ctx)
diff --git a/internal/api/external.go b/internal/api/external.go
index a47d201d..919cced7 100644
--- a/internal/api/external.go
+++ b/internal/api/external.go
@@ -213,7 +213,7 @@ 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 != "" {
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/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..c2e5c258 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)
 			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 {
@@ -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/user.go b/internal/api/user.go
index 73991b46..34a68703 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
 
diff --git a/internal/api/verify.go b/internal/api/verify.go
index 7ad5f127..3e99dc9a 100644
--- a/internal/api/verify.go
+++ b/internal/api/verify.go
@@ -177,6 +177,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)
 
@@ -268,6 +278,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
@@ -511,6 +531,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)
 		}
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/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/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())
 }