Repository: supabase/gotrue
Pull request #999 (2023-03-20 08:20:11)

fix: allow gotrue to work with multiple custom domains
## What kind of change does this PR introduce?
* Improves on #725, albeit with a slightly different approach
* Gotrue will accept an allow list of domains via a comma-separate string (`DOMAIN_ALLOW_LIST`) , which includes the `API_EXTERNAL_URL` by default. On each request, gotrue will check that the domain being used is also included in the allow list. 
* When gotrue starts up, it will take the `DOMAIN_ALLOW_LIST` and convert it into a map where the key is the hostname and the value is the url
* When a request is made to gotrue, gotrue will check the `DomainAllowListMap` to check if there is a matching hostname before allowing the request through. If there isn't a matching hostname used, gotrue will default to use the `API_EXTERNAL_URL` instead. 
* This helps to make gotrue usable with multiple custom domains, and also allows the email links to contain the custom domain. 
* Since the `EXTERNAL_XXX_REDIRECT_URI` is derived during runtime, we can remove that config once this PR is merged in as long as the `REDIRECT_URI` is also included in the `DOMAIN_ALLOW_LIST` 

J0: Thanks for your patience - generally LGTM as well

Patch:
diff --git a/example.env b/example.env
index b798478a..5ad051c5 100644
--- a/example.env
+++ b/example.env
@@ -96,6 +96,12 @@ GOTRUE_EXTERNAL_GITHUB_CLIENT_ID=""
 GOTRUE_EXTERNAL_GITHUB_SECRET=""
 GOTRUE_EXTERNAL_GITHUB_REDIRECT_URI="http://localhost:9999/callback"
 
+# Kakao OAuth config
+GOTRUE_EXTERNAL_KAKAO_ENABLED="false"
+GOTRUE_EXTERNAL_KAKAO_CLIENT_ID=""
+GOTRUE_EXTERNAL_KAKAO_SECRET=""
+GOTRUE_EXTERNAL_KAKAO_REDIRECT_URI="http://localhost:9999/callback"
+
 # Facebook OAuth config
 GOTRUE_EXTERNAL_FACEBOOK_ENABLED="false"
 GOTRUE_EXTERNAL_FACEBOOK_CLIENT_ID=""
diff --git a/internal/api/api.go b/internal/api/api.go
index 77bd2759..ca177a76 100644
--- a/internal/api/api.go
+++ b/internal/api/api.go
@@ -81,10 +81,15 @@ func NewAPIWithVersion(ctx context.Context, globalConfig *conf.GlobalConfigurati
 	r.UseBypass(xffmw.Handler)
 	r.Use(recoverer)
 
+	if globalConfig.DB.CleanupEnabled {
+		r.UseBypass(api.databaseCleanup)
+	}
+
 	r.Get("/health", api.HealthCheck)
 
 	r.Route("/callback", func(r *router) {
 		r.UseBypass(logger)
+		r.Use(api.isValidExternalHost)
 		r.Use(api.loadFlowState)
 
 		r.Get("/", api.ExternalProviderCallback)
@@ -93,6 +98,7 @@ func NewAPIWithVersion(ctx context.Context, globalConfig *conf.GlobalConfigurati
 
 	r.Route("/", func(r *router) {
 		r.UseBypass(logger)
+		r.Use(api.isValidExternalHost)
 
 		r.Get("/settings", api.Settings)
 
diff --git a/internal/api/context.go b/internal/api/context.go
index cb500402..107377b5 100644
--- a/internal/api/context.go
+++ b/internal/api/context.go
@@ -2,6 +2,7 @@ package api
 
 import (
 	"context"
+	"net/url"
 
 	jwt "github.com/golang-jwt/jwt"
 	"github.com/supabase/gotrue/internal/models"
@@ -28,6 +29,7 @@ const (
 	oauthTokenKey           = contextKey("oauth_token") // for OAuth1.0, also known as request token
 	oauthVerifierKey        = contextKey("oauth_verifier")
 	ssoProviderKey          = contextKey("sso_provider")
+	externalHostKey         = contextKey("external_host")
 	flowStateKey            = contextKey("flow_state_id")
 )
 
@@ -235,3 +237,15 @@ func getSSOProvider(ctx context.Context) *models.SSOProvider {
 	}
 	return obj.(*models.SSOProvider)
 }
+
+func withExternalHost(ctx context.Context, u *url.URL) context.Context {
+	return context.WithValue(ctx, externalHostKey, u)
+}
+
+func getExternalHost(ctx context.Context) *url.URL {
+	obj := ctx.Value(externalHostKey)
+	if obj == nil {
+		return nil
+	}
+	return obj.(*url.URL)
+}
diff --git a/internal/api/external.go b/internal/api/external.go
index 4351d994..5bcb376d 100644
--- a/internal/api/external.go
+++ b/internal/api/external.go
@@ -377,7 +377,8 @@ func (a *API) createAccountFromExternalIdentity(tx *storage.Connection, r *http.
 		if !emailData.Verified && !config.Mailer.Autoconfirm {
 			mailer := a.Mailer(ctx)
 			referrer := a.getReferrer(r)
-			if terr = sendConfirmation(tx, user, mailer, config.SMTP.MaxFrequency, referrer, config.Mailer.OtpLength, models.ImplicitFlow); terr != nil {
+			externalURL := getExternalHost(ctx)
+			if terr = sendConfirmation(tx, user, mailer, config.SMTP.MaxFrequency, referrer, externalURL, 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")
 				}
@@ -510,41 +511,61 @@ func (a *API) loadExternalState(ctx context.Context, state string) (context.Cont
 func (a *API) Provider(ctx context.Context, name string, scopes string) (provider.Provider, error) {
 	config := a.config
 	name = strings.ToLower(name)
+	callbackURL := getExternalHost(ctx).String() + "/callback"
 
 	switch name {
 	case "apple":
+		config.External.Apple.RedirectURI = callbackURL
 		return provider.NewAppleProvider(config.External.Apple)
 	case "azure":
+		config.External.Azure.RedirectURI = callbackURL
 		return provider.NewAzureProvider(config.External.Azure, scopes)
 	case "bitbucket":
+		config.External.Bitbucket.RedirectURI = callbackURL
 		return provider.NewBitbucketProvider(config.External.Bitbucket)
 	case "discord":
+		config.External.Discord.RedirectURI = callbackURL
 		return provider.NewDiscordProvider(config.External.Discord, scopes)
 	case "github":
+		config.External.Github.RedirectURI = callbackURL
 		return provider.NewGithubProvider(config.External.Github, scopes)
 	case "gitlab":
+		config.External.Gitlab.RedirectURI = callbackURL
 		return provider.NewGitlabProvider(config.External.Gitlab, scopes)
 	case "google":
+		config.External.Google.RedirectURI = callbackURL
 		return provider.NewGoogleProvider(config.External.Google, scopes)
+	case "kakao":
+		return provider.NewKakaoProvider(config.External.Kakao, scopes)
 	case "keycloak":
+		config.External.Keycloak.RedirectURI = callbackURL
 		return provider.NewKeycloakProvider(config.External.Keycloak, scopes)
 	case "linkedin":
+		config.External.Linkedin.RedirectURI = callbackURL
 		return provider.NewLinkedinProvider(config.External.Linkedin, scopes)
 	case "facebook":
+		config.External.Facebook.RedirectURI = callbackURL
 		return provider.NewFacebookProvider(config.External.Facebook, scopes)
 	case "notion":
+		config.External.Notion.RedirectURI = callbackURL
 		return provider.NewNotionProvider(config.External.Notion)
 	case "spotify":
+		config.External.Spotify.RedirectURI = callbackURL
 		return provider.NewSpotifyProvider(config.External.Spotify, scopes)
 	case "slack":
+		config.External.Slack.RedirectURI = callbackURL
 		return provider.NewSlackProvider(config.External.Slack, scopes)
 	case "twitch":
+		config.External.Twitch.RedirectURI = callbackURL
 		return provider.NewTwitchProvider(config.External.Twitch, scopes)
 	case "twitter":
+		config.External.Twitter.RedirectURI = callbackURL
 		return provider.NewTwitterProvider(config.External.Twitter, scopes)
 	case "workos":
+		config.External.WorkOS.RedirectURI = callbackURL
 		return provider.NewWorkOSProvider(config.External.WorkOS)
 	case "zoom":
+		config.External.Zoom.RedirectURI = callbackURL
 		return provider.NewZoomProvider(config.External.Zoom)
 	default:
 		return nil, fmt.Errorf("Provider %s could not be found", name)
diff --git a/internal/api/invite.go b/internal/api/invite.go
index 85a651d1..0d9d054a 100644
--- a/internal/api/invite.go
+++ b/internal/api/invite.go
@@ -79,7 +79,8 @@ func (a *API) Invite(w http.ResponseWriter, r *http.Request) error {
 
 		mailer := a.Mailer(ctx)
 		referrer := a.getReferrer(r)
-		if err := sendInvite(tx, user, mailer, referrer, config.Mailer.OtpLength); err != nil {
+		externalURL := getExternalHost(ctx)
+		if err := sendInvite(tx, user, mailer, referrer, externalURL, config.Mailer.OtpLength); err != nil {
 			return internalServerError("Error inviting user").WithInternalError(err)
 		}
 		return nil
diff --git a/internal/api/magic_link.go b/internal/api/magic_link.go
index fc7b9fae..ad217b19 100644
--- a/internal/api/magic_link.go
+++ b/internal/api/magic_link.go
@@ -142,7 +142,8 @@ func (a *API) MagicLink(w http.ResponseWriter, r *http.Request) error {
 
 		mailer := a.Mailer(ctx)
 		referrer := a.getReferrer(r)
-		return a.sendMagicLink(tx, user, mailer, config.SMTP.MaxFrequency, referrer, config.Mailer.OtpLength, flowType)
+		externalURL := getExternalHost(ctx)
+		return a.sendMagicLink(tx, user, mailer, config.SMTP.MaxFrequency, referrer, externalURL, config.Mailer.OtpLength, flowType)
 	})
 	if err != nil {
 		if errors.Is(err, MaxFrequencyLimitError) {
diff --git a/internal/api/mail.go b/internal/api/mail.go
index 73f5b834..d1550784 100644
--- a/internal/api/mail.go
+++ b/internal/api/mail.go
@@ -5,6 +5,7 @@ import (
 	"encoding/json"
 	"fmt"
 	"net/http"
+	"net/url"
 	"strings"
 	"time"
 
@@ -205,7 +206,8 @@ func (a *API) GenerateLink(w http.ResponseWriter, r *http.Request) error {
 			return terr
 		}
 
-		url, terr = mailer.GetEmailActionLink(user, params.Type, referrer)
+		externalURL := getExternalHost(ctx)
+		url, terr = mailer.GetEmailActionLink(user, params.Type, referrer, externalURL)
 		if terr != nil {
 			return terr
 		}
@@ -228,7 +230,7 @@ func (a *API) GenerateLink(w http.ResponseWriter, r *http.Request) error {
 	return sendJSON(w, http.StatusOK, resp)
 }
 
-func sendConfirmation(tx *storage.Connection, u *models.User, mailer mailer.Mailer, maxFrequency time.Duration, referrerURL string, otpLength int, flowType models.FlowType) error {
+func sendConfirmation(tx *storage.Connection, u *models.User, mailer mailer.Mailer, maxFrequency time.Duration, referrerURL string, externalURL *url.URL, otpLength int, flowType models.FlowType) error {
 	var err error
 	if u.ConfirmationSentAt != nil && !u.ConfirmationSentAt.Add(maxFrequency).Before(time.Now()) {
 		return MaxFrequencyLimitError
@@ -241,7 +243,7 @@ func sendConfirmation(tx *storage.Connection, u *models.User, mailer mailer.Mail
 	token := fmt.Sprintf("%x", sha256.Sum224([]byte(u.GetEmail()+otp)))
 	u.ConfirmationToken = addFlowPrefixToToken(token, flowType)
 	now := time.Now()
-	if err := mailer.ConfirmationMail(u, otp, referrerURL); err != nil {
+	if err := mailer.ConfirmationMail(u, otp, referrerURL, externalURL); err != nil {
 		u.ConfirmationToken = oldToken
 		return errors.Wrap(err, "Error sending confirmation email")
 	}
@@ -249,7 +251,7 @@ func sendConfirmation(tx *storage.Connection, u *models.User, mailer mailer.Mail
 	return errors.Wrap(tx.UpdateOnly(u, "confirmation_token", "confirmation_sent_at"), "Database error updating user for confirmation")
 }
 
-func sendInvite(tx *storage.Connection, u *models.User, mailer mailer.Mailer, referrerURL string, otpLength int) error {
+func sendInvite(tx *storage.Connection, u *models.User, mailer mailer.Mailer, referrerURL string, externalURL *url.URL, otpLength int) error {
 	var err error
 	oldToken := u.ConfirmationToken
 	otp, err := crypto.GenerateOtp(otpLength)
@@ -258,7 +260,7 @@ func sendInvite(tx *storage.Connection, u *models.User, mailer mailer.Mailer, re
 	}
 	u.ConfirmationToken = fmt.Sprintf("%x", sha256.Sum224([]byte(u.GetEmail()+otp)))
 	now := time.Now()
-	if err := mailer.InviteMail(u, otp, referrerURL); err != nil {
+	if err := mailer.InviteMail(u, otp, referrerURL, externalURL); err != nil {
 		u.ConfirmationToken = oldToken
 		return errors.Wrap(err, "Error sending invite email")
 	}
@@ -267,7 +269,7 @@ func sendInvite(tx *storage.Connection, u *models.User, mailer mailer.Mailer, re
 	return errors.Wrap(tx.UpdateOnly(u, "confirmation_token", "confirmation_sent_at", "invited_at"), "Database error updating user for invite")
 }
 
-func (a *API) sendPasswordRecovery(tx *storage.Connection, u *models.User, mailer mailer.Mailer, maxFrequency time.Duration, referrerURL string, otpLength int, flowType models.FlowType) error {
+func (a *API) sendPasswordRecovery(tx *storage.Connection, u *models.User, mailer mailer.Mailer, maxFrequency time.Duration, referrerURL string, externalURL *url.URL, otpLength int, flowType models.FlowType) error {
 	var err error
 	if u.RecoverySentAt != nil && !u.RecoverySentAt.Add(maxFrequency).Before(time.Now()) {
 		return MaxFrequencyLimitError
@@ -281,7 +283,7 @@ func (a *API) sendPasswordRecovery(tx *storage.Connection, u *models.User, maile
 	token := fmt.Sprintf("%x", sha256.Sum224([]byte(u.GetEmail()+otp)))
 	u.RecoveryToken = addFlowPrefixToToken(token, flowType)
 	now := time.Now()
-	if err := mailer.RecoveryMail(u, otp, referrerURL); err != nil {
+	if err := mailer.RecoveryMail(u, otp, referrerURL, externalURL); err != nil {
 		u.RecoveryToken = oldToken
 		return errors.Wrap(err, "Error sending recovery email")
 	}
@@ -313,7 +315,7 @@ func (a *API) sendReauthenticationOtp(tx *storage.Connection, u *models.User, ma
 	return errors.Wrap(tx.UpdateOnly(u, "reauthentication_token", "reauthentication_sent_at"), "Database error updating user for reauthentication")
 }
 
-func (a *API) sendMagicLink(tx *storage.Connection, u *models.User, mailer mailer.Mailer, maxFrequency time.Duration, referrerURL string, otpLength int, flowType models.FlowType) error {
+func (a *API) sendMagicLink(tx *storage.Connection, u *models.User, mailer mailer.Mailer, maxFrequency time.Duration, referrerURL string, externalURL *url.URL, otpLength int, flowType models.FlowType) error {
 	var err error
 	// since Magic Link is just a recovery with a different template and behaviour
 	// around new users we will reuse the recovery db timer to prevent potential abuse
@@ -329,7 +331,7 @@ func (a *API) sendMagicLink(tx *storage.Connection, u *models.User, mailer maile
 	u.RecoveryToken = addFlowPrefixToToken(token, flowType)
 
 	now := time.Now()
-	if err := mailer.MagicLinkMail(u, otp, referrerURL); err != nil {
+	if err := mailer.MagicLinkMail(u, otp, referrerURL, externalURL); err != nil {
 		u.RecoveryToken = oldToken
 		return errors.Wrap(err, "Error sending magic link email")
 	}
@@ -338,7 +340,7 @@ func (a *API) sendMagicLink(tx *storage.Connection, u *models.User, mailer maile
 }
 
 // sendEmailChange sends out an email change token to the new email.
-func (a *API) sendEmailChange(tx *storage.Connection, config *conf.GlobalConfiguration, u *models.User, mailer mailer.Mailer, email string, referrerURL string, otpLength int, flowType models.FlowType) error {
+func (a *API) sendEmailChange(tx *storage.Connection, config *conf.GlobalConfiguration, u *models.User, mailer mailer.Mailer, email, referrerURL string, externalURL *url.URL, otpLength int, flowType models.FlowType) error {
 	var err error
 	if u.EmailChangeSentAt != nil && !u.EmailChangeSentAt.Add(config.SMTP.MaxFrequency).Before(time.Now()) {
 		return MaxFrequencyLimitError
@@ -366,7 +368,7 @@ func (a *API) sendEmailChange(tx *storage.Connection, config *conf.GlobalConfigu
 
 	u.EmailChangeConfirmStatus = zeroConfirmation
 	now := time.Now()
-	if err := mailer.EmailChangeMail(u, otpNew, otpCurrent, referrerURL); err != nil {
+	if err := mailer.EmailChangeMail(u, otpNew, otpCurrent, referrerURL, externalURL); err != nil {
 		return err
 	}
 
diff --git a/internal/api/mfa.go b/internal/api/mfa.go
index dc555aee..ab717862 100644
--- a/internal/api/mfa.go
+++ b/internal/api/mfa.go
@@ -300,6 +300,9 @@ func (a *API) UnenrollFactor(w http.ResponseWriter, r *http.Request) error {
 	user := getUser(ctx)
 	factor := getFactor(ctx)
 	session := getSession(ctx)
+	if factor == nil || session == nil || user == nil {
+		return internalServerError("A valid session and factor are required to unenroll a factor")
+	}
 
 	if factor.IsVerified() && !session.IsAAL2() {
 		return badRequestError("AAL2 required to unenroll verified factor")
diff --git a/internal/api/middleware.go b/internal/api/middleware.go
index 8300a19f..ebe2f196 100644
--- a/internal/api/middleware.go
+++ b/internal/api/middleware.go
@@ -3,10 +3,13 @@ package api
 import (
 	"context"
 	"encoding/json"
+	"fmt"
 	"net/http"
+	"net/url"
 	"strings"
 	"time"
 
+	"github.com/supabase/gotrue/internal/models"
 	"github.com/supabase/gotrue/internal/observability"
 	"github.com/supabase/gotrue/internal/security"
 
@@ -179,6 +182,32 @@ func isIgnoreCaptchaRoute(req *http.Request) bool {
 	return false
 }
 
+func (a *API) isValidExternalHost(w http.ResponseWriter, req *http.Request) (context.Context, error) {
+	ctx := req.Context()
+	config := a.config
+
+	var u *url.URL
+	var err error
+
+	baseUrl := config.API.ExternalURL
+	xForwardedHost := req.Header.Get("X-Forwarded-Host")
+	xForwardedProto := req.Header.Get("X-Forwarded-Proto")
+	if xForwardedHost != "" && xForwardedProto != "" {
+		baseUrl = fmt.Sprintf("%s://%s", xForwardedProto, xForwardedHost)
+	} else if req.URL.Scheme != "" && req.URL.Hostname() != "" {
+		baseUrl = fmt.Sprintf("%s://%s", req.URL.Scheme, req.URL.Hostname())
+	}
+	if u, err = url.ParseRequestURI(baseUrl); err != nil {
+		// fallback to the default hostname
+		log := observability.GetLogEntry(req)
+		log.WithField("request_url", baseUrl).Warn(err)
+		if u, err = url.ParseRequestURI(config.API.ExternalURL); err != nil {
+			return ctx, err
+		}
+	}
+	return withExternalHost(ctx, u), nil
+}
+
 func (a *API) requireSAMLEnabled(w http.ResponseWriter, req *http.Request) (context.Context, error) {
 	ctx := req.Context()
 	if !a.config.SAML.Enabled {
@@ -186,3 +215,27 @@ func (a *API) requireSAMLEnabled(w http.ResponseWriter, req *http.Request) (cont
 	}
 	return ctx, nil
 }
+
+func (a *API) databaseCleanup(next http.Handler) http.Handler {
+	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		next.ServeHTTP(w, r)
+
+		switch r.Method {
+		case http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete:
+			// continue
+
+		default:
+			return
+		}
+
+		db := a.db.WithContext(r.Context())
+		log := observability.GetLogEntry(r)
+
+		affectedRows, err := models.Cleanup(db)
+		if err != nil {
+			log.WithError(err).WithField("affected_rows", affectedRows).Warn("database cleanup failed")
+		} else if affectedRows > 0 {
+			log.WithField("affected_rows", affectedRows).Debug("cleaned up expired or stale rows")
+		}
+	})
+}
diff --git a/internal/api/provider/kakao.go b/internal/api/provider/kakao.go
new file mode 100644
index 00000000..917facff
--- /dev/null
+++ b/internal/api/provider/kakao.go
@@ -0,0 +1,105 @@
+package provider
+
+import (
+	"context"
+	"strconv"
+	"strings"
+
+	"github.com/supabase/gotrue/internal/conf"
+	"golang.org/x/oauth2"
+)
+
+const (
+	defaultKakaoAuthBase = "kauth.kakao.com"
+	defaultKakaoAPIBase  = "kapi.kakao.com"
+)
+
+type kakaoProvider struct {
+	*oauth2.Config
+	APIHost string
+}
+
+type kakaoUser struct {
+	ID      int `json:"id"`
+	Account struct {
+		Profile struct {
+			Name            string `json:"nickname"`
+			ProfileImageURL string `json:"profile_image_url"`
+		} `json:"profile"`
+		Email         string `json:"email"`
+		EmailValid    bool   `json:"is_email_valid"`
+		EmailVerified bool   `json:"is_email_verified"`
+	} `json:"kakao_account"`
+}
+
+func (p kakaoProvider) GetOAuthToken(code string) (*oauth2.Token, error) {
+	return p.Exchange(context.Background(), code)
+}
+
+func (p kakaoProvider) GetUserData(ctx context.Context, tok *oauth2.Token) (*UserProvidedData, error) {
+	var u kakaoUser
+
+	if err := makeRequest(ctx, tok, p.Config, p.APIHost+"/v2/user/me", &u); err != nil {
+		return nil, err
+	}
+
+	data := &UserProvidedData{
+		Emails: []Email{
+			{
+				Email:    u.Account.Email,
+				Verified: u.Account.EmailVerified && u.Account.EmailValid,
+				Primary:  true,
+			},
+		},
+		Metadata: &Claims{
+			Issuer:        p.APIHost,
+			Subject:       strconv.Itoa(u.ID),
+			Email:         u.Account.Email,
+			EmailVerified: u.Account.EmailVerified && u.Account.EmailValid,
+
+			Name:              u.Account.Profile.Name,
+			PreferredUsername: u.Account.Profile.Name,
+
+			// To be deprecated
+			AvatarURL:   u.Account.Profile.ProfileImageURL,
+			FullName:    u.Account.Profile.Name,
+			ProviderId:  strconv.Itoa(u.ID),
+			UserNameKey: u.Account.Profile.Name,
+		},
+	}
+	return data, nil
+}
+
+func NewKakaoProvider(ext conf.OAuthProviderConfiguration, scopes string) (OAuthProvider, error) {
+	if err := ext.Validate(); err != nil {
+		return nil, err
+	}
+
+	authHost := chooseHost(ext.URL, defaultKakaoAuthBase)
+	apiHost := chooseHost(ext.URL, defaultKakaoAPIBase)
+
+	oauthScopes := []string{
+		"account_email",
+		"profile_image",
+		"profile_nickname",
+	}
+
+	if scopes != "" {
+		oauthScopes = append(oauthScopes, strings.Split(scopes, ",")...)
+	}
+
+	return &kakaoProvider{
+		Config: &oauth2.Config{
+			ClientID:     ext.ClientID,
+			ClientSecret: ext.Secret,
+			Endpoint: oauth2.Endpoint{
+				AuthStyle: oauth2.AuthStyleInParams,
+				AuthURL:   authHost + "/oauth/authorize",
+				TokenURL:  authHost + "/oauth/token",
+			},
+			RedirectURL: ext.RedirectURI,
+			Scopes:      oauthScopes,
+		},
+		APIHost: apiHost,
+	}, nil
+}
diff --git a/internal/api/recover.go b/internal/api/recover.go
index d3276fe3..661503ec 100644
--- a/internal/api/recover.go
+++ b/internal/api/recover.go
@@ -77,7 +77,8 @@ func (a *API) Recover(w http.ResponseWriter, r *http.Request) error {
 				return terr
 			}
 		}
-		return a.sendPasswordRecovery(tx, user, mailer, config.SMTP.MaxFrequency, referrer, config.Mailer.OtpLength, flowType)
+		externalURL := getExternalHost(ctx)
+		return a.sendPasswordRecovery(tx, user, mailer, config.SMTP.MaxFrequency, referrer, externalURL, config.Mailer.OtpLength, flowType)
 	})
 	if err != nil {
 		if errors.Is(err, MaxFrequencyLimitError) {
diff --git a/internal/api/resend.go b/internal/api/resend.go
index 2403d348..22ecdc1b 100644
--- a/internal/api/resend.go
+++ b/internal/api/resend.go
@@ -115,13 +115,14 @@ func (a *API) Resend(w http.ResponseWriter, r *http.Request) error {
 	err = db.Transaction(func(tx *storage.Connection) error {
 		mailer := a.Mailer(ctx)
 		referrer := a.getReferrer(r)
+		externalURL := getExternalHost(ctx)
 		switch params.Type {
 		case signupVerification:
 			if terr := models.NewAuditLogEntry(r, tx, user, models.UserConfirmationRequestedAction, "", nil); terr != nil {
 				return terr
 			}
 			// PKCE not implemented yet
-			return sendConfirmation(tx, user, mailer, config.SMTP.MaxFrequency, referrer, config.Mailer.OtpLength, models.ImplicitFlow)
+			return sendConfirmation(tx, user, mailer, config.SMTP.MaxFrequency, referrer, externalURL, config.Mailer.OtpLength, models.ImplicitFlow)
 		case smsVerification:
 			if terr := models.NewAuditLogEntry(r, tx, user, models.UserRecoveryRequestedAction, "", nil); terr != nil {
 				return terr
@@ -132,7 +133,7 @@ func (a *API) Resend(w http.ResponseWriter, r *http.Request) error {
 			}
 			return a.sendPhoneConfirmation(ctx, tx, user, params.Phone, phoneConfirmationOtp, smsProvider, sms_provider.SMSProvider)
 		case emailChangeVerification:
-			return a.sendEmailChange(tx, config, user, mailer, user.EmailChange, referrer, config.Mailer.OtpLength, models.ImplicitFlow)
+			return a.sendEmailChange(tx, config, user, mailer, params.Email, referrer, externalURL, config.Mailer.OtpLength, models.ImplicitFlow)
 		case phoneChangeVerification:
 			smsProvider, terr := sms_provider.GetSmsProvider(*config)
 			if terr != nil {
diff --git a/internal/api/settings.go b/internal/api/settings.go
index 29c96137..93be22b8 100644
--- a/internal/api/settings.go
+++ b/internal/api/settings.go
@@ -11,6 +11,7 @@ type ProviderSettings struct {
 	GitLab    bool `json:"gitlab"`
 	Keycloak  bool `json:"keycloak"`
 	Google    bool `json:"google"`
+	Kakao     bool `json:"kakao"`
 	Linkedin  bool `json:"linkedin"`
 	Facebook  bool `json:"facebook"`
 	Notion    bool `json:"notion"`
@@ -46,6 +47,7 @@ func (a *API) Settings(w http.ResponseWriter, r *http.Request) error {
 			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,
 			Facebook:  config.External.Facebook.Enabled,
diff --git a/internal/api/signup.go b/internal/api/signup.go
index c643efde..5d123e5e 100644
--- a/internal/api/signup.go
+++ b/internal/api/signup.go
@@ -189,7 +189,8 @@ func (a *API) Signup(w http.ResponseWriter, r *http.Request) error {
 						return terr
 					}
 				}
-				if terr = sendConfirmation(tx, user, mailer, config.SMTP.MaxFrequency, referrer, config.Mailer.OtpLength, flowType); terr != nil {
+				externalURL := getExternalHost(ctx)
+				if terr = sendConfirmation(tx, user, mailer, config.SMTP.MaxFrequency, referrer, externalURL, config.Mailer.OtpLength, flowType); terr != nil {
 					if errors.Is(terr, MaxFrequencyLimitError) {
 						now := time.Now()
 						left := user.ConfirmationSentAt.Add(config.SMTP.MaxFrequency).Sub(now) / time.Second
diff --git a/internal/api/token.go b/internal/api/token.go
index 5149970c..5412854f 100644
--- a/internal/api/token.go
+++ b/internal/api/token.go
@@ -525,7 +525,8 @@ func (a *API) IdTokenGrant(ctx context.Context, w http.ResponseWriter, r *http.R
 
 				mailer := a.Mailer(ctx)
 				referrer := a.getReferrer(r)
-				if terr = sendConfirmation(tx, user, mailer, config.SMTP.MaxFrequency, referrer, config.Mailer.OtpLength, models.ImplicitFlow); terr != nil {
+				externalURL := getExternalHost(ctx)
+				if terr = sendConfirmation(tx, user, mailer, config.SMTP.MaxFrequency, referrer, externalURL, config.Mailer.OtpLength, models.ImplicitFlow); terr != nil {
 					return internalServerError("Error sending confirmation mail").WithInternalError(terr)
 				}
 				return unauthorizedError("Error unverified email")
diff --git a/internal/api/user.go b/internal/api/user.go
index 100f5056..e446bbac 100644
--- a/internal/api/user.go
+++ b/internal/api/user.go
@@ -188,7 +188,8 @@ func (a *API) UserUpdate(w http.ResponseWriter, r *http.Request) error {
 					return terr
 				}
 			}
-			if terr = a.sendEmailChange(tx, config, user, mailer, params.Email, referrer, config.Mailer.OtpLength, flowType); terr != nil {
+			externalURL := getExternalHost(ctx)
+			if terr = a.sendEmailChange(tx, config, user, mailer, params.Email, referrer, externalURL, config.Mailer.OtpLength, flowType); terr != nil {
 				if errors.Is(terr, MaxFrequencyLimitError) {
 					return tooManyRequestsError("For security purposes, you can only request this once every 60 seconds")
 				}
diff --git a/internal/conf/configuration.go b/internal/conf/configuration.go
index b104e6eb..c6de717a 100644
--- a/internal/conf/configuration.go
+++ b/internal/conf/configuration.go
@@ -43,6 +43,7 @@ type DBConfiguration struct {
 	ConnMaxIdleTime   time.Duration `json:"conn_max_idle_time,omitempty" split_words:"true"`
 	HealthCheckPeriod time.Duration `json:"health_check_period" split_words:"true"`
 	MigrationsPath    string        `json:"migrations_path" split_words:"true" default:"./migrations"`
+	CleanupEnabled    bool          `json:"cleanup_enabled" split_words:"true" default:"false"`
 }
 
 func (c *DBConfiguration) Validate() error {
@@ -144,6 +145,7 @@ type ProviderConfiguration struct {
 	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"`
diff --git a/internal/mailer/mailer.go b/internal/mailer/mailer.go
index 22054fa3..b73be371 100644
--- a/internal/mailer/mailer.go
+++ b/internal/mailer/mailer.go
@@ -15,14 +15,14 @@ import (
 // Mailer defines the interface a mailer must implement.
 type Mailer interface {
 	Send(user *models.User, subject, body string, data map[string]interface{}) error
-	InviteMail(user *models.User, otp, referrerURL string) error
-	ConfirmationMail(user *models.User, otp, referrerURL string) error
-	RecoveryMail(user *models.User, otp, referrerURL string) error
-	MagicLinkMail(user *models.User, otp, referrerURL string) error
-	EmailChangeMail(user *models.User, otpNew, otpCurrent, referrerURL string) error
+	InviteMail(user *models.User, otp, referrerURL string, externalURL *url.URL) error
+	ConfirmationMail(user *models.User, otp, referrerURL string, externalURL *url.URL) error
+	RecoveryMail(user *models.User, otp, referrerURL string, externalURL *url.URL) error
+	MagicLinkMail(user *models.User, otp, referrerURL string, externalURL *url.URL) error
+	EmailChangeMail(user *models.User, otpNew, otpCurrent, referrerURL string, externalURL *url.URL) error
 	ReauthenticateMail(user *models.User, otp string) error
 	ValidateEmail(email string) error
-	GetEmailActionLink(user *models.User, actionType, referrerURL string) (string, error)
+	GetEmailActionLink(user *models.User, actionType, referrerURL string, externalURL *url.URL) (string, error)
 }
 
 // NewMailer returns a new gotrue mailer
@@ -64,23 +64,19 @@ func withDefault(value, defaultValue string) string {
 	return value
 }
 
-func getSiteURL(referrerURL, siteURL, filepath, fragment string) (string, error) {
-	baseURL := siteURL
-	if filepath == "" && referrerURL != "" {
-		baseURL = referrerURL
-	}
-
-	site, err := url.Parse(baseURL)
-	if err != nil {
-		return "", err
-	}
+func getPath(filepath string, params map[string]string) (*url.URL, error) {
+	path := &url.URL{}
 	if filepath != "" {
-		path, err := url.Parse(filepath)
-		if err != nil {
-			return "", err
+		if p, err := url.Parse(filepath); err != nil {
+			return nil, err
+		} else {
+			path = p
 		}
-		site = site.ResolveReference(path)
 	}
-	site.RawQuery = fragment
-	return site.String(), nil
+	v := url.Values{}
+	for key, val := range params {
+		v.Add(key, val)
+	}
+	path.RawQuery = v.Encode()
+	return path, nil
 }
diff --git a/internal/mailer/template.go b/internal/mailer/template.go
index b9e69615..0fe1329f 100644
--- a/internal/mailer/template.go
+++ b/internal/mailer/template.go
@@ -21,8 +21,7 @@ type TemplateMailer struct {
 	Mailer  MailClient
 }
 
-func encodeRedirectParam(referrerURL string) string {
-	redirectParam := ""
+func encodeRedirectURL(referrerURL string) string {
 	if len(referrerURL) > 0 {
 		if strings.ContainsAny(referrerURL, "&=#") {
 			// if the string contains &, = or # it has not been URL
@@ -30,11 +29,8 @@ func encodeRedirectParam(referrerURL string) string {
 			// encoded by us otherwise, it should be taken as-is
 			referrerURL = url.QueryEscape(referrerURL)
 		}
-
-		redirectParam = "&redirect_to=" + referrerURL
 	}
-
-	return redirectParam
+	return referrerURL
 }
 
 const defaultInviteMail = `<h2>You have been invited</h2>
@@ -79,16 +75,20 @@ func (m TemplateMailer) ValidateEmail(email string) error {
 }
 
 // InviteMail sends a invite mail to a new user
-func (m *TemplateMailer) InviteMail(user *models.User, otp, referrerURL string) error {
-	redirectParam := encodeRedirectParam(referrerURL)
+func (m *TemplateMailer) InviteMail(user *models.User, otp, referrerURL string, externalURL *url.URL) error {
+	path, err := getPath(m.Config.Mailer.URLPaths.Invite, map[string]string{
+		"token":       user.ConfirmationToken,
+		"type":        "invite",
+		"redirect_to": encodeRedirectURL(referrerURL),
+	})
 
-	url, err := getSiteURL(referrerURL, m.Config.API.ExternalURL, m.Config.Mailer.URLPaths.Invite, "token="+user.ConfirmationToken+"&type=invite"+redirectParam)
 	if err != nil {
 		return err
 	}
+
 	data := map[string]interface{}{
 		"SiteURL":         m.Config.SiteURL,
-		"ConfirmationURL": url,
+		"ConfirmationURL": externalURL.ResolveReference(path).String(),
 		"Email":           user.Email,
 		"Token":           otp,
 		"TokenHash":       user.ConfirmationToken,
@@ -105,16 +105,19 @@ func (m *TemplateMailer) InviteMail(user *models.User, otp, referrerURL string)
 }
 
 // ConfirmationMail sends a signup confirmation mail to a new user
-func (m *TemplateMailer) ConfirmationMail(user *models.User, otp, referrerURL string) error {
-	redirectParam := encodeRedirectParam(referrerURL)
-	fragment := "token=" + user.ConfirmationToken + "&type=signup" + redirectParam
-	url, err := getSiteURL(referrerURL, m.Config.API.ExternalURL, m.Config.Mailer.URLPaths.Confirmation, fragment)
+func (m *TemplateMailer) ConfirmationMail(user *models.User, otp, referrerURL string, externalURL *url.URL) error {
+	path, err := getPath(m.Config.Mailer.URLPaths.Confirmation, map[string]string{
+		"token":       user.ConfirmationToken,
+		"type":        "signup",
+		"redirect_to": encodeRedirectURL(referrerURL),
+	})
 	if err != nil {
 		return err
 	}
+
 	data := map[string]interface{}{
 		"SiteURL":         m.Config.SiteURL,
-		"ConfirmationURL": url,
+		"ConfirmationURL": externalURL.ResolveReference(path).String(),
 		"Email":           user.Email,
 		"Token":           otp,
 		"TokenHash":       user.ConfirmationToken,
@@ -149,7 +152,7 @@ func (m *TemplateMailer) ReauthenticateMail(user *models.User, otp string) error
 }
 
 // EmailChangeMail sends an email change confirmation mail to a user
-func (m *TemplateMailer) EmailChangeMail(user *models.User, otpNew, otpCurrent, referrerURL string) error {
+func (m *TemplateMailer) EmailChangeMail(user *models.User, otpNew, otpCurrent, referrerURL string, externalURL *url.URL) error {
 	type Email struct {
 		Address   string
 		Otp       string
@@ -178,15 +181,15 @@ func (m *TemplateMailer) EmailChangeMail(user *models.User, otpNew, otpCurrent,
 		})
 	}
 
-	redirectParam := encodeRedirectParam(referrerURL)
-
 	errors := make(chan error)
 	for _, email := range emails {
-		url, err := getSiteURL(
-			referrerURL,
-			m.Config.API.ExternalURL,
+		path, err := getPath(
 			m.Config.Mailer.URLPaths.EmailChange,
-			"token="+email.TokenHash+"&type=email_change"+redirectParam,
+			map[string]string{
+				"token":       email.TokenHash,
+				"type":        "email_change",
+				"redirect_to": encodeRedirectURL(referrerURL),
+			},
 		)
 		if err != nil {
 			return err
@@ -194,7 +197,7 @@ func (m *TemplateMailer) EmailChangeMail(user *models.User, otpNew, otpCurrent,
 		go func(address, token, tokenHash, template string) {
 			data := map[string]interface{}{
 				"SiteURL":         m.Config.SiteURL,
-				"ConfirmationURL": url,
+				"ConfirmationURL": externalURL.ResolveReference(path).String(),
 				"Email":           user.GetEmail(),
 				"NewEmail":        user.EmailChange,
 				"Token":           token,
@@ -222,15 +225,18 @@ func (m *TemplateMailer) EmailChangeMail(user *models.User, otpNew, otpCurrent,
 }
 
 // RecoveryMail sends a password recovery mail
-func (m *TemplateMailer) RecoveryMail(user *models.User, otp, referrerURL string) error {
-	redirectParam := encodeRedirectParam(referrerURL)
-	url, err := getSiteURL(referrerURL, m.Config.API.ExternalURL, m.Config.Mailer.URLPaths.Recovery, "token="+user.RecoveryToken+"&type=recovery"+redirectParam)
+func (m *TemplateMailer) RecoveryMail(user *models.User, otp, referrerURL string, externalURL *url.URL) error {
+	path, err := getPath(m.Config.Mailer.URLPaths.Recovery, map[string]string{
+		"token":       user.RecoveryToken,
+		"type":        "recovery",
+		"redirect_to": encodeRedirectURL(referrerURL),
+	})
 	if err != nil {
 		return err
 	}
 	data := map[string]interface{}{
 		"SiteURL":         m.Config.SiteURL,
-		"ConfirmationURL": url,
+		"ConfirmationURL": externalURL.ResolveReference(path).String(),
 		"Email":           user.Email,
 		"Token":           otp,
 		"TokenHash":       user.RecoveryToken,
@@ -247,17 +253,19 @@ func (m *TemplateMailer) RecoveryMail(user *models.User, otp, referrerURL string
 }
 
 // MagicLinkMail sends a login link mail
-func (m *TemplateMailer) MagicLinkMail(user *models.User, otp, referrerURL string) error {
-	redirectParam := encodeRedirectParam(referrerURL)
-	fragment := "token=" + user.RecoveryToken + "&type=magiclink" + redirectParam
-	url, err := getSiteURL(referrerURL, m.Config.API.ExternalURL, m.Config.Mailer.URLPaths.Recovery, fragment)
+func (m *TemplateMailer) MagicLinkMail(user *models.User, otp, referrerURL string, externalURL *url.URL) error {
+	path, err := getPath(m.Config.Mailer.URLPaths.Recovery, map[string]string{
+		"token":       user.RecoveryToken,
+		"type":        "magiclink",
+		"redirect_to": encodeRedirectURL(referrerURL),
+	})
 	if err != nil {
 		return err
 	}
 
 	data := map[string]interface{}{
 		"SiteURL":         m.Config.SiteURL,
-		"ConfirmationURL": url,
+		"ConfirmationURL": externalURL.ResolveReference(path).String(),
 		"Email":           user.Email,
 		"Token":           otp,
 		"TokenHash":       user.RecoveryToken,
@@ -285,30 +293,53 @@ func (m TemplateMailer) Send(user *models.User, subject, body string, data map[s
 }
 
 // GetEmailActionLink returns a magiclink, recovery or invite link based on the actionType passed.
-func (m TemplateMailer) GetEmailActionLink(user *models.User, actionType, referrerURL string) (string, error) {
+func (m TemplateMailer) GetEmailActionLink(user *models.User, actionType, referrerURL string, externalURL *url.URL) (string, error) {
 	var err error
+	var path *url.URL
 
-	redirectParam := encodeRedirectParam(referrerURL)
-
-	var url string
+	referrerURL = encodeRedirectURL(referrerURL)
 	switch actionType {
 	case "magiclink":
-		url, err = getSiteURL(referrerURL, m.Config.API.ExternalURL, m.Config.Mailer.URLPaths.Recovery, "token="+user.RecoveryToken+"&type=magiclink"+redirectParam)
+		path, err = getPath(m.Config.Mailer.URLPaths.Recovery, map[string]string{
+			"token":       user.RecoveryToken,
+			"type":        "magiclink",
+			"redirect_to": referrerURL,
+		})
 	case "recovery":
-		url, err = getSiteURL(referrerURL, m.Config.API.ExternalURL, m.Config.Mailer.URLPaths.Recovery, "token="+user.RecoveryToken+"&type=recovery"+redirectParam)
+		path, err = getPath(m.Config.Mailer.URLPaths.Recovery, map[string]string{
+			"token":       user.RecoveryToken,
+			"type":        "recovery",
+			"redirect_to": referrerURL,
+		})
 	case "invite":
-		url, err = getSiteURL(referrerURL, m.Config.API.ExternalURL, m.Config.Mailer.URLPaths.Invite, "token="+user.ConfirmationToken+"&type=invite"+redirectParam)
+		path, err = getPath(m.Config.Mailer.URLPaths.Invite, map[string]string{
+			"token":       user.ConfirmationToken,
+			"type":        "invite",
+			"redirect_to": referrerURL,
+		})
 	case "signup":
-		url, err = getSiteURL(referrerURL, m.Config.API.ExternalURL, m.Config.Mailer.URLPaths.Confirmation, "token="+user.ConfirmationToken+"&type=signup"+redirectParam)
+		path, err = getPath(m.Config.Mailer.URLPaths.Confirmation, map[string]string{
+			"token":       user.ConfirmationToken,
+			"type":        "signup",
+			"redirect_to": referrerURL,
+		})
 	case "email_change_current":
-		url, err = getSiteURL(referrerURL, m.Config.API.ExternalURL, m.Config.Mailer.URLPaths.EmailChange, "token="+user.EmailChangeTokenCurrent+"&type=email_change"+redirectParam)
+		path, err = getPath(m.Config.Mailer.URLPaths.EmailChange, map[string]string{
+			"token":       user.EmailChangeTokenCurrent,
+			"type":        "email_change",
+			"redirect_to": referrerURL,
+		})
 	case "email_change_new":
-		url, err = getSiteURL(referrerURL, m.Config.API.ExternalURL, m.Config.Mailer.URLPaths.EmailChange, "token="+user.EmailChangeTokenNew+"&type=email_change"+redirectParam)
+		path, err = getPath(m.Config.Mailer.URLPaths.EmailChange, map[string]string{
+			"token":       user.EmailChangeTokenNew,
+			"type":        "email_change",
+			"redirect_to": referrerURL,
+		})
 	default:
 		return "", fmt.Errorf("invalid email action link type: %s", actionType)
 	}
 	if err != nil {
 		return "", err
 	}
-	return url, nil
+	return externalURL.ResolveReference(path).String(), nil
 }
diff --git a/internal/models/cleanup.go b/internal/models/cleanup.go
new file mode 100644
index 00000000..ea669b79
--- /dev/null
+++ b/internal/models/cleanup.go
@@ -0,0 +1,94 @@
+package models
+
+import (
+	"fmt"
+	"sync/atomic"
+
+	"github.com/sirupsen/logrus"
+	"go.opentelemetry.io/otel/attribute"
+	metricglobal "go.opentelemetry.io/otel/metric/global"
+	metricinstrument "go.opentelemetry.io/otel/metric/instrument"
+	otelasyncint64instrument "go.opentelemetry.io/otel/metric/instrument/asyncint64"
+
+	"github.com/supabase/gotrue/internal/observability"
+	"github.com/supabase/gotrue/internal/storage"
+)
+
+// cleanupNext holds an atomically incrementing value that determines which of
+// the cleanupStatements will be run next.
+var cleanupNext uint32
+
+// cleanupStatements holds all of the possible cleanup raw SQL. Only one at a
+// time is executed using cleanupNext % len(cleanupStatements).
+var CleanupStatements []string
+
+// cleanupAffectedRows tracks an OpenTelemetry metric on the total number of
+// cleaned up rows.
+var cleanupAffectedRows otelasyncint64instrument.Counter
+
+func init() {
+	tableRefreshTokens := RefreshToken{}.TableName()
+	tableSessions := Session{}.TableName()
+	tableRelayStates := SAMLRelayState{}.TableName()
+	tableFlowStates := FlowState{}.TableName()
+
+	// These statements intentionally use SELECT ... FOR UPDATE SKIP LOCKED
+	// as this makes sure that only rows that are not being used in another
+	// transaction are deleted. These deletes are thus very quick and
+	// efficient, as they don't wait on other transactions.
+	CleanupStatements = append(CleanupStatements,
+		fmt.Sprintf("delete from %q where id in (select id from %q where revoked is true and updated_at < now() - interval '24 hours' limit 100 for update skip locked);", tableRefreshTokens, tableRefreshTokens),
+		fmt.Sprintf("update %q set revoked = true, updated_at = now() where id in (select %q.id from %q join %q on %q.session_id = %q.id where %q.not_after < now() - interval '24 hours' and %q.revoked is false limit 100 for update skip locked);", tableRefreshTokens, tableRefreshTokens, tableRefreshTokens, tableSessions, tableRefreshTokens, tableSessions, tableSessions, tableRefreshTokens),
+		// sessions are deleted after 72 hours to allow refresh tokens
+		// to be deleted piecemeal; 10 at once so that cascades don't
+		// overwork the database
+		fmt.Sprintf("delete from %q where id in (select id from %q where not_after < now() - interval '72 hours' limit 10 for update skip locked);", tableSessions, tableSessions),
+		fmt.Sprintf("delete from %q where id in (select id from %q where created_at < now() - interval '24 hours' limit 100 for update skip locked);", tableRelayStates, tableRelayStates),
+		fmt.Sprintf("delete from %q where id in (select id from %q where created_at < now() - interval '24 hours' limit 100 for update skip locked);", tableFlowStates, tableFlowStates),
+	)
+
+	var err error
+	cleanupAffectedRows, err = metricglobal.Meter("gotrue").AsyncInt64().Counter(
+		"gotrue_cleanup_affected_rows",
+		metricinstrument.WithDescription("Number of affected rows from cleaning up stale entities"),
+	)
+	if err != nil {
+		logrus.WithError(err).Error("unable to get gotrue.gotrue_cleanup_rows counter metric")
+	}
+}
+
+// Cleanup removes stale entities in the database. You can call it on each
+// request or as a periodic background job. It does quick lockless updates or
+// deletes, has an execution timeout and acquire timeout so that cleanups do
+// not affect performance of other database jobs. Note that calling this does
+// not clean up the whole database, but does a small piecemeal clean up each
+// time when called.
+func Cleanup(db *storage.Connection) (int, error) {
+	ctx, span := observability.Tracer("gotrue").Start(db.Context(), "database-cleanup")
+	defer span.End()
+
+	affectedRows := 0
+	defer span.SetAttributes(attribute.Int64("gotrue.cleanup.affected_rows", int64(affectedRows)))
+
+	if err := db.WithContext(ctx).Transaction(func(tx *storage.Connection) error {
+		nextIndex := atomic.AddUint32(&cleanupNext, 1) % uint32(len(CleanupStatements))
+		statement := CleanupStatements[nextIndex]
+
+		count, terr := tx.RawQuery(statement).ExecWithCount()
+		if terr != nil {
+			return terr
+		}
+
+		affectedRows += count
+
+		return nil
+	}); err != nil {
+		return affectedRows, err
+	}
+
+	if cleanupAffectedRows != nil {
+		cleanupAffectedRows.Observe(ctx, int64(affectedRows))
+	}
+
+	return affectedRows, nil
+}
diff --git a/migrations/20230508135423_add_cleanup_indexes.up.sql b/migrations/20230508135423_add_cleanup_indexes.up.sql
new file mode 100644
index 00000000..162acee1
--- /dev/null
+++ b/migrations/20230508135423_add_cleanup_indexes.up.sql
@@ -0,0 +1,17 @@
+-- Indexes used for cleaning up old or stale objects.
+
+create index if not exists
+  refresh_tokens_updated_at_idx
+  on {{ index .Options "Namespace" }}.refresh_tokens (updated_at desc);
+
+create index if not exists
+  flow_state_created_at_idx
+  on {{ index .Options "Namespace" }}.flow_state (created_at desc);
+
+create index if not exists
+  saml_relay_states_created_at_idx
+  on {{ index .Options "Namespace" }}.saml_relay_states (created_at desc);
+
+create index if not exists
+  sessions_not_after_idx
+  on {{ index .Options "Namespace" }}.sessions (not_after desc);


Test patch:
diff --git a/hack/test.env b/hack/test.env
index 41c0210c..2bee21ed 100644
--- a/hack/test.env
+++ b/hack/test.env
@@ -39,6 +39,10 @@ GOTRUE_EXTERNAL_GITHUB_ENABLED=true
 GOTRUE_EXTERNAL_GITHUB_CLIENT_ID=testclientid
 GOTRUE_EXTERNAL_GITHUB_SECRET=testsecret
 GOTRUE_EXTERNAL_GITHUB_REDIRECT_URI=https://identity.services.netlify.com/callback
+GOTRUE_EXTERNAL_KAKAO_ENABLED=true
+GOTRUE_EXTERNAL_KAKAO_CLIENT_ID=testclientid
+GOTRUE_EXTERNAL_KAKAO_SECRET=testsecret
+GOTRUE_EXTERNAL_KAKAO_REDIRECT_URI=https://identity.services.netlify.com/callback
 GOTRUE_EXTERNAL_KEYCLOAK_ENABLED=true
 GOTRUE_EXTERNAL_KEYCLOAK_CLIENT_ID=testclientid
 GOTRUE_EXTERNAL_KEYCLOAK_SECRET=testsecret
diff --git a/internal/api/admin_test.go b/internal/api/admin_test.go
index 6539be30..64ee821a 100644
--- a/internal/api/admin_test.go
+++ b/internal/api/admin_test.go
@@ -705,7 +705,8 @@ func (ts *AdminTestSuite) TestAdminUserCreateWithDisabledLogin() {
 			req := httptest.NewRequest(http.MethodPost, "/admin/users", &buffer)
 			req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", ts.token))
 
-			*ts.Config = *c.customConfig
+			ts.Config.JWT = c.customConfig.JWT
+			ts.Config.External = c.customConfig.External
 			ts.API.handler.ServeHTTP(w, req)
 			require.Equal(ts.T(), c.expected, w.Code)
 		})
diff --git a/internal/api/external_kakao_test.go b/internal/api/external_kakao_test.go
new file mode 100644
index 00000000..7cfb51b3
--- /dev/null
+++ b/internal/api/external_kakao_test.go
@@ -0,0 +1,234 @@
+package api
+
+import (
+	"encoding/json"
+	"fmt"
+	"net/http"
+	"net/http/httptest"
+	"net/url"
+	"time"
+
+	jwt "github.com/golang-jwt/jwt"
+	"github.com/stretchr/testify/require"
+	"github.com/supabase/gotrue/internal/api/provider"
+	"github.com/supabase/gotrue/internal/models"
+)
+
+func (ts *ExternalTestSuite) TestSignupExternalKakao() {
+	req := httptest.NewRequest(http.MethodGet, "http://localhost/authorize?provider=kakao", nil)
+	w := httptest.NewRecorder()
+	ts.API.handler.ServeHTTP(w, req)
+	ts.Require().Equal(http.StatusFound, w.Code)
+	u, err := url.Parse(w.Header().Get("Location"))
+	ts.Require().NoError(err, "redirect url parse failed")
+	q := u.Query()
+	ts.Equal(ts.Config.External.Kakao.RedirectURI, q.Get("redirect_uri"))
+	ts.Equal(ts.Config.External.Kakao.ClientID, q.Get("client_id"))
+	ts.Equal("code", q.Get("response_type"))
+
+	claims := ExternalProviderClaims{}
+	p := jwt.Parser{ValidMethods: []string{jwt.SigningMethodHS256.Name}}
+	_, err = p.ParseWithClaims(q.Get("state"), &claims, func(token *jwt.Token) (interface{}, error) {
+		return []byte(ts.Config.JWT.Secret), nil
+	})
+	ts.Require().NoError(err)
+
+	ts.Equal("kakao", claims.Provider)
+	ts.Equal(ts.Config.SiteURL, claims.SiteURL)
+}
+
+func KakaoTestSignupSetup(ts *ExternalTestSuite, tokenCount *int, userCount *int, code string, emails string) *httptest.Server {
+	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		switch r.URL.Path {
+		case "/oauth/token":
+			*tokenCount++
+			ts.Equal(code, r.FormValue("code"))
+			ts.Equal("authorization_code", r.FormValue("grant_type"))
+			ts.Equal(ts.Config.External.Kakao.RedirectURI, r.FormValue("redirect_uri"))
+			w.Header().Add("Content-Type", "application/json")
+			fmt.Fprint(w, `{"access_token":"kakao_token","expires_in":100000}`)
+		case "/v2/user/me":
+			*userCount++
+			var emailList []provider.Email
+			if err := json.Unmarshal([]byte(emails), &emailList); err != nil {
+				ts.Fail("Invalid email json %s", emails)
+			}
+
+			var email *provider.Email
+
+			for i, e := range emailList {
+				if len(e.Email) > 0 {
+					email = &emailList[i]
+					break
+				}
+			}
+
+			if email == nil {
+				w.WriteHeader(400)
+				return
+			}
+
+			w.Header().Add("Content-Type", "application/json")
+			fmt.Fprintf(w, `
+				{
+					"id":123, 
+					"kakao_account": {
+						"profile": {
+							"nickname":"Kakao Test",
+							"profile_image_url":"http://example.com/avatar"
+						},
+						"email": "%v",
+						"is_email_valid": %v,
+						"is_email_verified": %v
+					}
+				}`, email.Email, email.Verified, email.Verified)
+		default:
+			w.WriteHeader(500)
+			ts.Fail("unknown kakao oauth call %s", r.URL.Path)
+		}
+	}))
+	ts.Config.External.Kakao.URL = server.URL
+	return server
+}
+
+func (ts *ExternalTestSuite) TestSignupExternalKakao_AuthorizationCode() {
+	tokenCount, userCount := 0, 0
+	code := "authcode"
+	emails := `[{"email":"kakao@example.com", "primary": true, "verified": true}]`
+	server := KakaoTestSignupSetup(ts, &tokenCount, &userCount, code, emails)
+	defer server.Close()
+	u := performAuthorization(ts, "kakao", code, "")
+	assertAuthorizationSuccess(ts, u, tokenCount, userCount, "kakao@example.com", "Kakao Test", "123", "http://example.com/avatar")
+}
+
+func (ts *ExternalTestSuite) TestSignupExternalKakaoDisableSignupErrorWhenNoUser() {
+	ts.Config.DisableSignup = true
+	tokenCount, userCount := 0, 0
+	code := "authcode"
+	emails := `[{"email":"kakao@example.com", "primary": true, "verified": true}]`
+	server := KakaoTestSignupSetup(ts, &tokenCount, &userCount, code, emails)
+	defer server.Close()
+
+	u := performAuthorization(ts, "kakao", code, "")
+
+	assertAuthorizationFailure(ts, u, "Signups not allowed for this instance", "access_denied", "kakao@example.com")
+}
+
+func (ts *ExternalTestSuite) TestSignupExternalKakaoDisableSignupErrorWhenEmptyEmail() {
+	ts.Config.DisableSignup = true
+	tokenCount, userCount := 0, 0
+	code := "authcode"
+	emails := `[{"primary": true, "verified": true}]`
+	server := KakaoTestSignupSetup(ts, &tokenCount, &userCount, code, emails)
+	defer server.Close()
+
+	u := performAuthorization(ts, "kakao", code, "")
+
+	assertAuthorizationFailure(ts, u, "Error getting user email from external provider", "server_error", "kakao@example.com")
+}
+
+func (ts *ExternalTestSuite) TestSignupExternalKakaoDisableSignupSuccessWithPrimaryEmail() {
+	ts.Config.DisableSignup = true
+
+	ts.createUser("123", "kakao@example.com", "Kakao Test", "http://example.com/avatar", "")
+
+	tokenCount, userCount := 0, 0
+	code := "authcode"
+	emails := `[{"email":"kakao@example.com", "primary": true, "verified": true}]`
+	server := KakaoTestSignupSetup(ts, &tokenCount, &userCount, code, emails)
+	defer server.Close()
+
+	u := performAuthorization(ts, "kakao", code, "")
+
+	assertAuthorizationSuccess(ts, u, tokenCount, userCount, "kakao@example.com", "Kakao Test", "123", "http://example.com/avatar")
+}
+
+func (ts *ExternalTestSuite) TestInviteTokenExternalKakaoSuccessWhenMatchingToken() {
+	// name and avatar should be populated from Kakao API
+	ts.createUser("123", "kakao@example.com", "", "", "invite_token")
+
+	tokenCount, userCount := 0, 0
+	code := "authcode"
+	emails := `[{"email":"kakao@example.com", "primary": true, "verified": true}]`
+	server := KakaoTestSignupSetup(ts, &tokenCount, &userCount, code, emails)
+	defer server.Close()
+
+	u := performAuthorization(ts, "kakao", code, "invite_token")
+
+	assertAuthorizationSuccess(ts, u, tokenCount, userCount, "kakao@example.com", "Kakao Test", "123", "http://example.com/avatar")
+}
+
+func (ts *ExternalTestSuite) TestInviteTokenExternalKakaoErrorWhenNoMatchingToken() {
+	tokenCount, userCount := 0, 0
+	code := "authcode"
+	emails := `[{"email":"kakao@example.com", "primary": true, "verified": true}]`
+	server := KakaoTestSignupSetup(ts, &tokenCount, &userCount, code, emails)
+	defer server.Close()
+
+	w := performAuthorizationRequest(ts, "kakao", "invite_token")
+	ts.Require().Equal(http.StatusNotFound, w.Code)
+}
+
+func (ts *ExternalTestSuite) TestInviteTokenExternalKakaoErrorWhenWrongToken() {
+	ts.createUser("123", "kakao@example.com", "", "", "invite_token")
+
+	tokenCount, userCount := 0, 0
+	code := "authcode"
+	emails := `[{"email":"kakao@example.com", "primary": true, "verified": true}]`
+	server := KakaoTestSignupSetup(ts, &tokenCount, &userCount, code, emails)
+	defer server.Close()
+
+	w := performAuthorizationRequest(ts, "kakao", "wrong_token")
+	ts.Require().Equal(http.StatusNotFound, w.Code)
+}
+
+func (ts *ExternalTestSuite) TestInviteTokenExternalKakaoErrorWhenEmailDoesntMatch() {
+	ts.createUser("123", "kakao@example.com", "", "", "invite_token")
+
+	tokenCount, userCount := 0, 0
+	code := "authcode"
+	emails := `[{"email":"other@example.com", "primary": true, "verified": true}]`
+	server := KakaoTestSignupSetup(ts, &tokenCount, &userCount, code, emails)
+	defer server.Close()
+
+	u := performAuthorization(ts, "kakao", code, "invite_token")
+
+	assertAuthorizationFailure(ts, u, "Invited email does not match emails from external provider", "invalid_request", "")
+}
+
+func (ts *ExternalTestSuite) TestSignupExternalKakaoErrorWhenVerifiedFalse() {
+	tokenCount, userCount := 0, 0
+	code := "authcode"
+	emails := `[{"email":"kakao@example.com", "primary": true, "verified": false}]`
+	server := KakaoTestSignupSetup(ts, &tokenCount, &userCount, code, emails)
+	defer server.Close()
+
+	u := performAuthorization(ts, "kakao", code, "")
+
+	v, err := url.ParseQuery(u.Fragment)
+	ts.Require().NoError(err)
+	ts.Equal("unauthorized_client", v.Get("error"))
+	ts.Equal("401", v.Get("error_code"))
+	ts.Equal("Unverified email with kakao", v.Get("error_description"))
+	assertAuthorizationFailure(ts, u, "", "", "")
+}
+
+func (ts *ExternalTestSuite) TestSignupExternalKakaoErrorWhenUserBanned() {
+	tokenCount, userCount := 0, 0
+	code := "authcode"
+	emails := `[{"email":"kakao@example.com", "primary": true, "verified": true}]`
+	server := KakaoTestSignupSetup(ts, &tokenCount, &userCount, code, emails)
+	defer server.Close()
+
+	u := performAuthorization(ts, "kakao", code, "")
+	assertAuthorizationSuccess(ts, u, tokenCount, userCount, "kakao@example.com", "Kakao Test", "123", "http://example.com/avatar")
+
+	user, err := models.FindUserByEmailAndAudience(ts.API.db, "kakao@example.com", ts.Config.JWT.Aud)
+	require.NoError(ts.T(), err)
+	t := time.Now().Add(24 * time.Hour)
+	user.BannedUntil = &t
+	require.NoError(ts.T(), ts.API.db.UpdateOnly(user, "banned_until"))
+
+	u = performAuthorization(ts, "kakao", code, "")
+	assertAuthorizationFailure(ts, u, "User is unauthorized", "unauthorized_client", "")
+}
diff --git a/internal/api/mail_test.go b/internal/api/mail_test.go
index 23763f99..28517ef9 100644
--- a/internal/api/mail_test.go
+++ b/internal/api/mail_test.go
@@ -7,6 +7,7 @@ import (
 	"fmt"
 	"net/http"
 	"net/http/httptest"
+	"net/url"
 	"testing"
 
 	"github.com/golang-jwt/jwt"
@@ -39,6 +40,11 @@ func (ts *MailTestSuite) SetupTest() {
 	models.TruncateAll(ts.API.db)
 
 	ts.Config.Mailer.SecureEmailChangeEnabled = true
+
+	// Create User
+	u, err := models.NewUser("12345678", "test@example.com", "password", ts.Config.JWT.Aud, nil)
+	require.NoError(ts.T(), err, "Error creating new user model")
+	require.NoError(ts.T(), ts.API.db.Create(u), "Error saving new user")
 }
 
 func (ts *MailTestSuite) TestGenerateLink() {
@@ -108,11 +114,14 @@ func (ts *MailTestSuite) TestGenerateLink() {
 		},
 	}
 
+	customDomainUrl, err := url.ParseRequestURI("https://example.gotrue.com")
+	require.NoError(ts.T(), err)
+
 	for _, c := range cases {
 		ts.Run(c.Desc, func() {
 			var buffer bytes.Buffer
 			require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(c.Body))
-			req := httptest.NewRequest(http.MethodPost, "/admin/generate_link", &buffer)
+			req := httptest.NewRequest(http.MethodPost, customDomainUrl.String()+"/admin/generate_link", &buffer)
 			req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token))
 			w := httptest.NewRecorder()
 
@@ -131,6 +140,11 @@ func (ts *MailTestSuite) TestGenerateLink() {
 
 			// check if hashed_token matches hash function of email and the raw otp
 			require.Equal(ts.T(), data["hashed_token"], fmt.Sprintf("%x", sha256.Sum224([]byte(c.Body.Email+data["email_otp"].(string)))))
+
+			// check if the host used in the email link matches the initial request host
+			u, err := url.ParseRequestURI(data["action_link"].(string))
+			require.NoError(ts.T(), err)
+			require.Equal(ts.T(), req.Host, u.Host)
 		})
 	}
 }
diff --git a/internal/api/middleware_test.go b/internal/api/middleware_test.go
index 0d1ad20e..c3c15758 100644
--- a/internal/api/middleware_test.go
+++ b/internal/api/middleware_test.go
@@ -7,6 +7,7 @@ import (
 	"io"
 	"net/http"
 	"net/http/httptest"
+	"net/url"
 	"testing"
 
 	jwt "github.com/golang-jwt/jwt"
@@ -229,6 +230,35 @@ func (ts *MiddlewareTestSuite) TestLimitEmailOrPhoneSentHandler() {
 	}
 }
 
+func (ts *MiddlewareTestSuite) TestIsValidExternalHost() {
+	cases := []struct {
+		desc        string
+		requestURL  string
+		expectedURL string
+	}{
+		{
+			desc:        "Valid custom external url",
+			requestURL:  "https://example.custom.com",
+			expectedURL: "https://example.custom.com",
+		},
+	}
+
+	_, err := url.ParseRequestURI("https://example.custom.com")
+	require.NoError(ts.T(), err)
+
+	for _, c := range cases {
+		ts.Run(c.desc, func() {
+			req := httptest.NewRequest(http.MethodPost, c.requestURL, nil)
+			w := httptest.NewRecorder()
+			ctx, err := ts.API.isValidExternalHost(w, req)
+			require.NoError(ts.T(), err)
+
+			externalURL := getExternalHost(ctx)
+			require.Equal(ts.T(), c.expectedURL, externalURL.String())
+		})
+	}
+}
+
 func (ts *MiddlewareTestSuite) TestRequireSAMLEnabled() {
 	cases := []struct {
 		desc        string
diff --git a/internal/api/settings_test.go b/internal/api/settings_test.go
index 58398266..b066f434 100644
--- a/internal/api/settings_test.go
+++ b/internal/api/settings_test.go
@@ -36,6 +36,7 @@ func TestSettings_DefaultProviders(t *testing.T) {
 	require.True(t, p.Spotify)
 	require.True(t, p.Slack)
 	require.True(t, p.Google)
+	require.True(t, p.Kakao)
 	require.True(t, p.Keycloak)
 	require.True(t, p.Linkedin)
 	require.True(t, p.GitHub)
diff --git a/internal/mailer/mailer_test.go b/internal/mailer/mailer_test.go
index 2cd565a1..0c37e938 100644
--- a/internal/mailer/mailer_test.go
+++ b/internal/mailer/mailer_test.go
@@ -1,6 +1,7 @@
 package mailer
 
 import (
+	"net/url"
 	"regexp"
 	"testing"
 
@@ -13,27 +14,57 @@ func enforceRelativeURL(url string) string {
 	return urlRegexp.ReplaceAllString(url, "")
 }
 
-func TestGetSiteURL(t *testing.T) {
+func TestGetPath(t *testing.T) {
 	cases := []struct {
-		ReferrerURL string
-		SiteURL     string
-		Path        string
-		Fragment    string
-		Expected    string
+		SiteURL  string
+		Path     string
+		Params   map[string]string
+		Expected string
 	}{
-		{"", "https://test.example.com", "/templates/confirm.html", "", "https://test.example.com/templates/confirm.html"},
-		{"", "https://test.example.com/removedpath", "/templates/confirm.html", "", "https://test.example.com/templates/confirm.html"},
-		{"", "https://test.example.com/", "/trailingslash/", "", "https://test.example.com/trailingslash/"},
-		{"", "https://test.example.com", "f", "fragment", "https://test.example.com/f?fragment"},
-		{"https://test.example.com/admin", "https://test.example.com", "", "fragment", "https://test.example.com/admin?fragment"},
-		{"https://test.example.com/admin", "https://test.example.com", "f", "fragment", "https://test.example.com/f?fragment"},
-		{"", "https://test.example.com", "", "fragment", "https://test.example.com?fragment"},
+		{
+			SiteURL:  "https://test.example.com",
+			Path:     "/templates/confirm.html",
+			Params:   nil,
+			Expected: "https://test.example.com/templates/confirm.html",
+		},
+		{
+			SiteURL:  "https://test.example.com/removedpath",
+			Path:     "/templates/confirm.html",
+			Params:   nil,
+			Expected: "https://test.example.com/templates/confirm.html",
+		},
+		{
+			SiteURL:  "https://test.example.com/",
+			Path:     "/trailingslash/",
+			Params:   nil,
+			Expected: "https://test.example.com/trailingslash/",
+		},
+		{
+			SiteURL: "https://test.example.com",
+			Path:    "f",
+			Params: map[string]string{
+				"key": "val",
+			},
+			Expected: "https://test.example.com/f?key=val",
+		},
+		{
+			SiteURL: "https://test.example.com",
+			Path:    "",
+			Params: map[string]string{
+				"key": "val",
+			},
+			Expected: "https://test.example.com?key=val",
+		},
 	}
 
 	for _, c := range cases {
-		act, err := getSiteURL(c.ReferrerURL, c.SiteURL, c.Path, c.Fragment)
+		u, err := url.ParseRequestURI(c.SiteURL)
+		assert.NoError(t, err, "error parsing URI request")
+
+		path, err := getPath(c.Path, c.Params)
+
 		assert.NoError(t, err, c.Expected)
-		assert.Equal(t, c.Expected, act)
+		assert.Equal(t, c.Expected, u.ResolveReference(path).String())
 	}
 }
 
diff --git a/internal/models/cleanup_test.go b/internal/models/cleanup_test.go
new file mode 100644
index 00000000..c38cf72f
--- /dev/null
+++ b/internal/models/cleanup_test.go
@@ -0,0 +1,38 @@
+package models
+
+import (
+	"fmt"
+	"testing"
+
+	"github.com/stretchr/testify/require"
+
+	"github.com/supabase/gotrue/internal/conf"
+	"github.com/supabase/gotrue/internal/storage/test"
+)
+
+func TestCleanupSQL(t *testing.T) {
+	globalConfig, err := conf.LoadGlobal(modelsTestConfig)
+	require.NoError(t, err)
+	conn, err := test.SetupDBConnection(globalConfig)
+	require.NoError(t, err)
+
+	for _, statement := range CleanupStatements {
+		_, err := conn.RawQuery(statement).ExecWithCount()
+		require.NoError(t, err, statement)
+	}
+}
+
+func TestCleanup(t *testing.T) {
+	globalConfig, err := conf.LoadGlobal(modelsTestConfig)
+	require.NoError(t, err)
+	conn, err := test.SetupDBConnection(globalConfig)
+	require.NoError(t, err)
+
+	for _, statement := range CleanupStatements {
+		_, err := Cleanup(conn)
+		if err != nil {
+			fmt.Printf("%v %t\n", err, err)
+		}
+		require.NoError(t, err, statement)
+	}
+}
diff --git a/internal/models/db_test.go b/internal/models/db_test.go
index 6d41798c..c3d6ab25 100644
--- a/internal/models/db_test.go
+++ b/internal/models/db_test.go
@@ -1,11 +1,10 @@
-package models_test
+package models
 
 import (
 	"testing"
 
 	"github.com/gobuffalo/pop/v6"
 	"github.com/stretchr/testify/assert"
-	"github.com/supabase/gotrue/internal/models"
 )
 
 func TestTableNameNamespacing(t *testing.T) {
@@ -13,9 +12,9 @@ func TestTableNameNamespacing(t *testing.T) {
 		expected string
 		value    interface{}
 	}{
-		{expected: "audit_log_entries", value: []*models.AuditLogEntry{}},
-		{expected: "refresh_tokens", value: []*models.RefreshToken{}},
-		{expected: "users", value: []*models.User{}},
+		{expected: "audit_log_entries", value: []*AuditLogEntry{}},
+		{expected: "refresh_tokens", value: []*RefreshToken{}},
+		{expected: "users", value: []*User{}},
 	}
 
 	for _, tc := range cases {
diff --git a/internal/models/factor_test.go b/internal/models/factor_test.go
index f725de5e..d2e29749 100644
--- a/internal/models/factor_test.go
+++ b/internal/models/factor_test.go
@@ -7,6 +7,7 @@ import (
 	"github.com/gofrs/uuid"
 	"github.com/stretchr/testify/require"
 	"github.com/stretchr/testify/suite"
+
 	"github.com/supabase/gotrue/internal/conf"
 	"github.com/supabase/gotrue/internal/storage"
 	"github.com/supabase/gotrue/internal/storage/test"