Repository: supabase/gotrue
Pull request #769 (2022-10-24 11:21:40)

feat: add password hashing metrics
Refactors the password hashing code to live in a central place under `crypto` and adds metrics that count the number of submitted and completed password hashing attempts. These metrics can be useful to determine whether GoTrue is under a password stuffing attack (where submitted will rise drastically over completed or not-matched will rise drastically over matched). Example:

```prometheus
# HELP gotrue_compare_hash_and_password_completed Number of completed CompareHashAndPassword hashing attempts
# TYPE gotrue_compare_hash_and_password_completed counter
gotrue_compare_hash_and_password_completed{alg="bcrypt",bcrypt_cost="10",gotrue_version="",match="false"} 4
gotrue_compare_hash_and_password_completed{alg="bcrypt",bcrypt_cost="10",gotrue_version="",match="true"} 3
# HELP gotrue_compare_hash_and_password_submitted Number of submitted CompareHashAndPassword hashing attempts
# TYPE gotrue_compare_hash_and_password_submitted counter
gotrue_compare_hash_and_password_submitted{alg="bcrypt",bcrypt_cost="10",gotrue_version=""} 7
# HELP gotrue_generate_from_password_completed Number of completed GenerateFromPassword hashing attempts
# TYPE gotrue_generate_from_password_completed counter
gotrue_generate_from_password_completed{alg="bcrypt",bcrypt_cost="10",gotrue_version=""} 1
# HELP gotrue_generate_from_password_submitted Number of submitted GenerateFromPassword hashing attempts
# TYPE gotrue_generate_from_password_submitted counter
gotrue_generate_from_password_submitted{alg="bcrypt",bcrypt_cost="10",gotrue_version=""} 1
```


Patch:
diff --git a/api/api.go b/api/api.go
index 0e71c1c4..9e884e4f 100644
--- a/api/api.go
+++ b/api/api.go
@@ -98,7 +98,7 @@ func NewAPIWithVersion(ctx context.Context, globalConfig *conf.GlobalConfigurati
 
 		r.Get("/authorize", api.ExternalProviderRedirect)
 
-		sharedLimiter := api.limitEmailSentHandler()
+		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).With(api.requireEmailProvider).Post("/recover", api.Recover)
@@ -154,6 +154,21 @@ func NewAPIWithVersion(ctx context.Context, globalConfig *conf.GlobalConfigurati
 			})
 		}
 
+		if api.config.SAML.Enabled {
+			r.Route("/sso", func(r *router) {
+				r.Route("/saml", func(r *router) {
+					r.Get("/metadata", api.SAMLMetadata)
+
+					r.With(api.limitHandler(
+						// Allow requests at the specified rate per 5 minutes.
+						tollbooth.NewLimiter(api.config.SAML.RateLimitAssertion/(60*5), &limiter.ExpirableOptions{
+							DefaultExpirationTTL: time.Hour,
+						}).SetBurst(30),
+					)).Post("/acs", api.SAMLACS)
+				})
+			})
+		}
+
 		r.Route("/admin", func(r *router) {
 			r.Use(api.requireAdminCredentials)
 
@@ -185,6 +200,23 @@ func NewAPIWithVersion(ctx context.Context, globalConfig *conf.GlobalConfigurati
 			})
 
 			r.Post("/generate_link", api.GenerateLink)
+
+			if api.config.SAML.Enabled {
+				r.Route("/sso", func(r *router) {
+					r.Route("/providers", func(r *router) {
+						r.Get("/", api.adminSSOProvidersList)
+						r.Post("/", api.adminSSOProvidersCreate)
+
+						r.Route("/{idp_id}", func(r *router) {
+							r.Use(api.loadSSOProvider)
+
+							r.Get("/", api.adminSSOProvidersGet)
+							r.Put("/", api.adminSSOProvidersUpdate)
+							r.Delete("/", api.adminSSOProvidersDelete)
+						})
+					})
+				})
+			}
 		})
 	})
 
diff --git a/api/auth.go b/api/auth.go
index b771b18d..3b13fdfe 100644
--- a/api/auth.go
+++ b/api/auth.go
@@ -72,7 +72,7 @@ func (a *API) parseJWTClaims(bearer string, r *http.Request, w http.ResponseWrit
 	})
 	if err != nil {
 		a.clearCookieTokens(config, w)
-		return nil, unauthorizedError("invalid JWT: unable to parse or verify signature").WithInternalError(err)
+		return nil, unauthorizedError("invalid JWT: unable to parse or verify signature, %v", err)
 	}
 
 	return withToken(ctx, token), nil
@@ -98,6 +98,9 @@ func (a *API) maybeLoadUserOrSession(ctx context.Context) (context.Context, erro
 		}
 		user, err = models.FindUserByID(db, userId)
 		if err != nil {
+			if models.IsNotFoundError(err) {
+				return ctx, notFoundError(err.Error())
+			}
 			return ctx, err
 		}
 		ctx = withUser(ctx, user)
diff --git a/api/context.go b/api/context.go
index ef893e0f..d7a03c5a 100644
--- a/api/context.go
+++ b/api/context.go
@@ -27,6 +27,7 @@ const (
 	adminUserKey            = contextKey("admin_user")
 	oauthTokenKey           = contextKey("oauth_token") // for OAuth1.0, also known as request token
 	oauthVerifierKey        = contextKey("oauth_verifier")
+	ssoProviderKey          = contextKey("sso_provider")
 )
 
 // withToken adds the JWT token to the context.
@@ -209,3 +210,15 @@ func getOAuthVerifier(ctx context.Context) string {
 	}
 	return obj.(string)
 }
+
+func withSSOProvider(ctx context.Context, provider *models.SSOProvider) context.Context {
+	return context.WithValue(ctx, ssoProviderKey, provider)
+}
+
+func getSSOProvider(ctx context.Context) *models.SSOProvider {
+	obj := ctx.Value(ssoProviderKey)
+	if obj == nil {
+		return nil
+	}
+	return obj.(*models.SSOProvider)
+}
diff --git a/api/external.go b/api/external.go
index 51540296..763dca8e 100644
--- a/api/external.go
+++ b/api/external.go
@@ -6,7 +6,6 @@ import (
 	"fmt"
 	"net/http"
 	"net/url"
-	"strconv"
 	"strings"
 	"time"
 
@@ -116,6 +115,9 @@ func (a *API) ExternalProviderCallback(w http.ResponseWriter, r *http.Request) e
 	return nil
 }
 
+// errReturnNil is a hack that signals internalExternalProviderCallback to return nil
+var errReturnNil = errors.New("createAccountFromExternalIdentity: return nil in internalExternalProviderCallback")
+
 func (a *API) internalExternalProviderCallback(w http.ResponseWriter, r *http.Request) error {
 	ctx := r.Context()
 	db := a.db.WithContext(ctx)
@@ -155,139 +157,12 @@ func (a *API) internalExternalProviderCallback(w http.ResponseWriter, r *http.Re
 				return terr
 			}
 		} else {
-			aud := a.requestAud(ctx, r)
-			var emailData provider.Email
-			var identityData map[string]interface{}
-			if userData.Metadata != nil {
-				identityData, terr = userData.Metadata.ToMap()
-				if terr != nil {
-					return terr
-				}
-			}
-
-			var identity *models.Identity
-			// check if identity exists
-			if identity, terr = models.FindIdentityByIdAndProvider(tx, userData.Metadata.Subject, providerType); terr != nil {
-				if models.IsNotFoundError(terr) {
-					user, emailData, terr = a.getUserByVerifiedEmail(tx, config, userData.Emails, aud)
-					if terr != nil && !models.IsNotFoundError(terr) {
-						return internalServerError("Error checking for existing users").WithInternalError(terr)
-					}
-					if user != nil {
-						if identity, terr = a.createNewIdentity(tx, user, providerType, identityData); terr != nil {
-							return terr
-						}
-						if terr = user.UpdateAppMetaDataProviders(tx); terr != nil {
-							return terr
-						}
-					} else {
-						if config.DisableSignup {
-							return forbiddenError("Signups not allowed for this instance")
-						}
-
-						// prefer primary email for new signups
-						emailData = userData.Emails[0]
-						for _, e := range userData.Emails {
-							if e.Primary {
-								emailData = e
-								break
-							}
-						}
-
-						params := &SignupParams{
-							Provider: providerType,
-							Email:    emailData.Email,
-							Aud:      aud,
-							Data:     identityData,
-						}
-
-						user, terr = a.signupNewUser(ctx, tx, params)
-						if terr != nil {
-							return terr
-						}
-
-						if identity, terr = a.createNewIdentity(tx, user, providerType, identityData); terr != nil {
-							return terr
-						}
-					}
-				} else {
-					return terr
-				}
-			}
-
-			if identity != nil && user == nil {
-				// get user associated with identity
-				user, terr = models.FindUserByID(tx, identity.UserID)
-				if terr != nil {
-					return terr
-				}
-				identity.IdentityData = identityData
-				if terr = tx.UpdateOnly(identity, "identity_data", "last_sign_in_at"); terr != nil {
-					return terr
-				}
-				// email & verified status might have changed if identity's email changed
-				emailData = provider.Email{
-					Email:    userData.Metadata.Email,
-					Verified: userData.Metadata.EmailVerified,
-				}
-				if terr = user.UpdateUserMetaData(tx, identityData); terr != nil {
-					return terr
-				}
-				if terr = user.UpdateAppMetaDataProviders(tx); terr != nil {
-					return terr
-				}
-			}
-
-			if user.IsBanned() {
-				return unauthorizedError("User is unauthorized")
-			}
-
-			// an account with a previously unconfirmed email + password
-			// combination or phone may exist. so now that there is an
-			// OAuth identity bound to this user, and since they have not
-			// confirmed their email or phone, they are unaware that a
-			// potentially malicious door exists into their account; thus
-			// the password and phone needs to be removed.
-			if terr = user.RemoveUnconfirmedIdentities(tx); terr != nil {
-				return internalServerError("Error updating user").WithInternalError(terr)
-			}
-
-			if !user.IsConfirmed() {
-				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); terr != nil {
-						if errors.Is(terr, MaxFrequencyLimitError) {
-							return tooManyRequestsError("For security purposes, you can only request this once every minute")
-						}
-						return internalServerError("Error sending confirmation mail").WithInternalError(terr)
-					}
-					// email must be verified to issue a token
+			if user, terr = a.createAccountFromExternalIdentity(tx, r, userData, providerType, user); terr != nil {
+				if errors.Is(terr, errReturnNil) {
 					return nil
 				}
 
-				if terr := models.NewAuditLogEntry(r, tx, user, models.UserSignedUpAction, "", map[string]interface{}{
-					"provider": providerType,
-				}); terr != nil {
-					return terr
-				}
-				if terr = triggerEventHooks(ctx, tx, SignupEvent, user, config); terr != nil {
-					return terr
-				}
-
-				// fall through to auto-confirm and issue token
-				if terr = user.Confirm(tx); terr != nil {
-					return internalServerError("Error updating user").WithInternalError(terr)
-				}
-			} else {
-				if terr := models.NewAuditLogEntry(r, tx, user, models.LoginAction, "", map[string]interface{}{
-					"provider": providerType,
-				}); terr != nil {
-					return terr
-				}
-				if terr = triggerEventHooks(ctx, tx, LoginEvent, user, config); terr != nil {
-					return terr
-				}
+				return terr
 			}
 		}
 		if config.MFA.Enabled {
@@ -314,11 +189,8 @@ func (a *API) internalExternalProviderCallback(w http.ResponseWriter, r *http.Re
 		if providerRefreshToken != "" {
 			q.Set("provider_refresh_token", providerRefreshToken)
 		}
-		q.Set("access_token", token.Token)
-		q.Set("token_type", token.TokenType)
-		q.Set("expires_in", strconv.Itoa(token.ExpiresIn))
-		q.Set("refresh_token", token.RefreshToken)
-		rurl += "#" + q.Encode()
+
+		rurl = token.AsRedirectURL(rurl, q)
 
 		if err := a.setCookieTokens(config, token, false, w); err != nil {
 			return internalServerError("Failed to set JWT cookie. %s", err)
@@ -331,6 +203,150 @@ func (a *API) internalExternalProviderCallback(w http.ResponseWriter, r *http.Re
 	return nil
 }
 
+func (a *API) createAccountFromExternalIdentity(tx *storage.Connection, r *http.Request, userData *provider.UserProvidedData, providerType string, user *models.User) (*models.User, error) {
+	ctx := r.Context()
+	aud := a.requestAud(ctx, r)
+	config := a.config
+
+	var terr error
+
+	var emailData provider.Email
+	var identityData map[string]interface{}
+	if userData.Metadata != nil {
+		identityData, terr = userData.Metadata.ToMap()
+		if terr != nil {
+			return nil, terr
+		}
+	}
+
+	var identity *models.Identity
+	// check if identity exists
+	if identity, terr = models.FindIdentityByIdAndProvider(tx, userData.Metadata.Subject, providerType); terr != nil {
+		if models.IsNotFoundError(terr) {
+			user, emailData, terr = a.getUserByVerifiedEmail(tx, config, userData.Emails, aud)
+			if terr != nil && !models.IsNotFoundError(terr) {
+				return nil, internalServerError("Error checking for existing users").WithInternalError(terr)
+			}
+			if user != nil {
+				if identity, terr = a.createNewIdentity(tx, user, providerType, identityData); terr != nil {
+					return nil, terr
+				}
+				if terr = user.UpdateAppMetaDataProviders(tx); terr != nil {
+					return nil, terr
+				}
+			} else {
+				if config.DisableSignup {
+					return nil, forbiddenError("Signups not allowed for this instance")
+				}
+
+				// prefer primary email for new signups
+				emailData = userData.Emails[0]
+				for _, e := range userData.Emails {
+					if e.Primary {
+						emailData = e
+						break
+					}
+				}
+
+				params := &SignupParams{
+					Provider: providerType,
+					Email:    emailData.Email,
+					Aud:      aud,
+					Data:     identityData,
+				}
+
+				user, terr = a.signupNewUser(ctx, tx, params)
+				if terr != nil {
+					return nil, terr
+				}
+
+				if identity, terr = a.createNewIdentity(tx, user, providerType, identityData); terr != nil {
+					return nil, terr
+				}
+			}
+		} else {
+			return nil, terr
+		}
+	}
+
+	if identity != nil && user == nil {
+		// get user associated with identity
+		user, terr = models.FindUserByID(tx, identity.UserID)
+		if terr != nil {
+			return nil, terr
+		}
+		identity.IdentityData = identityData
+		if terr = tx.UpdateOnly(identity, "identity_data", "last_sign_in_at"); terr != nil {
+			return nil, terr
+		}
+		// email & verified status might have changed if identity's email changed
+		emailData = provider.Email{
+			Email:    userData.Metadata.Email,
+			Verified: userData.Metadata.EmailVerified,
+		}
+		if terr = user.UpdateUserMetaData(tx, identityData); terr != nil {
+			return nil, terr
+		}
+		if terr = user.UpdateAppMetaDataProviders(tx); terr != nil {
+			return nil, terr
+		}
+	}
+
+	if user.IsBanned() {
+		return nil, unauthorizedError("User is unauthorized")
+	}
+
+	// an account with a previously unconfirmed email + password
+	// combination or phone may exist. so now that there is an
+	// OAuth identity bound to this user, and since they have not
+	// confirmed their email or phone, they are unaware that a
+	// potentially malicious door exists into their account; thus
+	// the password and phone needs to be removed.
+	if terr = user.RemoveUnconfirmedIdentities(tx); terr != nil {
+		return nil, internalServerError("Error updating user").WithInternalError(terr)
+	}
+
+	if !user.IsConfirmed() {
+		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); terr != nil {
+				if errors.Is(terr, MaxFrequencyLimitError) {
+					return nil, tooManyRequestsError("For security purposes, you can only request this once every minute")
+				}
+				return nil, internalServerError("Error sending confirmation mail").WithInternalError(terr)
+			}
+			// email must be verified to issue a token
+			return nil, errReturnNil
+		}
+
+		if terr := models.NewAuditLogEntry(r, tx, user, models.UserSignedUpAction, "", map[string]interface{}{
+			"provider": providerType,
+		}); terr != nil {
+			return nil, terr
+		}
+		if terr = triggerEventHooks(ctx, tx, SignupEvent, user, config); terr != nil {
+			return nil, terr
+		}
+
+		// fall through to auto-confirm and issue token
+		if terr = user.Confirm(tx); terr != nil {
+			return nil, internalServerError("Error updating user").WithInternalError(terr)
+		}
+	} else {
+		if terr := models.NewAuditLogEntry(r, tx, user, models.LoginAction, "", map[string]interface{}{
+			"provider": providerType,
+		}); terr != nil {
+			return nil, terr
+		}
+		if terr = triggerEventHooks(ctx, tx, LoginEvent, user, config); terr != nil {
+			return nil, terr
+		}
+	}
+
+	return user, nil
+}
+
 func (a *API) processInvite(r *http.Request, ctx context.Context, tx *storage.Connection, userData *provider.UserProvidedData, inviteToken, providerType string) (*models.User, error) {
 	config := a.config
 	user, err := models.FindUserByConfirmationToken(tx, inviteToken)
@@ -522,21 +538,14 @@ func (a *API) getExternalRedirectURL(r *http.Request) string {
 	return config.SiteURL
 }
 
-func (a *API) createNewIdentity(conn *storage.Connection, user *models.User, providerType string, identityData map[string]interface{}) (*models.Identity, error) {
+func (a *API) createNewIdentity(tx *storage.Connection, user *models.User, providerType string, identityData map[string]interface{}) (*models.Identity, error) {
 	identity, err := models.NewIdentity(user, providerType, identityData)
 	if err != nil {
 		return nil, err
 	}
 
-	err = conn.Transaction(func(tx *storage.Connection) error {
-		if terr := tx.Create(identity); terr != nil {
-			return internalServerError("Error creating identity").WithInternalError(terr)
-		}
-		return nil
-	})
-
-	if err != nil {
-		return nil, err
+	if terr := tx.Create(identity); terr != nil {
+		return nil, internalServerError("Error creating identity").WithInternalError(terr)
 	}
 
 	return identity, nil
diff --git a/api/helpers.go b/api/helpers.go
index 30ce9aa5..c0283eb9 100644
--- a/api/helpers.go
+++ b/api/helpers.go
@@ -1,11 +1,9 @@
 package api
 
 import (
-	"bytes"
 	"context"
 	"encoding/json"
 	"fmt"
-	"io"
 	"net"
 	"net/http"
 	"net/http/httptrace"
@@ -14,6 +12,7 @@ import (
 	"github.com/gofrs/uuid"
 	"github.com/netlify/gotrue/conf"
 	"github.com/netlify/gotrue/models"
+	"github.com/netlify/gotrue/utilities"
 	"github.com/pkg/errors"
 	"github.com/sirupsen/logrus"
 )
@@ -237,19 +236,5 @@ func isStringInSlice(checkValue string, list []string) bool {
 
 // getBodyBytes returns a byte array of the request's Body.
 func getBodyBytes(req *http.Request) ([]byte, error) {
-	if req.Body == nil || req.Body == http.NoBody {
-		return nil, nil
-	}
-
-	originalBody := req.Body
-	defer originalBody.Close()
-
-	buf, err := io.ReadAll(originalBody)
-	if err != nil {
-		return nil, err
-	}
-
-	req.Body = io.NopCloser(bytes.NewReader(buf))
-
-	return buf, nil
+	return utilities.GetBodyBytes(req)
 }
diff --git a/api/mail.go b/api/mail.go
index 1bf727a1..fcb9e1d7 100644
--- a/api/mail.go
+++ b/api/mail.go
@@ -115,6 +115,11 @@ func (a *API) GenerateLink(w http.ResponseWriter, r *http.Request) error {
 				if terr != nil {
 					return terr
 				}
+				identity, terr := a.createNewIdentity(tx, user, "email", map[string]interface{}{"sub": user.ID.String()})
+				if terr != nil {
+					return terr
+				}
+				user.Identities = []models.Identity{*identity}
 			}
 			if terr = models.NewAuditLogEntry(r, tx, adminUser, models.UserInvitedAction, "", map[string]interface{}{
 				"user_id":    user.ID,
@@ -152,6 +157,11 @@ func (a *API) GenerateLink(w http.ResponseWriter, r *http.Request) error {
 				if terr != nil {
 					return terr
 				}
+				identity, terr := a.createNewIdentity(tx, user, "email", map[string]interface{}{"sub": user.ID.String()})
+				if terr != nil {
+					return terr
+				}
+				user.Identities = []models.Identity{*identity}
 			}
 			user.ConfirmationToken = hashedToken
 			user.ConfirmationSentAt = &now
diff --git a/api/mfa.go b/api/mfa.go
index 2faefad6..d8536795 100644
--- a/api/mfa.go
+++ b/api/mfa.go
@@ -292,7 +292,7 @@ func (a *API) UnenrollFactor(w http.ResponseWriter, r *http.Request) error {
 	factor := getFactor(ctx)
 	session := getSession(ctx)
 
-	if factor.Status == models.FactorStateVerified && session.AAL != models.AAL2.String() {
+	if factor.Status == models.FactorStateVerified && session.GetAAL() != models.AAL2.String() {
 		return badRequestError("AAL2 required to unenroll verified factor")
 	}
 
diff --git a/api/middleware.go b/api/middleware.go
index cd20032b..51b46218 100644
--- a/api/middleware.go
+++ b/api/middleware.go
@@ -9,7 +9,6 @@ import (
 
 	"github.com/netlify/gotrue/observability"
 	"github.com/netlify/gotrue/security"
-	"github.com/sirupsen/logrus"
 
 	"github.com/didip/tollbooth/v5"
 	"github.com/didip/tollbooth/v5/limiter"
@@ -69,7 +68,7 @@ func (a *API) limitHandler(lmt *limiter.Limiter) middlewareHandler {
 	}
 }
 
-func (a *API) limitEmailSentHandler() middlewareHandler {
+func (a *API) limitEmailOrPhoneSentHandler() middlewareHandler {
 	// limit per hour
 	freq := a.config.RateLimitEmailSent / (60 * 60)
 	lmt := tollbooth.NewLimiter(freq, &limiter.ExpirableOptions{
@@ -78,26 +77,32 @@ func (a *API) limitEmailSentHandler() middlewareHandler {
 	return func(w http.ResponseWriter, req *http.Request) (context.Context, error) {
 		c := req.Context()
 		config := a.config
-		if config.External.Email.Enabled && !config.Mailer.Autoconfirm {
+		if (config.External.Email.Enabled && !config.Mailer.Autoconfirm) || (config.External.Phone.Enabled) {
 			if req.Method == "PUT" || req.Method == "POST" {
-				res := make(map[string]interface{})
-
 				bodyBytes, err := getBodyBytes(req)
 				if err != nil {
 					return c, internalServerError("Error invalid request body").WithInternalError(err)
 				}
 
-				if err := json.Unmarshal(bodyBytes, &res); err != nil {
+				var requestBody struct {
+					Email string `json:"email"`
+					Phone string `json:"phone"`
+				}
+
+				if err := json.Unmarshal(bodyBytes, &requestBody); err != nil {
 					return c, badRequestError("Error invalid request body").WithInternalError(err)
 				}
 
-				if _, ok := res["email"]; !ok {
-					// email not in POST body
-					return c, nil
+				if requestBody.Email != "" {
+					if err := tollbooth.LimitByKeys(lmt, []string{"email_functions"}); err != nil {
+						return c, httpError(http.StatusTooManyRequests, "Rate limit exceeded")
+					}
 				}
 
-				if err := tollbooth.LimitByKeys(lmt, []string{"email_functions"}); err != nil {
-					return c, httpError(http.StatusTooManyRequests, "Rate limit exceeded")
+				if requestBody.Phone != "" {
+					if err := tollbooth.LimitByKeys(lmt, []string{"phone_functions"}); err != nil {
+						return c, httpError(http.StatusTooManyRequests, "Rate limit exceeded")
+					}
 				}
 			}
 		}
@@ -133,6 +138,7 @@ func (a *API) requireEmailProvider(w http.ResponseWriter, req *http.Request) (co
 func (a *API) verifyCaptcha(w http.ResponseWriter, req *http.Request) (context.Context, error) {
 	ctx := req.Context()
 	config := a.config
+
 	if !config.Security.Captcha.Enabled {
 		return ctx, nil
 	}
@@ -143,28 +149,17 @@ func (a *API) verifyCaptcha(w http.ResponseWriter, req *http.Request) (context.C
 	if shouldIgnore := isIgnoreCaptchaRoute(req); shouldIgnore {
 		return ctx, nil
 	}
-	if config.Security.Captcha.Provider != "hcaptcha" {
-		logrus.WithField("provider", config.Security.Captcha.Provider).Warn("Unsupported captcha provider")
-		return nil, internalServerError("server misconfigured")
-	}
-	secret := strings.TrimSpace(config.Security.Captcha.Secret)
-	if secret == "" {
-		return nil, internalServerError("server misconfigured")
-	}
 
-	verificationResult, err := security.VerifyRequest(req, secret)
+	verificationResult, err := security.VerifyRequest(req, strings.TrimSpace(config.Security.Captcha.Secret))
 	if err != nil {
-		logrus.WithField("err", err).Infof("failed to validate result")
-		return nil, internalServerError("request validation failure")
-	}
-	if verificationResult == security.VerificationProcessFailure {
-		return nil, internalServerError("request validation failure")
-	} else if verificationResult == security.UserRequestFailed {
-		return nil, badRequestError("request disallowed")
+		return nil, internalServerError("hCaptcha verification process failed").WithInternalError(err)
 	}
-	if verificationResult != security.SuccessfullyVerified {
-		return nil, internalServerError("")
+
+	if !verificationResult.Success {
+		return nil, badRequestError("hCaptcha protection: request disallowed (%s)", strings.Join(verificationResult.ErrorCodes, ", "))
+
 	}
+
 	return ctx, nil
 }
 
diff --git a/api/saml.go b/api/saml.go
new file mode 100644
index 00000000..bfc4d2f1
--- /dev/null
+++ b/api/saml.go
@@ -0,0 +1,99 @@
+package api
+
+import (
+	"encoding/xml"
+	"net/http"
+	"net/url"
+	"strings"
+	"time"
+
+	"github.com/crewjam/saml"
+	"github.com/crewjam/saml/samlsp"
+)
+
+// getSAMLServiceProvider generates a new service provider object with the
+// (optionally) provided descriptor (metadata) for the identity provider.
+func (a *API) getSAMLServiceProvider(identityProvider *saml.EntityDescriptor, idpInitiated bool) *saml.ServiceProvider {
+	externalURL, err := url.ParseRequestURI(a.config.API.ExternalURL)
+	if err != nil {
+		// this should not fail as a.config should have been validated using #Validate()
+		panic(err)
+	}
+
+	if !strings.HasSuffix(externalURL.Path, "/") {
+		externalURL.Path += "/"
+	}
+
+	externalURL.Path += "sso/"
+
+	provider := samlsp.DefaultServiceProvider(samlsp.Options{
+		URL:               *externalURL,
+		Key:               a.config.SAML.RSAPrivateKey,
+		Certificate:       a.config.SAML.Certificate,
+		SignRequest:       true,
+		AllowIDPInitiated: idpInitiated,
+		IDPMetadata:       identityProvider,
+	})
+
+	return &provider
+}
+
+// SAMLMetadata serves GoTrue's SAML Service Provider metadata file.
+func (a *API) SAMLMetadata(w http.ResponseWriter, r *http.Request) error {
+	serviceProvider := a.getSAMLServiceProvider(nil, true)
+
+	metadata := serviceProvider.Metadata()
+
+	if r.FormValue("download") == "true" {
+		// 5 year expiration, comparable to what GSuite does
+		metadata.ValidUntil = time.Now().UTC().AddDate(5, 0, 0)
+	}
+
+	for i := range metadata.SPSSODescriptors {
+		// we set this to false since the IdP initiated flow can only
+		// sign the Assertion, and not the full Request
+		// unfortunately this is hardcoded in the crewjam library if
+		// signatures (instead of encryption) are supported
+		// https://github.com/crewjam/saml/blob/v0.4.8/service_provider.go#L217
+		metadata.SPSSODescriptors[i].AuthnRequestsSigned = nil
+
+		// advertize the requested NameID formats (either persistent or email address)
+		metadata.SPSSODescriptors[i].NameIDFormats = []saml.NameIDFormat{
+			saml.EmailAddressNameIDFormat,
+			saml.PersistentNameIDFormat,
+		}
+	}
+
+	// don't advertize the encryption keys as it makes it much difficult to debug
+	// requests / responses, and does not increase security since assertions are
+	// not "private" and not necessary to be hidden from the browser
+	for i := range metadata.SPSSODescriptors {
+		spd := &metadata.SPSSODescriptors[i]
+
+		var keyDescriptors []saml.KeyDescriptor
+
+		for _, kd := range spd.KeyDescriptors {
+			if kd.Use == "signing" {
+				keyDescriptors = append(keyDescriptors, kd)
+			}
+		}
+
+		spd.KeyDescriptors = keyDescriptors
+	}
+
+	metadataXML, err := xml.Marshal(metadata)
+	if err != nil {
+		return err
+	}
+
+	w.Header().Set("Content-Type", "application/xml")
+	w.Header().Set("Cache-Control", "public, max-age=600") // cache at CDN for 10 minutes
+
+	if r.FormValue("download") == "true" {
+		w.Header().Set("Content-Disposition", "attachment; filename=\"metadata.xml\"")
+	}
+
+	_, err = w.Write(metadataXML)
+
+	return err
+}
diff --git a/api/samlacs.go b/api/samlacs.go
new file mode 100644
index 00000000..da6adb19
--- /dev/null
+++ b/api/samlacs.go
@@ -0,0 +1,269 @@
+package api
+
+import (
+	"context"
+	"encoding/base64"
+	"encoding/json"
+	"encoding/xml"
+	"net/http"
+	"net/url"
+	"time"
+
+	"github.com/crewjam/saml"
+	"github.com/gofrs/uuid"
+	"github.com/netlify/gotrue/api/provider"
+	"github.com/netlify/gotrue/models"
+	"github.com/netlify/gotrue/observability"
+	"github.com/netlify/gotrue/storage"
+	"github.com/netlify/gotrue/utilities"
+)
+
+func (a *API) samlDestroyRelayState(ctx context.Context, relayState *models.SAMLRelayState) error {
+	db := a.db.WithContext(ctx)
+
+	// It's OK to destroy the RelayState, as a user will
+	// likely initiate a completely new login flow, instead
+	// of reusing the same one.
+
+	return db.Transaction(func(tx *storage.Connection) error {
+		return tx.Destroy(relayState)
+	})
+}
+
+// SAMLACS implements the main Assertion Consumer Service endpoint behavior.
+func (a *API) SAMLACS(w http.ResponseWriter, r *http.Request) error {
+	ctx := r.Context()
+
+	db := a.db.WithContext(ctx)
+	config := a.config
+	log := observability.GetLogEntry(r)
+
+	relayStateValue := r.FormValue("RelayState")
+	relayStateUUID := uuid.FromStringOrNil(relayStateValue)
+	relayStateURL, _ := url.ParseRequestURI(relayStateValue)
+
+	entityId := ""
+	initiatedBy := ""
+	redirectTo := ""
+	var requestIds []string
+
+	if relayStateUUID != uuid.Nil {
+		// relay state is a valid UUID, therefore this is likely a SP initiated flow
+
+		relayState, err := models.FindSAMLRelayStateByID(db, relayStateUUID)
+		if models.IsNotFoundError(err) {
+			return badRequestError("SAML RelayState does not exist, try logging in again?")
+		} else if err != nil {
+			return err
+		}
+
+		if time.Since(relayState.CreatedAt) >= a.config.SAML.RelayStateValidityPeriod {
+			if err := a.samlDestroyRelayState(ctx, relayState); err != nil {
+				return internalServerError("SAML RelayState has expired and destroying it failed. Try logging in again?").WithInternalError(err)
+			}
+
+			return badRequestError("SAML RelayState has expired. Try loggin in again?")
+		}
+
+		if relayState.FromIPAddress != utilities.GetIPAddress(r) {
+			if err := a.samlDestroyRelayState(ctx, relayState); err != nil {
+				return internalServerError("SAML RelayState comes from another IP address and destroying it failed. Try logging in again?").WithInternalError(err)
+			}
+
+			return badRequestError("SAML RelayState comes from another IP address, try logging in again?")
+		}
+
+		// TODO: add abuse detection to bind the RelayState UUID with a
+		// HTTP-Only cookie
+
+		ssoProvider, err := models.FindSSOProviderByID(db, relayState.SSOProviderID)
+		if err != nil {
+			return internalServerError("Unable to find SSO Provider from SAML RelayState")
+		}
+
+		initiatedBy = "sp"
+		entityId = ssoProvider.SAMLProvider.EntityID
+		redirectTo = relayState.RedirectTo
+		requestIds = append(requestIds, relayState.RequestID)
+
+		if err := a.samlDestroyRelayState(ctx, relayState); err != nil {
+			return err
+		}
+	} else if relayStateValue == "" || relayStateURL != nil {
+		// RelayState may be a URL in which case it's the URL where the
+		// IdP is telling us to redirect the user to
+
+		if r.FormValue("SAMLart") != "" {
+			// SAML Artifact responses are possible only when
+			// RelayState can be used to identify the Identity
+			// Provider.
+			return badRequestError("SAML Artifact response can only be used with SP initiated flow")
+		}
+
+		samlResponse := r.FormValue("SAMLResponse")
+		if samlResponse == "" {
+			return badRequestError("SAMLResponse is missing")
+		}
+
+		responseXML, err := base64.StdEncoding.DecodeString(samlResponse)
+		if err != nil {
+			return badRequestError("SAMLResponse is not a valid Base64 string")
+		}
+
+		var peekResponse saml.Response
+		err = xml.Unmarshal(responseXML, &peekResponse)
+		if err != nil {
+			return badRequestError("SAMLResponse is not a valid XML SAML assertion")
+		}
+
+		initiatedBy = "idp"
+		entityId = peekResponse.Issuer.Value
+		redirectTo = relayStateValue
+	} else {
+		// RelayState can't be identified, so SAML flow can't continue
+		return badRequestError("SAML RelayState is not a valid UUID or URL")
+	}
+
+	ssoProvider, err := models.FindSAMLProviderByEntityID(db, entityId)
+	if models.IsNotFoundError(err) {
+		return badRequestError("A SAML connection has not been established with this Identity Provider")
+	} else if err != nil {
+		return err
+	}
+
+	idpMetadata, err := ssoProvider.SAMLProvider.EntityDescriptor()
+	if err != nil {
+		return err
+	}
+
+	if ssoProvider.SAMLProvider.MetadataURL == nil {
+		if !idpMetadata.ValidUntil.IsZero() && time.Until(idpMetadata.ValidUntil) <= (30*24*60)*time.Second {
+			logentry := log.WithField("sso_provider_id", ssoProvider.ID.String())
+			logentry = logentry.WithField("expires_in", time.Until(idpMetadata.ValidUntil).String())
+			logentry = logentry.WithField("valid_until", idpMetadata.ValidUntil)
+			logentry = logentry.WithField("saml_entity_id", ssoProvider.SAMLProvider.EntityID)
+
+			logentry.Warn("SAML Metadata for identity provider will expire soon! Update its metadata_xml!")
+		}
+	}
+
+	// TODO: fetch new metadata if possible when validUntil < time.Now() or cacheDuration
+
+	serviceProvider := a.getSAMLServiceProvider(idpMetadata, initiatedBy == "idp")
+	spAssertion, err := serviceProvider.ParseResponse(r, requestIds)
+	if err != nil {
+		if ire, ok := err.(*saml.InvalidResponseError); ok {
+			return badRequestError("SAML Assertion is not valid").WithInternalError(ire.PrivateErr)
+		}
+
+		return badRequestError("SAML Assertion is not valid").WithInternalError(err)
+	}
+
+	assertion := SAMLAssertion{
+		spAssertion,
+	}
+
+	userID := assertion.UserID()
+	if userID == "" {
+		return badRequestError("SAML Assertion did not contain a persistent Subject Identifier attribute or Subject NameID uniquely identifying this user")
+	}
+
+	claims := assertion.Process(ssoProvider.SAMLProvider.AttributeMapping)
+
+	email, ok := claims["email"].(string)
+	if !ok || email == "" {
+		// mapping does not identify the email attribute, try to figure it out
+		email = assertion.Email()
+	}
+
+	if email == "" {
+		return badRequestError("SAML Assertion does not contain an email address")
+	} else {
+		claims["email"] = email
+	}
+
+	jsonClaims, err := json.Marshal(claims)
+	if err != nil {
+		return internalServerError("Mapped claims from provider could not be serialized into JSON").WithInternalError(err)
+	}
+
+	providerClaims := &provider.Claims{}
+	if err := json.Unmarshal(jsonClaims, providerClaims); err != nil {
+		return internalServerError("Mapped claims from provider could not be deserialized from JSON").WithInternalError(err)
+	}
+
+	providerClaims.Subject = userID
+	providerClaims.Issuer = ssoProvider.SAMLProvider.EntityID
+	providerClaims.Email = email
+	providerClaims.EmailVerified = true
+
+	providerClaimsMap, err := providerClaims.ToMap()
+	if err != nil {
+		return internalServerError("Parsed provider claims could not be turned into a map").WithInternalError(err)
+	}
+
+	// remove all of the parsed claims, so that the rest can go into CustomClaims
+	for key := range providerClaimsMap {
+		delete(claims, key)
+	}
+
+	providerClaims.CustomClaims = claims
+
+	var userProvidedData provider.UserProvidedData
+
+	userProvidedData.Emails = append(userProvidedData.Emails, provider.Email{
+		Email:    email,
+		Verified: true,
+		Primary:  true,
+	})
+
+	// userProvidedData.Provider.Type = "saml"
+	// userProvidedData.Provider.ID = ssoProvider.ID.String()
+	// userProvidedData.Provider.SAMLEntityID = ssoProvider.SAMLProvider.EntityID
+	// userProvidedData.Provider.SAMLInitiatedBy = initiatedBy
+
+	userProvidedData.Metadata = providerClaims
+
+	// TODO: below
+	// refreshTokenParams.SSOProviderID = ssoProvider.ID
+	// refreshTokenParams.InitiatedByProvider = initiatedBy == "idp"
+	// refreshTokenParams.NotBefore = assertion.NotBefore()
+	// refreshTokenParams.NotAfter = assertion.NotAfter()
+
+	var token *AccessTokenResponse
+
+	if err := db.Transaction(func(tx *storage.Connection) error {
+		var terr error
+		var user *models.User
+
+		if user, terr = a.createAccountFromExternalIdentity(tx, r, &userProvidedData, "sso:"+ssoProvider.ID.String(), user); terr != nil {
+			return terr
+		}
+
+		if config.MFA.Enabled {
+			token, terr = a.MFA_issueRefreshToken(ctx, tx, user, models.SSOSAML, models.GrantParams{})
+		} else {
+			token, terr = a.issueRefreshToken(ctx, tx, user, models.GrantParams{})
+		}
+
+		if terr != nil {
+			return internalServerError("Unable to issue refresh token from SAML Assertion").WithInternalError(terr)
+		}
+
+		return nil
+	}); err != nil {
+		return err
+	}
+
+	if err := a.setCookieTokens(config, token, false, w); err != nil {
+		return internalServerError("Failed to set JWT cookie").WithInternalError(err)
+	}
+
+	if !isRedirectURLValid(config, redirectTo) {
+		redirectTo = config.SiteURL
+	}
+
+	http.Redirect(w, r, token.AsRedirectURL(redirectTo, url.Values{}), http.StatusFound)
+
+	return nil
+}
diff --git a/api/samlassertion.go b/api/samlassertion.go
new file mode 100644
index 00000000..9d456d0e
--- /dev/null
+++ b/api/samlassertion.go
@@ -0,0 +1,167 @@
+package api
+
+import (
+	"strings"
+	"time"
+
+	"github.com/crewjam/saml"
+	"github.com/netlify/gotrue/models"
+)
+
+type SAMLAssertion struct {
+	*saml.Assertion
+}
+
+const (
+	SAMLSubjectIDAttributeName = "urn:oasis:names:tc:SAML:attribute:subject-id"
+)
+
+// Attribute returns the first matching attribute value in the attribute
+// statements where name equals the official SAML attribute Name or
+// FriendlyName. Returns nil if such an attribute can't be found.
+func (a *SAMLAssertion) Attribute(name string) []saml.AttributeValue {
+	var values []saml.AttributeValue
+
+	for _, stmt := range a.AttributeStatements {
+		for _, attr := range stmt.Attributes {
+			// TODO: maybe this should be case-insentivite equality?
+			if attr.Name == name || attr.FriendlyName == name {
+				values = append(values, attr.Values...)
+			}
+		}
+	}
+
+	return values
+}
+
+// UserID returns the best choice for a persistent user identifier on the
+// Identity Provider side. Don't assume the format of the string returned, as
+// it's Identity Provider specific.
+func (a *SAMLAssertion) UserID() string {
+	// First we look up the SAMLSubjectIDAttributeName in the attribute
+	// section of the assertion, as this is the preferred way to
+	// persistently identify users in SAML 2.0.
+	// See: https://docs.oasis-open.org/security/saml-subject-id-attr/v1.0/cs01/saml-subject-id-attr-v1.0-cs01.html#_Toc536097226
+	values := a.Attribute(SAMLSubjectIDAttributeName)
+	if len(values) > 0 {
+		return values[0].Value
+	}
+
+	// Otherwise, fall back to the SubjectID value.
+	subjectID, isPersistent := a.SubjectID()
+	if !isPersistent {
+		return ""
+	}
+
+	return subjectID
+}
+
+// SubjectID returns the user identifier in present in the Subject section of
+// the SAML assertion. Note that this way of identifying the Subject is
+// generally superseded by the SAMLSubjectIDAttributeName assertion attribute;
+// tho must be present in all assertions. It can have a few formats, of which
+// the most important are: saml.EmailAddressNameIDFormat (meaning the user ID
+// is an email address), saml.PersistentNameIDFormat (the user ID is an opaque
+// string that does not change with each assertion, e.g. UUID),
+// saml.TransientNameIDFormat (the user ID changes with each assertion -- can't
+// be used to identify a user). The boolean returned identifies if the user ID
+// is persistent. If it's an email address, it's lowercased just in case.
+func (a *SAMLAssertion) SubjectID() (string, bool) {
+	if a.Subject == nil {
+		return "", false
+	}
+
+	if a.Subject.NameID == nil {
+		return "", false
+	}
+
+	if a.Subject.NameID.Value == "" {
+		return "", false
+	}
+
+	if a.Subject.NameID.Format == string(saml.EmailAddressNameIDFormat) {
+		return strings.ToLower(strings.TrimSpace(a.Subject.NameID.Value)), true
+	}
+
+	// all other NameID formats are regarded as persistent
+	isPersistent := a.Subject.NameID.Format != string(saml.TransientNameIDFormat)
+
+	return a.Subject.NameID.Value, isPersistent
+}
+
+// Email returns the best guess for an email address.
+func (a *SAMLAssertion) Email() string {
+	attributeNames := []string{
+		"urn:oid:0.9.2342.19200300.100.1.3",
+		"http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress",
+		"http://schemas.xmlsoap.org/claims/EmailAddress",
+		"mail",
+		"email",
+	}
+
+	for _, name := range attributeNames {
+		for _, attr := range a.Attribute(name) {
+			if attr.Value != "" {
+				return attr.Value
+			}
+		}
+	}
+
+	if a.Subject.NameID.Format == string(saml.EmailAddressNameIDFormat) {
+		return a.Subject.NameID.Value
+	}
+
+	return ""
+}
+
+// Process processes this assertion according to the SAMLAttributeMapping. Never returns nil.
+func (a *SAMLAssertion) Process(mapping models.SAMLAttributeMapping) map[string]interface{} {
+	ret := make(map[string]interface{})
+
+	for key, mapper := range mapping.Keys {
+		names := []string{mapper.Name}
+		names = append(names, mapper.Names...)
+
+		setKey := false
+
+		for _, name := range names {
+			for _, attr := range a.Attribute(name) {
+				if attr.Value != "" {
+					ret[key] = attr.Value
+					setKey = true
+					break
+				}
+			}
+
+			if setKey {
+				break
+			}
+		}
+
+		if !setKey && mapper.Default != nil {
+			ret[key] = mapper.Default
+		}
+	}
+
+	return ret
+}
+
+// NotBefore extracts the time before which this assertion should not be
+// considered.
+func (a *SAMLAssertion) NotBefore() time.Time {
+	if a.Conditions != nil && !a.Conditions.NotBefore.IsZero() {
+		return a.Conditions.NotBefore.UTC()
+	}
+
+	return time.Time{}
+}
+
+// NotAfter extracts the time at which or after this assertion should not be
+// considered.
+func (a *SAMLAssertion) NotAfter() time.Time {
+	if a.Conditions != nil && !a.Conditions.NotOnOrAfter.IsZero() {
+		return a.Conditions.NotOnOrAfter.UTC()
+	}
+
+	return time.Time{}
+}
diff --git a/api/sso.go b/api/sso.go
new file mode 100644
index 00000000..1ac00640
--- /dev/null
+++ b/api/sso.go
@@ -0,0 +1,407 @@
+package api
+
+import (
+	"context"
+	"encoding/json"
+	"io"
+	"net/http"
+	"net/url"
+	"regexp"
+	"unicode/utf8"
+
+	"github.com/crewjam/saml"
+	"github.com/crewjam/saml/samlsp"
+	"github.com/go-chi/chi"
+	"github.com/gofrs/uuid"
+	"github.com/netlify/gotrue/models"
+	"github.com/netlify/gotrue/observability"
+	"github.com/netlify/gotrue/storage"
+)
+
+var (
+	ssoProviderResourceIDPattern = regexp.MustCompile("(?i)^[a-z0-9_-]{5,256}$")
+)
+
+// loadSSOProvider looks for an idp_id parameter in the URL route and loads the SSO provider
+// with that ID (or resource ID) and adds it to the context.
+func (a *API) loadSSOProvider(w http.ResponseWriter, r *http.Request) (context.Context, error) {
+	var err error
+	var provider *models.SSOProvider
+
+	idpParam := chi.URLParam(r, "idp_id")
+
+	if idpID, err := uuid.FromString(idpParam); err == nil {
+		// idpParam is a UUIDv4
+		provider, err = models.FindSSOProviderByID(a.db, idpID)
+		if err != nil {
+			if !models.IsNotFoundError(err) {
+				return nil, internalServerError("Database error finding SSO Identity Provider").WithInternalError(err)
+			}
+			// not found with the ID, maybe it's a ResourceID?
+		}
+	}
+
+	if provider == nil {
+		// provider wasn't found, consider idpParam is a resource ID
+
+		if !ssoProviderResourceIDPattern.MatchString(idpParam) {
+			// it is definitely not a resource ID
+			return nil, notFoundError("SSO Identity Provider not found")
+		}
+
+		provider, err = models.FindSSOProviderByResourceID(a.db, idpParam)
+		if err != nil {
+			if models.IsNotFoundError(err) {
+				return nil, notFoundError("SSO Identity Provider not found")
+			} else {
+				return nil, internalServerError("Database error finding SSO Identity Provider").WithInternalError(err)
+			}
+		}
+	}
+
+	observability.LogEntrySetField(r, "sso_provider_id", provider.ID.String())
+	if provider.ResourceID != nil {
+		resourceID := *provider.ResourceID
+		observability.LogEntrySetField(r, "sso_provider_resource_id", resourceID)
+	}
+
+	return withSSOProvider(r.Context(), provider), nil
+}
+
+// adminSSOProvidersList lists all SAML SSO Identity Providers in the system. Does
+// not deal with pagination at this time.
+func (a *API) adminSSOProvidersList(w http.ResponseWriter, r *http.Request) error {
+	providers, err := models.FindAllSAMLProviders(a.db)
+	if err != nil {
+		return err
+	}
+
+	for i := range providers {
+		// remove metadata XML so that the returned JSON is not ginormous
+		providers[i].SAMLProvider.MetadataXML = ""
+	}
+
+	return sendJSON(w, http.StatusOK, map[string]interface{}{
+		"items": providers,
+	})
+}
+
+type CreateSSOProviderParams struct {
+	ResourceID string `json:"resource_id"`
+	Type       string `json:"type"`
+
+	MetadataURL      string                      `json:"metadata_url"`
+	MetadataXML      string                      `json:"metadata_xml"`
+	Domains          []string                    `json:"domains"`
+	AttributeMapping models.SAMLAttributeMapping `json:"attribute_mapping"`
+}
+
+func (p *CreateSSOProviderParams) validate(forUpdate bool) error {
+	if !forUpdate && p.Type != "saml" {
+		return badRequestError("Only 'saml' supported for SSO provider type")
+	} else if p.ResourceID != "" && !ssoProviderResourceIDPattern.MatchString(p.ResourceID) {
+		return badRequestError("Resource IDs can only contain letters, digits, dashes and underscores, have a minimum lenth of 5 and a maximum of 256")
+	} else if p.MetadataURL != "" && p.MetadataXML != "" {
+		return badRequestError("Only one of metadata_xml or metadata_url needs to be set")
+	} else if !forUpdate && p.MetadataURL == "" && p.MetadataXML == "" {
+		return badRequestError("Either metadata_xml or metadata_url must be set")
+	} else if p.MetadataURL != "" {
+		metadataURL, err := url.ParseRequestURI(p.MetadataURL)
+		if err != nil {
+			return badRequestError("metadata_url is not a valid URL")
+		}
+
+		if metadataURL.Scheme != "https" {
+			return badRequestError("metadata_url is not a HTTPS URL")
+		}
+	}
+
+	// TODO validate p.AttributeMapping
+	// TODO validate domains
+
+	return nil
+}
+
+func (p *CreateSSOProviderParams) metadata(ctx context.Context) ([]byte, *saml.EntityDescriptor, error) {
+	var rawMetadata []byte
+	var err error
+
+	if p.MetadataXML != "" {
+		rawMetadata = []byte(p.MetadataXML)
+	} else if p.MetadataURL != "" {
+		rawMetadata, err = fetchSAMLMetadata(ctx, p.MetadataURL)
+		if err != nil {
+			return nil, nil, err
+		}
+	} else {
+		// impossible situation if you called validate() prior
+		return nil, nil, nil
+	}
+
+	metadata, err := parseSAMLMetadata(rawMetadata)
+	if err != nil {
+		return nil, nil, err
+	}
+
+	return rawMetadata, metadata, nil
+}
+
+func parseSAMLMetadata(rawMetadata []byte) (*saml.EntityDescriptor, error) {
+	if !utf8.Valid(rawMetadata) {
+		return nil, badRequestError("SAML Metadata XML contains invalid UTF-8 characters, which are not supported at this time")
+	}
+
+	metadata, err := samlsp.ParseMetadata(rawMetadata)
+	if err != nil {
+		return nil, err
+	}
+
+	if metadata.EntityID == "" {
+		return nil, badRequestError("SAML Metadata does not contain an EntityID")
+	}
+
+	if len(metadata.IDPSSODescriptors) < 1 {
+		return nil, badRequestError("SAML Metadata does not contain any IDPSSODescriptor")
+	}
+
+	if len(metadata.IDPSSODescriptors) > 1 {
+		return nil, badRequestError("SAML Metadata contains multiple IDPSSODescriptors")
+	}
+
+	return metadata, nil
+}
+
+func fetchSAMLMetadata(ctx context.Context, url string) ([]byte, error) {
+	req, err := http.NewRequest(http.MethodGet, url, nil)
+	if err != nil {
+		return nil, badRequestError("Unable to create a request to metadata_url").WithInternalError(err)
+	}
+
+	req = req.WithContext(ctx)
+
+	req.Header.Set("Accept", "application/xml;charset=UTF-8")
+	req.Header.Set("Accept-Charset", "UTF-8")
+
+	resp, err := http.DefaultClient.Do(req)
+	if err != nil {
+		return nil, err
+	}
+
+	defer resp.Body.Close()
+	if resp.StatusCode != http.StatusOK {
+		return nil, badRequestError("HTTP %v error fetching SAML Metadata from URL '%s'", resp.StatusCode, url)
+	}
+
+	data, err := io.ReadAll(resp.Body)
+	if err != nil {
+		return nil, err
+	}
+
+	return data, nil
+}
+
+// adminSSOProvidersCreate creates a new SAML Identity Provider in the system.
+func (a *API) adminSSOProvidersCreate(w http.ResponseWriter, r *http.Request) error {
+	ctx := r.Context()
+
+	body, err := getBodyBytes(r)
+	if err != nil {
+		return internalServerError("Unable to read request body").WithInternalError(err)
+	}
+
+	var params CreateSSOProviderParams
+	if err := json.Unmarshal(body, &params); err != nil {
+		return badRequestError("Unable to parse JSON").WithInternalError(err)
+	}
+
+	if err := params.validate(false /* <- forUpdate */); err != nil {
+		return err
+	}
+
+	rawMetadata, metadata, err := params.metadata(ctx)
+	if err != nil {
+		return err
+	}
+
+	if params.ResourceID != "" {
+		if _, err := models.FindSSOProviderByResourceID(a.db, params.ResourceID); err != nil {
+			if !models.IsNotFoundError(err) {
+				return internalServerError("Unable to find SSO provider by resource ID").WithInternalError(err)
+			}
+		} else {
+			return badRequestError("A SSO provider with this resource ID (%s) already exists", params.ResourceID)
+		}
+	}
+
+	existingProvider, err := models.FindSAMLProviderByEntityID(a.db, metadata.EntityID)
+	if err != nil && !models.IsNotFoundError(err) {
+		return err
+	}
+	if existingProvider != nil {
+		return badRequestError("SAML Identity Provider with this EntityID (%s) already exists", metadata.EntityID)
+	}
+
+	provider := &models.SSOProvider{
+		// TODO handle Name, Description, Attribute Mapping
+		SAMLProvider: models.SAMLProvider{
+			EntityID:    metadata.EntityID,
+			MetadataXML: string(rawMetadata),
+		},
+	}
+
+	if params.ResourceID != "" {
+		provider.ResourceID = &params.ResourceID
+	}
+
+	if params.MetadataURL != "" {
+		provider.SAMLProvider.MetadataURL = &params.MetadataURL
+	}
+
+	for _, domain := range params.Domains {
+
+		existingProvider, err := models.FindSSOProviderByDomain(a.db, domain)
+		if err != nil && !models.IsNotFoundError(err) {
+			return err
+		}
+		if existingProvider != nil {
+			return badRequestError("SSO Domain '%s' is already assigned to an SSO identity provider (%s)", domain, existingProvider.ID.String())
+		}
+
+		provider.SSODomains = append(provider.SSODomains, models.SSODomain{
+			Domain: domain,
+		})
+	}
+
+	if err := a.db.Transaction(func(tx *storage.Connection) error {
+		return tx.Eager().Create(provider)
+	}); err != nil {
+		return err
+	}
+
+	return sendJSON(w, http.StatusCreated, provider)
+}
+
+// adminSSOProvidersGet returns an existing SAML Identity Provider in the system.
+func (a *API) adminSSOProvidersGet(w http.ResponseWriter, r *http.Request) error {
+	provider := getSSOProvider(r.Context())
+
+	return sendJSON(w, http.StatusOK, provider)
+}
+
+// adminSSOProvidersUpdate updates a provider with the provided diff values.
+func (a *API) adminSSOProvidersUpdate(w http.ResponseWriter, r *http.Request) error {
+	ctx := r.Context()
+
+	body, err := getBodyBytes(r)
+	if err != nil {
+		return internalServerError("Unable to read request body").WithInternalError(err)
+	}
+
+	var params CreateSSOProviderParams
+	if err := json.Unmarshal(body, &params); err != nil {
+		return badRequestError("Unable to parse JSON").WithInternalError(err)
+	}
+
+	if err := params.validate(true /* <- forUpdate */); err != nil {
+		return err
+	}
+
+	modified := false
+
+	provider := getSSOProvider(ctx)
+
+	if params.ResourceID != "" && (provider.ResourceID == nil || params.ResourceID != *provider.ResourceID) {
+		// resource ID is being updated
+
+		existingProvider, err := models.FindSSOProviderByResourceID(a.db, params.ResourceID)
+		if err != nil && !models.IsNotFoundError(err) {
+			return err
+		}
+
+		if existingProvider != nil {
+			return badRequestError("SSO Provider Resource ID (%s) can't be updated, already assigned to another provider (%s)", params.ResourceID, existingProvider.ID.String())
+		}
+
+		provider.ResourceID = &params.ResourceID
+		modified = true
+	}
+
+	if params.MetadataXML != "" || params.MetadataURL != "" {
+		// metadata is being updated
+		rawMetadata, metadata, err := params.metadata(ctx)
+		if err != nil {
+			return err
+		}
+
+		if provider.SAMLProvider.EntityID != metadata.EntityID {
+			return badRequestError("SAML Metadata can be updated only if the EntityID matches for the provider; expected '%s' but got '%s'", provider.SAMLProvider.EntityID, metadata.EntityID)
+		}
+
+		if params.MetadataURL != "" {
+			provider.SAMLProvider.MetadataURL = &params.MetadataURL
+		}
+
+		provider.SAMLProvider.MetadataXML = string(rawMetadata)
+		modified = true
+	}
+
+	var createDomains []string
+	keepDomains := make(map[string]bool)
+
+	for _, domain := range params.Domains {
+		existingProvider, err := models.FindSSOProviderByDomain(a.db, domain)
+		if err != nil && !models.IsNotFoundError(err) {
+			return err
+		}
+		if existingProvider != nil {
+			if existingProvider.ID == provider.ID {
+				keepDomains[domain] = true
+			} else {
+				return badRequestError("SSO domain '%s' already assigned to another provider (%s)", domain, existingProvider.ID.String())
+			}
+		} else {
+			createDomains = append(createDomains, domain)
+		}
+	}
+
+	var updatedDomains []models.SSODomain
+
+	for _, domain := range provider.SSODomains {
+		if keepDomains[domain.Domain] {
+			updatedDomains = append(updatedDomains, domain)
+		}
+	}
+
+	for _, domain := range createDomains {
+		updatedDomains = append(updatedDomains, models.SSODomain{
+			Domain: domain,
+		})
+	}
+
+	modified = modified || len(createDomains) > 0
+
+	provider.SSODomains = updatedDomains
+
+	if modified {
+		if err := a.db.Transaction(func(tx *storage.Connection) error {
+			return tx.Eager().Update(provider)
+		}); err != nil {
+			return err
+		}
+	}
+
+	return sendJSON(w, http.StatusOK, provider)
+}
+
+// adminSSOProvidersDelete deletes a SAML identity provider.
+func (a *API) adminSSOProvidersDelete(w http.ResponseWriter, r *http.Request) error {
+	provider := getSSOProvider(r.Context())
+
+	if err := a.db.Transaction(func(tx *storage.Connection) error {
+		return tx.Eager().Destroy(provider)
+	}); err != nil {
+		return err
+	}
+
+	return sendJSON(w, http.StatusOK, provider)
+}
diff --git a/api/token.go b/api/token.go
index 29d59cfa..c6fe3737 100644
--- a/api/token.go
+++ b/api/token.go
@@ -7,6 +7,7 @@ import (
 	"errors"
 	"fmt"
 	"net/http"
+	"net/url"
 	"strconv"
 	"time"
 
@@ -41,6 +42,17 @@ type AccessTokenResponse struct {
 	User         *models.User `json:"user"`
 }
 
+// AsRedirectURL encodes the AccessTokenResponse as a redirect URL that
+// includes the access token response data in a URL fragment.
+func (r *AccessTokenResponse) AsRedirectURL(redirectURL string, extraParams url.Values) string {
+	extraParams.Set("access_token", r.Token)
+	extraParams.Set("token_type", r.TokenType)
+	extraParams.Set("expires_in", strconv.Itoa(r.ExpiresIn))
+	extraParams.Set("refresh_token", r.RefreshToken)
+
+	return redirectURL + "#" + extraParams.Encode()
+}
+
 // PasswordGrantParams are the parameters the ResourceOwnerPasswordGrant method accepts
 type PasswordGrantParams struct {
 	Email    string `json:"email"`
diff --git a/api/verify.go b/api/verify.go
index 27f70a51..8448d83a 100644
--- a/api/verify.go
+++ b/api/verify.go
@@ -150,13 +150,11 @@ func (a *API) verifyGet(w http.ResponseWriter, r *http.Request) error {
 	rurl := params.RedirectTo
 	if token != nil {
 		q := url.Values{}
-		q.Set("access_token", token.Token)
-		q.Set("token_type", token.TokenType)
-		q.Set("expires_in", strconv.Itoa(token.ExpiresIn))
-		q.Set("refresh_token", token.RefreshToken)
 		q.Set("type", params.Type)
-		rurl += "#" + q.Encode()
+
+		rurl = token.AsRedirectURL(rurl, q)
 	}
+
 	http.Redirect(w, r, rurl, http.StatusSeeOther)
 	return nil
 }
diff --git a/conf/configuration.go b/conf/configuration.go
index 7937bc39..f84efd9c 100644
--- a/conf/configuration.go
+++ b/conf/configuration.go
@@ -2,8 +2,10 @@ package conf
 
 import (
 	"errors"
+	"fmt"
 	"net/url"
 	"os"
+	"strings"
 	"time"
 
 	"github.com/gobwas/glob"
@@ -219,6 +221,24 @@ type CaptchaConfiguration struct {
 	Secret   string `json:"provider_secret"`
 }
 
+func (c *CaptchaConfiguration) Validate() error {
+	if !c.Enabled {
+		return nil
+	}
+
+	if c.Provider != "hcaptcha" {
+		return fmt.Errorf("unsupported captcha provider: %s", c.Provider)
+	}
+
+	c.Secret = strings.TrimSpace(c.Secret)
+
+	if c.Secret == "" {
+		return errors.New("hcaptcha provider secret is empty")
+	}
+
+	return nil
+}
+
 type SecurityConfiguration struct {
 	Captcha                               CaptchaConfiguration `json:"captcha"`
 	RefreshTokenRotationEnabled           bool                 `json:"refresh_token_rotation_enabled" split_words:"true" default:"true"`
@@ -226,6 +246,10 @@ type SecurityConfiguration struct {
 	UpdatePasswordRequireReauthentication bool                 `json:"update_password_require_reauthentication" split_words:"true"`
 }
 
+func (c *SecurityConfiguration) Validate() error {
+	return c.Captcha.Validate()
+}
+
 func loadEnvironment(filename string) error {
 	var err error
 	if filename != "" {
@@ -393,6 +417,7 @@ func (c *GlobalConfiguration) Validate() error {
 		&c.Metrics,
 		&c.SMTP,
 		&c.SAML,
+		&c.Security,
 	}
 
 	for _, validatable := range validatables {
diff --git a/conf/saml.go b/conf/saml.go
index 50dd8836..f78ff7f3 100644
--- a/conf/saml.go
+++ b/conf/saml.go
@@ -20,6 +20,8 @@ type SAMLConfiguration struct {
 	RSAPrivateKey *rsa.PrivateKey   `json:"-"`
 	RSAPublicKey  *rsa.PublicKey    `json:"-"`
 	Certificate   *x509.Certificate `json:"-"`
+
+	RateLimitAssertion float64 `default:"15" split_words:"true"`
 }
 
 func (c *SAMLConfiguration) Validate() error {
diff --git a/crypto/password.go b/crypto/password.go
new file mode 100644
index 00000000..18b57224
--- /dev/null
+++ b/crypto/password.go
@@ -0,0 +1,111 @@
+package crypto
+
+import (
+	"context"
+	"errors"
+
+	"github.com/netlify/gotrue/observability"
+	"go.opentelemetry.io/otel/attribute"
+	metricinstrument "go.opentelemetry.io/otel/metric/instrument"
+	"golang.org/x/crypto/bcrypt"
+)
+
+type HashCost = int
+
+const (
+	// DefaultHashCost represents the default
+	// hashing cost for any hashing algorithm.
+	DefaultHashCost HashCost = iota
+
+	// QuickHashCosts represents the quickest
+	// hashing cost for any hashing algorithm,
+	// useful for tests only.
+	QuickHashCost HashCost = iota
+)
+
+// PasswordHashCost is the current pasword hashing cost
+// for all new hashes generated with
+// GenerateHashFromPassword.
+var PasswordHashCost = DefaultHashCost
+
+type metricCounter interface {
+	Add(ctx context.Context, incr int64, attrs ...attribute.KeyValue)
+}
+
+func obtainMetricCounter(name, desc string) metricCounter {
+	counter, err := observability.Meter("gotrue").SyncInt64().Counter(name, metricinstrument.WithDescription(desc))
+	if err != nil {
+		panic(err)
+	}
+
+	return counter
+}
+
+var (
+	generateFromPasswordSubmittedCounter = obtainMetricCounter("gotrue_generate_from_password_submitted", "Number of submitted GenerateFromPassword hashing attempts")
+	generateFromPasswordCompletedCounter = obtainMetricCounter("gotrue_generate_from_password_completed", "Number of completed GenerateFromPassword hashing attempts")
+)
+
+var (
+	compareHashAndPasswordSubmittedCounter = obtainMetricCounter("gotrue_compare_hash_and_password_submitted", "Number of submitted CompareHashAndPassword hashing attempts")
+	compareHashAndPasswordCompletedCounter = obtainMetricCounter("gotrue_compare_hash_and_password_completed", "Number of completed CompareHashAndPassword hashing attempts")
+)
+
+// CompareHashAndPassword compares the hash and
+// password, returns nil if equal otherwise an error. Context can be used to
+// cancel the hashing if the algorithm supports it.
+func CompareHashAndPassword(ctx context.Context, hash, password string) error {
+	hashCost, err := bcrypt.Cost([]byte(hash))
+	if err != nil {
+		return err
+	}
+
+	attributes := []attribute.KeyValue{
+		attribute.String("alg", "bcrypt"),
+		attribute.Int("bcrypt_cost", hashCost),
+	}
+
+	compareHashAndPasswordSubmittedCounter.Add(ctx, 1, attributes...)
+	defer func() {
+		attributes = append(attributes, attribute.Bool(
+			"match",
+			!errors.Is(err, bcrypt.ErrMismatchedHashAndPassword),
+		))
+
+		compareHashAndPasswordCompletedCounter.Add(ctx, 1, attributes...)
+	}()
+
+	err = bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
+
+	return err
+}
+
+// GenerateFromPassword generates a password hash from a
+// password, using PasswordHashCost. Context can be used to cancel the hashing
+// if the algorithm supports it.
+func GenerateFromPassword(ctx context.Context, password string) (string, error) {
+        var hashCost int
+
+	switch PasswordHashCost {
+	case QuickHashCost:
+		hashCost = bcrypt.MinCost
+
+        default:
+		hashCost = bcrypt.DefaultCost
+	}
+
+	attributes := []attribute.KeyValue{
+		attribute.String("alg", "bcrypt"),
+		attribute.Int("bcrypt_cost", hashCost),
+	}
+
+	generateFromPasswordSubmittedCounter.Add(ctx, 1, attributes...)
+	defer generateFromPasswordCompletedCounter.Add(ctx, 1, attributes...)
+
+	hash, err := bcrypt.GenerateFromPassword([]byte(password), hashCost)
+	if err != nil {
+		return "", err
+	}
+
+	return string(hash), nil
+}
diff --git a/go.mod b/go.mod
index eae28f73..c0602a63 100644
--- a/go.mod
+++ b/go.mod
@@ -36,7 +36,7 @@ require (
 	github.com/sirupsen/logrus v1.8.1
 	github.com/spf13/cobra v1.1.3
 	github.com/stretchr/testify v1.8.0
-	golang.org/x/crypto v0.0.0-20201221181555-eec23a3978ad
+	golang.org/x/crypto v0.0.0-20220128200615-198e4374d7ed
 	golang.org/x/oauth2 v0.0.0-20211104180415-d3ed0bb246c8
 	gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df
 )
@@ -65,13 +65,18 @@ require (
 	gopkg.in/h2non/gock.v1 v1.1.2
 )
 
-require github.com/jackc/pgx/v4 v4.10.1
+require (
+	github.com/crewjam/saml v0.4.8
+	github.com/jackc/pgx/v4 v4.10.1
+)
 
 require (
 	github.com/aymerick/douceur v0.2.0 // indirect
+	github.com/beevik/etree v1.1.0 // indirect
 	github.com/beorn7/perks v1.0.1 // indirect
 	github.com/cenkalti/backoff/v4 v4.1.3 // indirect
 	github.com/cespare/xxhash/v2 v2.1.2 // indirect
+	github.com/crewjam/httperr v0.2.0 // indirect
 	github.com/davecgh/go-spew v1.1.1 // indirect
 	github.com/fatih/color v1.10.0 // indirect
 	github.com/fatih/structs v1.1.0 // indirect
@@ -88,6 +93,7 @@ require (
 	github.com/gobuffalo/packr/v2 v2.8.1 // indirect
 	github.com/gobuffalo/plush/v4 v4.1.0 // indirect
 	github.com/gobuffalo/tags/v3 v3.1.0 // indirect
+	github.com/golang-jwt/jwt/v4 v4.4.2 // indirect
 	github.com/golang/protobuf v1.5.2 // indirect
 	github.com/gorilla/context v1.1.1 // indirect
 	github.com/gorilla/css v1.0.0 // indirect
@@ -99,9 +105,10 @@ require (
 	github.com/jackc/pgpassfile v1.0.0 // indirect
 	github.com/jackc/pgservicefile v0.0.0-20200714003250-2b9c44734f2b // indirect
 	github.com/jackc/pgtype v1.6.2 // indirect
+	github.com/jonboulle/clockwork v0.2.2 // indirect
 	github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
-	github.com/kr/pretty v0.3.0 // indirect
 	github.com/luna-duclos/instrumentedsql v1.1.3 // indirect
+	github.com/mattermost/xml-roundtrip-validator v0.1.0 // indirect
 	github.com/mattn/go-colorable v0.1.8 // indirect
 	github.com/mattn/go-isatty v0.0.12 // indirect
 	github.com/mattn/go-sqlite3 v2.0.3+incompatible // indirect
@@ -115,6 +122,7 @@ require (
 	github.com/prometheus/common v0.32.1 // indirect
 	github.com/prometheus/procfs v0.7.3 // indirect
 	github.com/rogpeppe/go-internal v1.8.0 // indirect
+	github.com/russellhaering/goxmldsig v1.1.1 // indirect
 	github.com/sergi/go-diff v1.1.0 // indirect
 	github.com/sourcegraph/annotate v0.0.0-20160123013949-f4cad6c6324d // indirect
 	github.com/sourcegraph/syntaxhighlight v0.0.0-20170531221838-bd320f5d308e // indirect
@@ -134,7 +142,6 @@ require (
 	google.golang.org/grpc v1.46.2 // indirect
 	google.golang.org/protobuf v1.28.0 // indirect
 	gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect
-	gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
 	gopkg.in/square/go-jose.v2 v2.5.1 // indirect
 	gopkg.in/yaml.v2 v2.4.0 // indirect
 	gopkg.in/yaml.v3 v3.0.1 // indirect
diff --git a/go.sum b/go.sum
index 5d3701eb..bdf77265 100644
--- a/go.sum
+++ b/go.sum
@@ -62,6 +62,8 @@ github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuP
 github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
 github.com/badoux/checkmail v0.0.0-20170203135005-d0a759655d62 h1:vMqcPzLT1/mbYew0gM6EJy4/sCNy9lY9rmlFO+pPwhY=
 github.com/badoux/checkmail v0.0.0-20170203135005-d0a759655d62/go.mod h1:r5ZalvRl3tXevRNJkwIB6DC4DD3DMjIlY9NEU1XGoaQ=
+github.com/beevik/etree v1.1.0 h1:T0xke/WvNtMoCqgzPhkX2r4rjY3GDZFi+FjpRZY2Jbs=
+github.com/beevik/etree v1.1.0/go.mod h1:r8Aw8JqVegEf0w2fDnATrX9VpkMcyFeM0FhwO62wh+A=
 github.com/benbjohnson/clock v1.3.0 h1:ip6w0uFQkncKQ979AypyG0ER7mqUSBdKLOgAle/AT8A=
 github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
 github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8=
@@ -115,6 +117,10 @@ github.com/cpuguy83/go-md2man v1.0.10/go.mod h1:SmD6nW6nTyfqj6ABTjUi3V3JVMnlJmwc
 github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
 github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY=
 github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
+github.com/crewjam/httperr v0.2.0 h1:b2BfXR8U3AlIHwNeFFvZ+BV1LFvKLlzMjzaTnZMybNo=
+github.com/crewjam/httperr v0.2.0/go.mod h1:Jlz+Sg/XqBQhyMjdDiC+GNNRzZTD7x39Gu3pglZ5oH4=
+github.com/crewjam/saml v0.4.8 h1:XPrpg7DOIqh7AvMXPMRzHylzzb/ltgnfRRilwCgYb5A=
+github.com/crewjam/saml v0.4.8/go.mod h1:9Zh6dWPtB3MSzTRt8fIFH60Z351QQ+s7hCU3J/tTlA4=
 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -217,6 +223,8 @@ github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7a
 github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zVXpSg4=
 github.com/golang-jwt/jwt v3.2.1+incompatible h1:73Z+4BJcrTC+KczS6WvTPvRGOp1WmfEP4Q1lOd9Z/+c=
 github.com/golang-jwt/jwt v3.2.1+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I=
+github.com/golang-jwt/jwt/v4 v4.4.2 h1:rcc4lwaZgFMCZ5jxF9ABolDcIHdBytAFgqFPbSJQAYs=
+github.com/golang-jwt/jwt/v4 v4.4.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
 github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
 github.com/golang/glog v1.0.0 h1:nfP3RFugxnNRyKgeWd4oI1nYvXpxrx8ck8ZrcizshdQ=
 github.com/golang/glog v1.0.0/go.mod h1:EWib/APOK0SL3dFbYqvxE3UYd8E6s1ouQ7iEp/0LWV4=
@@ -261,6 +269,7 @@ github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
 github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
 github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
 github.com/google/go-cmp v0.5.8 h1:e6P7q2lk1O+qJJb4BtCQXlK8vWEO8V1ZeuEdJNOqZyg=
+github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
 github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
 github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
 github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
@@ -386,6 +395,8 @@ github.com/jmoiron/sqlx v1.3.1/go.mod h1:2BljVx/86SuTyjE+aPYlHCTNvZrnJXghYGpNiXL
 github.com/joho/godotenv v1.3.0 h1:Zjp+RcGpHhGlrMbJzXTrZZPrWj+1vfm90La1wgB6Bhc=
 github.com/joho/godotenv v1.3.0/go.mod h1:7hK45KPybAkOC6peb+G5yklZfMxEjkZhHbwpqxOKXbg=
 github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22gdxWY5EU2bo=
+github.com/jonboulle/clockwork v0.2.2 h1:UOGuzwb1PwsrDAObMuhUnj0p5ULPj8V/xJ7Kx9qUBdQ=
+github.com/jonboulle/clockwork v0.2.2/go.mod h1:Pkfl5aHPm1nk2H9h0bjmnJD/BcgbGXUBGnn1kMkgxc8=
 github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4=
 github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
 github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
@@ -440,6 +451,8 @@ github.com/markbates/oncer v1.0.0 h1:E83IaVAHygyndzPimgUYJjbshhDTALZyXxvk9FOlQRY
 github.com/markbates/oncer v1.0.0/go.mod h1:Z59JA581E9GP6w96jai+TGqafHPW+cPfRxz2aSZ0mcI=
 github.com/markbates/safe v1.0.1 h1:yjZkbvRM6IzKj9tlu/zMJLS0n/V351OZWRnF3QfaUxI=
 github.com/markbates/safe v1.0.1/go.mod h1:nAqgmRi7cY2nqMc92/bSEeQA+R4OheNU2T1kNSCBdG0=
+github.com/mattermost/xml-roundtrip-validator v0.1.0 h1:RXbVD2UAl7A7nOTR4u7E3ILa4IbtvKBHw64LDsmu9hU=
+github.com/mattermost/xml-roundtrip-validator v0.1.0/go.mod h1:qccnGMcpgwcNaBnxqpJpWWUiPNr5H3O8eDgGV9gT5To=
 github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU=
 github.com/mattn/go-colorable v0.1.1/go.mod h1:FuOcm+DKB9mbwrcAfNl7/TZVBZ6rcnceauSikq3lYCQ=
 github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
@@ -564,6 +577,8 @@ github.com/rs/cors v1.6.0/go.mod h1:gFx+x8UowdsKA9AchylcLynDq+nNFfI8FkUZdN/jGCU=
 github.com/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ=
 github.com/rs/zerolog v1.13.0/go.mod h1:YbFCdg8HfsridGWAh22vktObvhZbQsZXe4/zB0OKkWU=
 github.com/rs/zerolog v1.15.0/go.mod h1:xYTKnLHcpfU2225ny5qZjxnj9NvkumZYjJHlAThCjNc=
+github.com/russellhaering/goxmldsig v1.1.1 h1:vI0r2osGF1A9PLvsGdPUAGwEIrKa4Pj5sesSBsebIxM=
+github.com/russellhaering/goxmldsig v1.1.1/go.mod h1:gM4MDENBQf7M+V824SGfyIUVFWydB7n0KkEubVJl+Tw=
 github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g=
 github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
 github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts=
@@ -620,6 +635,7 @@ github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXf
 github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
 github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
 github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
+github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
 github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
 github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
 github.com/stretchr/testify v1.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PKk=
@@ -702,8 +718,8 @@ golang.org/x/crypto v0.0.0-20191122220453-ac88ee75c92c/go.mod h1:LzIPMQfyMNhhGPh
 golang.org/x/crypto v0.0.0-20200323165209-0ec3e9974c59/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
 golang.org/x/crypto v0.0.0-20200604202706-70a84ac30bf9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
 golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
-golang.org/x/crypto v0.0.0-20201221181555-eec23a3978ad h1:DN0cp81fZ3njFcrLCytUHRSUkqBjfTo4Tx9RJTWs0EY=
-golang.org/x/crypto v0.0.0-20201221181555-eec23a3978ad/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I=
+golang.org/x/crypto v0.0.0-20220128200615-198e4374d7ed h1:YoWVYYAfvQ4ddHv3OKmIvX7NCAhFGTj62VP2l2kfBbA=
+golang.org/x/crypto v0.0.0-20220128200615-198e4374d7ed/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
 golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
 golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
 golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
@@ -773,6 +789,7 @@ golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81R
 golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
 golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
 golang.org/x/net v0.0.0-20210525063256-abc453219eb5/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
+golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
 golang.org/x/net v0.0.0-20220121210141-e204ce36a2ba h1:6u6sik+bn/y7vILcYkK3iwTBWN7WtBvB0+SZswQnbf8=
 golang.org/x/net v0.0.0-20220121210141-e204ce36a2ba/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
 golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
@@ -845,10 +862,10 @@ golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7w
 golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20210603081109-ebe580a85c40/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20220114195835-da31bd327af9/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20220422013727-9388b58f7150 h1:xHms4gcpe1YE7A3yIllJXP16CMAGuqwO2lX1mTyyRRc=
 golang.org/x/sys v0.0.0-20220422013727-9388b58f7150/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
 golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 h1:JGgROgKl9N8DuW20oFS5gxc+lE67/N3FcwmBPMe7ArY=
 golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
@@ -1047,8 +1064,11 @@ gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
 gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
 gopkg.in/yaml.v3 v3.0.0-20190924164351-c8b7dadae555/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
 gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
 gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
 gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gotest.tools v2.2.0+incompatible h1:VsBPFP1AI068pPrMxtb/S8Zkgf9xEmTLJjfM+P5UIEo=
+gotest.tools v2.2.0+incompatible/go.mod h1:DsYFclhRJ6vuDpmuTbkuFWG+y2sxOXAzmJt81HFBacw=
 honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
 honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
 honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
diff --git a/migrations/20221021082433_add_saml.up.sql b/migrations/20221021082433_add_saml.up.sql
index 68b794b3..30ac3d03 100644
--- a/migrations/20221021082433_add_saml.up.sql
+++ b/migrations/20221021082433_add_saml.up.sql
@@ -1,6 +1,6 @@
 -- Multi-instance mode (see auth.instances) table intentionally not supported and ignored.
 
-create table if not exists auth.sso_providers (
+create table if not exists {{ index .Options "Namespace" }}.sso_providers (
 	id uuid not null,
 	resource_id text null,
 	created_at timestamptz null,
@@ -9,28 +9,28 @@ create table if not exists auth.sso_providers (
 	constraint "resource_id not empty" check (resource_id = null or char_length(resource_id) > 0)
 );
 
-comment on table auth.sso_providers is 'Auth: Manages SSO identity provider information; see saml_providers for SAML.';
-comment on column auth.sso_providers.resource_id is 'Auth: Uniquely identifies a SSO provider according to a user-chosen resource ID (case insensitive), useful in infrastructure as code.';
+comment on table {{ index .Options "Namespace" }}.sso_providers is 'Auth: Manages SSO identity provider information; see saml_providers for SAML.';
+comment on column {{ index .Options "Namespace" }}.sso_providers.resource_id is 'Auth: Uniquely identifies a SSO provider according to a user-chosen resource ID (case insensitive), useful in infrastructure as code.';
 
-create unique index if not exists sso_providers_resource_id_idx on auth.sso_providers (lower(resource_id));
+create unique index if not exists sso_providers_resource_id_idx on {{ index .Options "Namespace" }}.sso_providers (lower(resource_id));
 
-create table if not exists auth.sso_domains (
+create table if not exists {{ index .Options "Namespace" }}.sso_domains (
 	id uuid not null,
 	sso_provider_id uuid not null,
 	domain text not null,
 	created_at timestamptz null,
 	updated_at timestamptz null,
 	primary key (id),
-	foreign key (sso_provider_id) references auth.sso_providers (id) on delete cascade,
+	foreign key (sso_provider_id) references {{ index .Options "Namespace" }}.sso_providers (id) on delete cascade,
 	constraint "domain not empty" check (char_length(domain) > 0)
 );
 
-create index if not exists sso_domains_sso_provider_id_idx on auth.sso_domains (sso_provider_id);
-create unique index if not exists sso_domains_domain_idx on auth.sso_domains (lower(domain));
+create index if not exists sso_domains_sso_provider_id_idx on {{ index .Options "Namespace" }}.sso_domains (sso_provider_id);
+create unique index if not exists sso_domains_domain_idx on {{ index .Options "Namespace" }}.sso_domains (lower(domain));
 
-comment on table auth.sso_domains is 'Auth: Manages SSO email address domain mapping to an SSO Identity Provider.';
+comment on table {{ index .Options "Namespace" }}.sso_domains is 'Auth: Manages SSO email address domain mapping to an SSO Identity Provider.';
 
-create table if not exists auth.saml_providers (
+create table if not exists {{ index .Options "Namespace" }}.saml_providers (
 	id uuid not null,
 	sso_provider_id uuid not null,
 	entity_id text not null unique,
@@ -40,17 +40,17 @@ create table if not exists auth.saml_providers (
 	created_at timestamptz null,
 	updated_at timestamptz null,
 	primary key (id),
-	foreign key (sso_provider_id) references auth.sso_providers (id) on delete cascade,
+	foreign key (sso_provider_id) references {{ index .Options "Namespace" }}.sso_providers (id) on delete cascade,
 	constraint "metadata_xml not empty" check (char_length(metadata_xml) > 0),
 	constraint "metadata_url not empty" check (metadata_url = null or char_length(metadata_url) > 0),
 	constraint "entity_id not empty" check (char_length(entity_id) > 0)
 );
 
-create index if not exists saml_providers_sso_provider_id_idx on auth.saml_providers (sso_provider_id);
+create index if not exists saml_providers_sso_provider_id_idx on {{ index .Options "Namespace" }}.saml_providers (sso_provider_id);
 
-comment on table auth.saml_providers is 'Auth: Manages SAML Identity Provider connections.';
+comment on table {{ index .Options "Namespace" }}.saml_providers is 'Auth: Manages SAML Identity Provider connections.';
 
-create table if not exists auth.saml_relay_states (
+create table if not exists {{ index .Options "Namespace" }}.saml_relay_states (
 	id uuid not null,
 	sso_provider_id uuid not null,
 	request_id text not null,
@@ -60,16 +60,16 @@ create table if not exists auth.saml_relay_states (
 	created_at timestamptz null,
 	updated_at timestamptz null,
 	primary key (id),
-	foreign key (sso_provider_id) references auth.sso_providers (id) on delete cascade,
+	foreign key (sso_provider_id) references {{ index .Options "Namespace" }}.sso_providers (id) on delete cascade,
 	constraint "request_id not empty" check(char_length(request_id) > 0)
 );
 
-create index if not exists saml_relay_states_sso_provider_id_idx on auth.saml_relay_states (sso_provider_id);
-create index if not exists saml_relay_states_for_email_idx on auth.saml_relay_states (for_email);
+create index if not exists saml_relay_states_sso_provider_id_idx on {{ index .Options "Namespace" }}.saml_relay_states (sso_provider_id);
+create index if not exists saml_relay_states_for_email_idx on {{ index .Options "Namespace" }}.saml_relay_states (for_email);
 
-comment on table auth.saml_relay_states is 'Auth: Contains SAML Relay State information for each Service Provider initiated login.';
+comment on table {{ index .Options "Namespace" }}.saml_relay_states is 'Auth: Contains SAML Relay State information for each Service Provider initiated login.';
 
-create table if not exists auth.sso_sessions (
+create table if not exists {{ index .Options "Namespace" }}.sso_sessions (
 	id uuid not null,
 	session_id uuid not null,
 	sso_provider_id uuid null,
@@ -79,12 +79,12 @@ create table if not exists auth.sso_sessions (
 	created_at timestamptz null,
 	updated_at timestamptz null,
 	primary key (id),
-	foreign key (session_id) references auth.sessions (id) on delete cascade,
-	foreign key (sso_provider_id) references auth.sso_providers (id) on delete cascade
+	foreign key (session_id) references {{ index .Options "Namespace" }}.sessions (id) on delete cascade,
+	foreign key (sso_provider_id) references {{ index .Options "Namespace" }}.sso_providers (id) on delete cascade
 );
 
-create index if not exists sso_sessions_session_id_idx on auth.sso_sessions (session_id);
-create index if not exists sso_sessions_sso_provider_id_idx on auth.sso_sessions (sso_provider_id);
+create index if not exists sso_sessions_session_id_idx on {{ index .Options "Namespace" }}.sso_sessions (session_id);
+create index if not exists sso_sessions_sso_provider_id_idx on {{ index .Options "Namespace" }}.sso_sessions (sso_provider_id);
 
-comment on table auth.sso_sessions is 'Auth: A session initiated by an SSO Identity Provider';
+comment on table {{ index .Options "Namespace" }}.sso_sessions is 'Auth: A session initiated by an SSO Identity Provider';
 
diff --git a/migrations/20221027105023_add_identities_user_id_idx.up.sql b/migrations/20221027105023_add_identities_user_id_idx.up.sql
new file mode 100644
index 00000000..12e7aa55
--- /dev/null
+++ b/migrations/20221027105023_add_identities_user_id_idx.up.sql
@@ -0,0 +1,1 @@
+create index if not exists identities_user_id_idx on {{ index .Options "Namespace" }}.identities using btree (user_id);
diff --git a/models/amr.go b/models/amr.go
index b68ca7cb..f951e63a 100644
--- a/models/amr.go
+++ b/models/amr.go
@@ -14,7 +14,7 @@ type AMRClaim struct {
 	SessionID            uuid.UUID `json:"session_id" db:"session_id"`
 	CreatedAt            time.Time `json:"created_at" db:"created_at"`
 	UpdatedAt            time.Time `json:"updated_at" db:"updated_at"`
-	AuthenticationMethod string    `json:"authentication_method" db:"authentication_method"`
+	AuthenticationMethod *string   `json:"authentication_method" db:"authentication_method"`
 }
 
 func (AMRClaim) TableName() string {
@@ -33,3 +33,10 @@ func AddClaimToSession(tx *storage.Connection, session *Session, authenticationM
 			ON CONFLICT ON CONSTRAINT mfa_amr_claims_session_id_authentication_method_pkey
 			DO UPDATE SET updated_at = ?;`, id, session.ID, currentTime, currentTime, authenticationMethod.String(), currentTime).Exec()
 }
+
+func (a *AMRClaim) GetAuthenticationMethod() string {
+	if a.AuthenticationMethod == nil {
+		return ""
+	}
+	return *(a.AuthenticationMethod)
+}
diff --git a/models/connection.go b/models/connection.go
index dae66955..a372b5fa 100644
--- a/models/connection.go
+++ b/models/connection.go
@@ -43,6 +43,11 @@ func TruncateAll(conn *storage.Connection) error {
 			(&pop.Model{Value: Factor{}}).TableName(),
 			(&pop.Model{Value: Challenge{}}).TableName(),
 			(&pop.Model{Value: AMRClaim{}}).TableName(),
+			(&pop.Model{Value: SSOProvider{}}).TableName(),
+			(&pop.Model{Value: SSODomain{}}).TableName(),
+			(&pop.Model{Value: SSOSession{}}).TableName(),
+			(&pop.Model{Value: SAMLProvider{}}).TableName(),
+			(&pop.Model{Value: SAMLRelayState{}}).TableName(),
 		}
 
 		for _, tableName := range tables {
diff --git a/models/errors.go b/models/errors.go
index e4ab935e..8b6e717f 100644
--- a/models/errors.go
+++ b/models/errors.go
@@ -17,9 +17,13 @@ func IsNotFoundError(err error) bool {
 		return true
 	case IdentityNotFoundError, *IdentityNotFoundError:
 		return true
-	case ChallengeNotFoundError:
+	case ChallengeNotFoundError, *ChallengeNotFoundError:
 		return true
-	case FactorNotFoundError:
+	case FactorNotFoundError, *FactorNotFoundError:
+		return true
+	case SSOProviderNotFoundError, *SSOProviderNotFoundError:
+		return true
+	case SAMLRelayStateNotFoundError, *SAMLRelayStateNotFoundError:
 		return true
 	}
 	return false
@@ -85,3 +89,19 @@ type TotpSecretNotFoundError struct{}
 func (e TotpSecretNotFoundError) Error() string {
 	return "Totp Secret not found"
 }
+
+// SSOProviderNotFoundError represents an error when a SSO Provider can't be
+// found.
+type SSOProviderNotFoundError struct{}
+
+func (e SSOProviderNotFoundError) Error() string {
+	return "SSO Identity Provider not found"
+}
+
+// SAMLRelayStateNotFoundError represents an error when a SAML relay state
+// can't be found.
+type SAMLRelayStateNotFoundError struct{}
+
+func (e SAMLRelayStateNotFoundError) Error() string {
+	return "SAML RelayState not found"
+}
diff --git a/models/factor.go b/models/factor.go
index 8169b867..8c73d879 100644
--- a/models/factor.go
+++ b/models/factor.go
@@ -24,6 +24,7 @@ const (
 	PasswordGrant
 	OTP
 	TOTPSignIn
+	SSOSAML
 )
 
 func (authMethod AuthenticationMethod) String() string {
@@ -36,6 +37,8 @@ func (authMethod AuthenticationMethod) String() string {
 		return "otp"
 	case TOTPSignIn:
 		return "totp"
+	case SSOSAML:
+		return "sso/saml"
 	}
 	return ""
 }
diff --git a/models/sessions.go b/models/sessions.go
index 2320b91e..a28d5bc4 100644
--- a/models/sessions.go
+++ b/models/sessions.go
@@ -61,7 +61,7 @@ type Session struct {
 	UpdatedAt time.Time  `json:"updated_at" db:"updated_at"`
 	FactorID  *uuid.UUID `json:"factor_id" db:"factor_id"`
 	AMRClaims []AMRClaim `json:"amr,omitempty" has_many:"amr_claims"`
-	AAL       string     `json:"aal" db:"aal"`
+	AAL       *string    `json:"aal" db:"aal"`
 }
 
 func (Session) TableName() string {
@@ -75,11 +75,13 @@ func NewSession(user *User, factorID *uuid.UUID) (*Session, error) {
 		return nil, errors.Wrap(err, "Error generating unique session id")
 	}
 
+	defaultAAL := AAL1.String()
+
 	session := &Session{
 		ID:        id,
 		UserID:    user.ID,
 		FactorID:  factorID,
-		AAL:       AAL1.String(),
+		AAL:       &defaultAAL,
 		AMRClaims: []AMRClaim{},
 	}
 	return session, nil
@@ -160,17 +162,17 @@ func (s *Session) UpdateAssociatedFactor(tx *storage.Connection, factorID *uuid.
 }
 
 func (s *Session) UpdateAssociatedAAL(tx *storage.Connection, aal string) error {
-	s.AAL = aal
+	s.AAL = &aal
 	return tx.Update(s)
 }
 
 func (s *Session) CalculateAALAndAMR() (aal string, amr []AMREntry) {
 	amr, aal = []AMREntry{}, AAL1.String()
 	for _, claim := range s.AMRClaims {
-		if claim.AuthenticationMethod == TOTPSignIn.String() {
+		if *claim.AuthenticationMethod == TOTPSignIn.String() {
 			aal = AAL2.String()
 		}
-		amr = append(amr, AMREntry{Method: claim.AuthenticationMethod, Timestamp: claim.UpdatedAt.Unix()})
+		amr = append(amr, AMREntry{Method: claim.GetAuthenticationMethod(), Timestamp: claim.UpdatedAt.Unix()})
 	}
 
 	// makes sure that the AMR claims are always ordered most-recent first
@@ -187,3 +189,10 @@ func (s *Session) CalculateAALAndAMR() (aal string, amr []AMREntry) {
 
 	return aal, amr
 }
+
+func (s *Session) GetAAL() string {
+	if s.AAL == nil {
+		return ""
+	}
+	return *(s.AAL)
+}
diff --git a/models/sso.go b/models/sso.go
new file mode 100644
index 00000000..1291ccd7
--- /dev/null
+++ b/models/sso.go
@@ -0,0 +1,248 @@
+package models
+
+import (
+	"database/sql"
+	"database/sql/driver"
+	"encoding/json"
+	"strings"
+	"time"
+
+	"github.com/crewjam/saml"
+	"github.com/crewjam/saml/samlsp"
+	"github.com/gofrs/uuid"
+	"github.com/netlify/gotrue/storage"
+	"github.com/pkg/errors"
+)
+
+type SSOProvider struct {
+	ID         uuid.UUID `db:"id" json:"id"`
+	ResourceID *string   `db:"resource_id" json:"resource_id,omitempty"`
+
+	SAMLProvider SAMLProvider `has_one:"saml_providers" fk_id:"sso_provider_id" json:"saml,omitempty"`
+	SSODomains   []SSODomain  `has_many:"sso_domains" fk_id:"sso_provider_id" json:"domains"`
+
+	CreatedAt time.Time `db:"created_at" json:"created_at"`
+	UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
+}
+
+func (p SSOProvider) TableName() string {
+	return "sso_providers"
+}
+
+func (p SSOProvider) Type() string {
+	return "saml"
+}
+
+type SAMLAttribute struct {
+	Name    string      `json:"name,omitempty"`
+	Names   []string    `json:"names,omitempty"`
+	Default interface{} `json:"default,omitempty"`
+}
+
+type SAMLAttributeMapping struct {
+	Keys map[string]SAMLAttribute `json:"keys,omitempty"`
+}
+
+func (m *SAMLAttributeMapping) Scan(src interface{}) error {
+	b, ok := src.([]byte)
+	if !ok {
+		return errors.New("scan source was not []byte")
+	}
+	err := json.Unmarshal(b, m)
+	if err != nil {
+		return err
+	}
+	return nil
+}
+
+func (m SAMLAttributeMapping) Value() (driver.Value, error) {
+	b, err := json.Marshal(m)
+	if err != nil {
+		return nil, err
+	}
+	return string(b), nil
+}
+
+type SAMLProvider struct {
+	ID uuid.UUID `db:"id" json:"-"`
+
+	SSOProvider   *SSOProvider `belongs_to:"sso_providers" json:"-"`
+	SSOProviderID uuid.UUID    `db:"sso_provider_id" json:"-"`
+
+	EntityID    string  `db:"entity_id" json:"entity_id"`
+	MetadataXML string  `db:"metadata_xml" json:"metadata_xml,omitempty"`
+	MetadataURL *string `db:"metadata_url" json:"metadata_url,omitempty"`
+
+	AttributeMapping SAMLAttributeMapping `db:"attribute_mapping" json:"attribute_mapping,omitempty"`
+
+	CreatedAt time.Time `db:"created_at" json:"-"`
+	UpdatedAt time.Time `db:"updated_at" json:"-"`
+}
+
+func (p SAMLProvider) TableName() string {
+	return "saml_providers"
+}
+
+func (p SAMLProvider) EntityDescriptor() (*saml.EntityDescriptor, error) {
+	return samlsp.ParseMetadata([]byte(p.MetadataXML))
+}
+
+type SSODomain struct {
+	ID uuid.UUID `db:"id" json:"-"`
+
+	SSOProvider   *SSOProvider `belongs_to:"sso_providers" json:"-"`
+	SSOProviderID uuid.UUID    `db:"sso_provider_id" json:"-"`
+
+	Domain string `db:"domain" json:"domain"`
+
+	CreatedAt time.Time `db:"created_at" json:"-"`
+	UpdatedAt time.Time `db:"updated_at" json:"-"`
+}
+
+func (d SSODomain) TableName() string {
+	return "sso_domains"
+}
+
+type SSOSession struct {
+	ID uuid.UUID `db:"id"`
+
+	Session   *Session  `belongs_to:"session"`
+	SessionID uuid.UUID `db:"session_id"`
+
+	SSOProvider   *SSOProvider `belongs_to:"sso_providers"`
+	SSOProviderID uuid.UUID    `db:"sso_provider_id"`
+
+	NotBefore time.Time `db:"not_before"`
+	NotAfter  time.Time `db:"not_after"`
+
+	IdPInitiated bool `db:"idp_initiated"`
+
+	CreatedAt time.Time `db:"created_at" json:"-"`
+	UpdatedAt time.Time `db:"updated_at" json:"-"`
+}
+
+func (s SSOSession) TableName() string {
+	return "sso_sessions"
+}
+
+type SAMLRelayState struct {
+	ID uuid.UUID `db:"id"`
+
+	SSOProviderID uuid.UUID `db:"sso_provider_id"`
+
+	RequestID     string  `db:"request_id"`
+	ForEmail      *string `db:"for_email"`
+	FromIPAddress string  `db:"from_ip_address"`
+
+	RedirectTo string `db:"redirect_to"`
+
+	CreatedAt time.Time `db:"created_at" json:"-"`
+	UpdatedAt time.Time `db:"updated_at" json:"-"`
+}
+
+func (s SAMLRelayState) TableName() string {
+	return "saml_relay_states"
+}
+
+func FindSSOProviderByResourceID(tx *storage.Connection, resourceID string) (*SSOProvider, error) {
+	var ssoProvider SSOProvider
+	if err := tx.Eager().Q().Where("resource_id = ?", strings.ToLower(resourceID)).First(&ssoProvider); err != nil {
+		if errors.Cause(err) == sql.ErrNoRows {
+			return nil, SSOProviderNotFoundError{}
+		}
+
+		return nil, errors.Wrap(err, "error finding SSO provider by resource ID")
+	}
+
+	return &ssoProvider, nil
+}
+
+func FindSAMLProviderByEntityID(tx *storage.Connection, entityId string) (*SSOProvider, error) {
+	var samlProvider SAMLProvider
+	if err := tx.Q().Where("entity_id = ?", entityId).First(&samlProvider); err != nil {
+		if errors.Cause(err) == sql.ErrNoRows {
+			return nil, SSOProviderNotFoundError{}
+		}
+
+		return nil, errors.Wrap(err, "error finding SAML SSO provider by EntityID")
+	}
+
+	var ssoProvider SSOProvider
+	if err := tx.Eager().Q().Where("id = ?", samlProvider.SSOProviderID).First(&ssoProvider); err != nil {
+		return nil, errors.Wrap(err, "error finding SAML SSO provider by ID (via EntityID)")
+	}
+
+	return &ssoProvider, nil
+}
+
+func FindSSOProviderByID(tx *storage.Connection, id uuid.UUID) (*SSOProvider, error) {
+	var ssoProvider SSOProvider
+
+	if err := tx.Eager().Q().Where("id = ?", id).First(&ssoProvider); err != nil {
+		if errors.Cause(err) == sql.ErrNoRows {
+			return nil, SSOProviderNotFoundError{}
+		}
+
+		return nil, errors.Wrap(err, "error finding SAML SSO provider by ID")
+	}
+
+	return &ssoProvider, nil
+}
+
+func FindSSOProviderForEmailAddress(tx *storage.Connection, emailAddress string) (*SSOProvider, error) {
+	parts := strings.Split(emailAddress, "@")
+	emailDomain := strings.ToLower(parts[1])
+
+	return FindSSOProviderByDomain(tx, emailDomain)
+}
+
+func FindSSOProviderByDomain(tx *storage.Connection, domain string) (*SSOProvider, error) {
+	var ssoDomain SSODomain
+
+	if err := tx.Q().Where("domain = ?", domain).First(&ssoDomain); err != nil {
+		if errors.Cause(err) == sql.ErrNoRows {
+			return nil, SSOProviderNotFoundError{}
+		}
+
+		return nil, errors.Wrap(err, "error finding SAML SSO domain")
+	}
+
+	var ssoProvider SSOProvider
+	if err := tx.Eager().Q().Where("id = ?", ssoDomain.SSOProviderID).First(&ssoProvider); err != nil {
+		if errors.Cause(err) == sql.ErrNoRows {
+			return nil, SSOProviderNotFoundError{}
+		}
+
+		return nil, errors.Wrap(err, "error finding SAML SSO provider by ID (via domain)")
+	}
+
+	return &ssoProvider, nil
+}
+
+func FindAllSAMLProviders(tx *storage.Connection) ([]SSOProvider, error) {
+	var providers []SSOProvider
+
+	if err := tx.Eager().All(&providers); err != nil {
+		if errors.Cause(err) == sql.ErrNoRows {
+			return nil, nil
+		}
+
+		return nil, errors.Wrap(err, "error loading all SAML SSO providers")
+	}
+
+	return providers, nil
+}
+
+func FindSAMLRelayStateByID(tx *storage.Connection, id uuid.UUID) (*SAMLRelayState, error) {
+	var state SAMLRelayState
+
+	if err := tx.Eager().Q().Where("id = ?", id).First(&state); err != nil {
+		if errors.Cause(err) == sql.ErrNoRows {
+			return nil, SAMLRelayStateNotFoundError{}
+		}
+
+		return nil, errors.Wrap(err, "error loading SAML Relay State")
+	}
+
+	return &state, nil
+}
diff --git a/models/user.go b/models/user.go
index 4f4116ef..a3245cd1 100644
--- a/models/user.go
+++ b/models/user.go
@@ -1,20 +1,18 @@
 package models
 
 import (
+	"context"
 	"database/sql"
-	"fmt"
 	"strings"
 	"time"
 
 	"github.com/gobuffalo/pop/v5"
 	"github.com/gofrs/uuid"
+	"github.com/netlify/gotrue/crypto"
 	"github.com/netlify/gotrue/storage"
 	"github.com/pkg/errors"
-	"golang.org/x/crypto/bcrypt"
 )
 
-var PasswordHashCost = bcrypt.DefaultCost
-
 // User respresents a registered user with email/password authentication
 type User struct {
 	ID uuid.UUID `json:"id" db:"id"`
@@ -72,7 +70,7 @@ func NewUser(phone, email, password, aud string, userData map[string]interface{}
 	if err != nil {
 		return nil, errors.Wrap(err, "Error generating unique id")
 	}
-	pw, err := hashPassword(password)
+	pw, err := crypto.GenerateFromPassword(context.Background(), password)
 	if err != nil {
 		return nil, err
 	}
@@ -221,18 +219,9 @@ func (u *User) SetPhone(tx *storage.Connection, phone string) error {
 	return tx.UpdateOnly(u, "phone")
 }
 
-// hashPassword generates a hashed password from a plaintext string
-func hashPassword(password string) (string, error) {
-	pw, err := bcrypt.GenerateFromPassword([]byte(password), PasswordHashCost)
-	if err != nil {
-		return "", err
-	}
-	return string(pw), nil
-}
-
 // UpdatePassword updates the user's password
 func (u *User) UpdatePassword(tx *storage.Connection, password string) error {
-	pw, err := hashPassword(password)
+	pw, err := crypto.GenerateFromPassword(context.Background(), password)
 	if err != nil {
 		return err
 	}
@@ -248,7 +237,7 @@ func (u *User) UpdatePhone(tx *storage.Connection, phone string) error {
 
 // Authenticate a user from a password
 func (u *User) Authenticate(password string) bool {
-	err := bcrypt.CompareHashAndPassword([]byte(u.EncryptedPassword), []byte(password))
+	err := crypto.CompareHashAndPassword(context.Background(), u.EncryptedPassword, password)
 	return err == nil
 }
 
@@ -364,12 +353,6 @@ func FindUserByEmailChangeToken(tx *storage.Connection, token string) (*User, er
 	return findUser(tx, "email_change_token_current = ? or email_change_token_new = ?", token, token)
 }
 
-// FindUserByTokenAndTokenType finds a user with the matching token and token type.
-func FindUserByTokenAndTokenType(tx *storage.Connection, token string, tokenType string) (*User, error) {
-	query := fmt.Sprintf("%v = ?", tokenType)
-	return findUser(tx, query, token)
-}
-
 // FindUserWithRefreshToken finds a user from the provided refresh token.
 func FindUserWithRefreshToken(tx *storage.Connection, token string) (*User, *RefreshToken, error) {
 	refreshToken := &RefreshToken{}
diff --git a/security/hcaptcha.go b/security/hcaptcha.go
index ba8e6da1..427e762f 100644
--- a/security/hcaptcha.go
+++ b/security/hcaptcha.go
@@ -1,10 +1,7 @@
 package security
 
 import (
-	"bytes"
 	"encoding/json"
-	"fmt"
-	"io"
 	"log"
 	"net/http"
 	"net/url"
@@ -15,7 +12,6 @@ import (
 
 	"github.com/netlify/gotrue/utilities"
 	"github.com/pkg/errors"
-	"github.com/sirupsen/logrus"
 )
 
 type GotrueRequest struct {
@@ -32,14 +28,6 @@ type VerificationResponse struct {
 	Hostname   string   `json:"hostname"`
 }
 
-type VerificationResult int
-
-const (
-	UserRequestFailed VerificationResult = iota
-	VerificationProcessFailure
-	SuccessfullyVerified
-)
-
 var Client *http.Client
 
 func init() {
@@ -56,29 +44,30 @@ func init() {
 	Client = &http.Client{Timeout: defaultTimeout}
 }
 
-func VerifyRequest(r *http.Request, secretKey string) (VerificationResult, error) {
-	res := GotrueRequest{}
-	bodyBytes, err := io.ReadAll(r.Body)
+func VerifyRequest(r *http.Request, secretKey string) (VerificationResponse, error) {
+	bodyBytes, err := utilities.GetBodyBytes(r)
 	if err != nil {
-		return UserRequestFailed, err
+		return VerificationResponse{}, err
 	}
-	if err := r.Body.Close(); err != nil {
-		return UserRequestFailed, err
+
+	var requestBody GotrueRequest
+
+	if err := json.Unmarshal(bodyBytes, &requestBody); err != nil {
+		return VerificationResponse{}, errors.Wrap(err, "request body was not JSON")
 	}
 
-	// re-init body so downstream route handlers don't get borked
-	r.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))
+	captchaResponse := strings.TrimSpace(requestBody.Security.Token)
 
-	jsonDecoder := json.NewDecoder(bytes.NewBuffer(bodyBytes))
-	err = jsonDecoder.Decode(&res)
-	if err != nil || strings.TrimSpace(res.Security.Token) == "" {
-		return UserRequestFailed, errors.Wrap(err, "couldn't decode captcha info")
+	if captchaResponse == "" {
+		return VerificationResponse{}, errors.New("no hCaptcha response (captcha_token) found in request")
 	}
+
 	clientIP := utilities.GetIPAddress(r)
-	return verifyCaptchaCode(res.Security.Token, secretKey, clientIP)
+
+	return verifyCaptchaCode(captchaResponse, secretKey, clientIP)
 }
 
-func verifyCaptchaCode(token string, secretKey string, clientIP string) (VerificationResult, error) {
+func verifyCaptchaCode(token string, secretKey string, clientIP string) (VerificationResponse, error) {
 	data := url.Values{}
 	data.Set("secret", secretKey)
 	data.Set("response", token)
@@ -87,24 +76,21 @@ func verifyCaptchaCode(token string, secretKey string, clientIP string) (Verific
 
 	r, err := http.NewRequest("POST", "https://hcaptcha.com/siteverify", strings.NewReader(data.Encode()))
 	if err != nil {
-		return VerificationProcessFailure, errors.Wrap(err, "couldn't initialize request object for hcaptcha check")
+		return VerificationResponse{}, errors.Wrap(err, "couldn't initialize request object for hcaptcha check")
 	}
 	r.Header.Add("Content-Type", "application/x-www-form-urlencoded")
 	r.Header.Add("Content-Length", strconv.Itoa(len(data.Encode())))
 	res, err := Client.Do(r)
 	if err != nil {
-		return VerificationProcessFailure, errors.Wrap(err, "failed to verify hcaptcha token")
+		return VerificationResponse{}, errors.Wrap(err, "failed to verify hcaptcha response")
 	}
-	verResult := VerificationResponse{}
 	defer res.Body.Close()
-	decoder := json.NewDecoder(res.Body)
-	err = decoder.Decode(&verResult)
-	if err != nil {
-		return VerificationProcessFailure, errors.Wrap(err, "failed to decode hcaptcha response")
-	}
-	logrus.WithField("result", verResult).Info("obtained hcaptcha verification result")
-	if !verResult.Success {
-		return UserRequestFailed, fmt.Errorf("user request suppressed by hcaptcha")
+
+	var verificationResponse VerificationResponse
+
+	if err := json.NewDecoder(res.Body).Decode(&verificationResponse); err != nil {
+		return VerificationResponse{}, errors.Wrap(err, "failed to decode hcaptcha response: not JSON")
 	}
-	return SuccessfullyVerified, nil
+
+	return verificationResponse, nil
 }
diff --git a/utilities/request.go b/utilities/request.go
index 3e244b7b..cf61ba6e 100644
--- a/utilities/request.go
+++ b/utilities/request.go
@@ -1,6 +1,8 @@
 package utilities
 
 import (
+	"bytes"
+	"io"
 	"net"
 	"net/http"
 	"strings"
@@ -33,3 +35,22 @@ func GetIPAddress(r *http.Request) string {
 
 	return ip
 }
+
+// GetBodyBytes reads the whole request body properly into a byte array.
+func GetBodyBytes(req *http.Request) ([]byte, error) {
+	if req.Body == nil || req.Body == http.NoBody {
+		return nil, nil
+	}
+
+	originalBody := req.Body
+	defer originalBody.Close()
+
+	buf, err := io.ReadAll(originalBody)
+	if err != nil {
+		return nil, err
+	}
+
+	req.Body = io.NopCloser(bytes.NewReader(buf))
+
+	return buf, nil
+}


Test patch:
diff --git a/api/api_test.go b/api/api_test.go
index d8faf948..a7b6d3c9 100644
--- a/api/api_test.go
+++ b/api/api_test.go
@@ -5,11 +5,10 @@ import (
 	"testing"
 
 	"github.com/netlify/gotrue/conf"
-	"github.com/netlify/gotrue/models"
+	"github.com/netlify/gotrue/crypto"
 	"github.com/netlify/gotrue/storage"
 	"github.com/netlify/gotrue/storage/test"
 	"github.com/stretchr/testify/require"
-	"golang.org/x/crypto/bcrypt"
 )
 
 const (
@@ -18,7 +17,7 @@ const (
 )
 
 func init() {
-	models.PasswordHashCost = bcrypt.MinCost
+	crypto.PasswordHashCost = crypto.QuickHashCost
 }
 
 // setupAPIForTest creates a new API to run tests with.
diff --git a/api/mfa_test.go b/api/mfa_test.go
index b4c5636f..40e7e1e6 100644
--- a/api/mfa_test.go
+++ b/api/mfa_test.go
@@ -327,7 +327,7 @@ func (ts *MFATestSuite) TestUnenrollVerifiedFactor() {
 				_, err = models.FindFactorByFactorID(ts.API.db, f.ID)
 				require.EqualError(ts.T(), err, models.FactorNotFoundError{}.Error())
 				session, _ := models.FindSessionById(ts.API.db, secondarySession.ID)
-				require.Equal(ts.T(), models.AAL1.String(), session.AAL)
+				require.Equal(ts.T(), models.AAL1.String(), session.GetAAL())
 				require.Nil(ts.T(), session.FactorID)
 
 			}
@@ -368,7 +368,7 @@ func (ts *MFATestSuite) TestUnenrollUnverifiedFactor() {
 	_, err = models.FindFactorByFactorID(ts.API.db, f.ID)
 	require.EqualError(ts.T(), err, models.FactorNotFoundError{}.Error())
 	session, _ := models.FindSessionById(ts.API.db, secondarySession.ID)
-	require.Equal(ts.T(), models.AAL1.String(), session.AAL)
+	require.Equal(ts.T(), models.AAL1.String(), session.GetAAL())
 	require.Nil(ts.T(), session.FactorID)
 
 }
@@ -396,7 +396,7 @@ func (ts *MFATestSuite) TestSessionsMaintainAALOnRefresh() {
 	require.NoError(ts.T(), err)
 	ctx, err = ts.API.maybeLoadUserOrSession(ctx)
 	require.NoError(ts.T(), err)
-	require.Equal(ts.T(), models.AAL2.String(), getSession(ctx).AAL)
+	require.Equal(ts.T(), models.AAL2.String(), getSession(ctx).GetAAL())
 }
 
 // Performing MFA Verification followed by a sign in should return an AAL1 session and an AAL2 session
@@ -422,10 +422,10 @@ func (ts *MFATestSuite) TestMFAFollowedByPasswordSignIn() {
 	require.NoError(ts.T(), err)
 	ctx, err = ts.API.maybeLoadUserOrSession(ctx)
 	require.NoError(ts.T(), err)
-	require.Equal(ts.T(), models.AAL1.String(), getSession(ctx).AAL)
+	require.Equal(ts.T(), models.AAL1.String(), getSession(ctx).GetAAL())
 	session, err := models.FindSessionByUserID(ts.API.db, token.User.ID)
 	require.NoError(ts.T(), err)
-	require.Equal(ts.T(), models.AAL2.String(), session.AAL)
+	require.Equal(ts.T(), models.AAL2.String(), session.GetAAL())
 }
 
 func signUp(ts *MFATestSuite, email, password string) (signUpResp AccessTokenResponse) {
diff --git a/api/middleware_test.go b/api/middleware_test.go
index d5cdf3f6..fa95e1a5 100644
--- a/api/middleware_test.go
+++ b/api/middleware_test.go
@@ -114,25 +114,6 @@ func (ts *MiddlewareTestSuite) TestVerifyCaptchaInvalid() {
 		expectedCode int
 		expectedMsg  string
 	}{
-		{
-			"Unsupported provider",
-			&conf.CaptchaConfiguration{
-				Enabled:  true,
-				Provider: "test",
-			},
-			http.StatusInternalServerError,
-			"server misconfigured",
-		},
-		{
-			"Missing secret",
-			&conf.CaptchaConfiguration{
-				Enabled:  true,
-				Provider: "hcaptcha",
-				Secret:   "",
-			},
-			http.StatusInternalServerError,
-			"server misconfigured",
-		},
 		{
 			"Captcha validation failed",
 			&conf.CaptchaConfiguration{
@@ -140,8 +121,8 @@ func (ts *MiddlewareTestSuite) TestVerifyCaptchaInvalid() {
 				Provider: "hcaptcha",
 				Secret:   "test",
 			},
-			http.StatusInternalServerError,
-			"request validation failure",
+			http.StatusBadRequest,
+			"hCaptcha protection: request disallowed (not-using-dummy-secret)",
 		},
 	}
 	for _, c := range cases {
diff --git a/api/saml_test.go b/api/saml_test.go
new file mode 100644
index 00000000..af662b0b
--- /dev/null
+++ b/api/saml_test.go
@@ -0,0 +1,56 @@
+package api
+
+import (
+	tst "testing"
+
+	"encoding/xml"
+	"net/http"
+	"net/http/httptest"
+
+	"github.com/crewjam/saml"
+	"github.com/netlify/gotrue/conf"
+	"github.com/stretchr/testify/require"
+)
+
+func TestSAMLMetadataWithAPI(t *tst.T) {
+	config := &conf.GlobalConfiguration{}
+	config.API.ExternalURL = "https://projectref.supabase.co/auth/v1/"
+	config.SAML.Enabled = true
+	config.SAML.PrivateKey = "MIIEowIBAAKCAQEAszrVveMQcSsa0Y+zN1ZFb19cRS0jn4UgIHTprW2tVBmO2PABzjY3XFCfx6vPirMAPWBYpsKmXrvm1tr0A6DZYmA8YmJd937VUQ67fa6DMyppBYTjNgGEkEhmKuszvF3MARsIKCGtZqUrmS7UG4404wYxVppnr2EYm3RGtHlkYsXu20MBqSDXP47bQP+PkJqC3BuNGk3xt5UHl2FSFpTHelkI6lBynw16B+lUT1F96SERNDaMqi/TRsZdGe5mB/29ngC/QBMpEbRBLNRir5iUevKS7Pn4aph9Qjaxx/97siktK210FJT23KjHpgcUfjoQ6BgPBTLtEeQdRyDuc/CgfwIDAQABAoIBAGYDWOEpupQPSsZ4mjMnAYJwrp4ZISuMpEqVAORbhspVeb70bLKonT4IDcmiexCg7cQBcLQKGpPVM4CbQ0RFazXZPMVq470ZDeWDEyhoCfk3bGtdxc1Zc9CDxNMs6FeQs6r1beEZug6weG5J/yRn/qYxQife3qEuDMl+lzfl2EN3HYVOSnBmdt50dxRuX26iW3nqqbMRqYn9OHuJ1LvRRfYeyVKqgC5vgt/6Tf7DAJwGe0dD7q08byHV8DBZ0pnMVU0bYpf1GTgMibgjnLjK//EVWafFHtN+RXcjzGmyJrk3+7ZyPUpzpDjO21kpzUQLrpEkkBRnmg6bwHnSrBr8avECgYEA3pq1PTCAOuLQoIm1CWR9/dhkbJQiKTJevlWV8slXQLR50P0WvI2RdFuSxlWmA4xZej8s4e7iD3MYye6SBsQHygOVGc4efvvEZV8/XTlDdyj7iLVGhnEmu2r7AFKzy8cOvXx0QcLg+zNd7vxZv/8D3Qj9Jje2LjLHKM5n/dZ3RzUCgYEAzh5Lo2anc4WN8faLGt7rPkGQF+7/18ImQE11joHWa3LzAEy7FbeOGpE/vhOv5umq5M/KlWFIRahMEQv4RusieHWI19ZLIP+JwQFxWxS+cPp3xOiGcquSAZnlyVSxZ//dlVgaZq2o2MfrxECcovRlaknl2csyf+HjFFwKlNxHm2MCgYAr//R3BdEy0oZeVRndo2lr9YvUEmu2LOihQpWDCd0fQw0ZDA2kc28eysL2RROte95r1XTvq6IvX5a0w11FzRWlDpQ4J4/LlcQ6LVt+98SoFwew+/PWuyLmxLycUbyMOOpm9eSc4wJJZNvaUzMCSkvfMtmm5jgyZYMMQ9A2Ul/9SQKBgB9mfh9mhBwVPIqgBJETZMMXOdxrjI5SBYHGSyJqpT+5Q0vIZLfqPrvNZOiQFzwWXPJ+tV4Mc/YorW3rZOdo6tdvEGnRO6DLTTEaByrY/io3/gcBZXoSqSuVRmxleqFdWWRnB56c1hwwWLqNHU+1671FhL6pNghFYVK4suP6qu4BAoGBAMk+VipXcIlD67mfGrET/xDqiWWBZtgTzTMjTpODhDY1GZck1eb4CQMP5j5V3gFJ4cSgWDJvnWg8rcz0unz/q4aeMGl1rah5WNDWj1QKWMS6vJhMHM/rqN1WHWR0ZnV83svYgtg0zDnQKlLujqW4JmGXLMU7ur6a+e6lpa1fvLsP"
+
+	require.NoError(t, config.ApplyDefaults())
+	require.NoError(t, config.SAML.PopulateFields(config.API.ExternalURL))
+
+	require.NotNil(t, config.SAML.Certificate)
+
+	api := NewAPI(config, nil)
+
+	// Setup request
+	req := httptest.NewRequest(http.MethodGet, "http://localhost/sso/saml/metadata", nil)
+
+	w := httptest.NewRecorder()
+	api.handler.ServeHTTP(w, req)
+	require.Equal(t, w.Code, http.StatusOK)
+
+	metadata := saml.EntityDescriptor{}
+	require.NoError(t, xml.Unmarshal(w.Body.Bytes(), &metadata))
+
+	require.Equal(t, metadata.EntityID, "https://projectref.supabase.co/auth/v1/sso/saml/metadata")
+	require.Equal(t, len(metadata.SPSSODescriptors), 1)
+
+	require.Nil(t, metadata.SPSSODescriptors[0].AuthnRequestsSigned)
+	require.True(t, *(metadata.SPSSODescriptors[0].WantAssertionsSigned))
+
+	require.Equal(t, len(metadata.SPSSODescriptors[0].AssertionConsumerServices), 2)
+	require.Equal(t, metadata.SPSSODescriptors[0].AssertionConsumerServices[0].Location, "https://projectref.supabase.co/auth/v1/sso/saml/acs")
+	require.Equal(t, metadata.SPSSODescriptors[0].AssertionConsumerServices[1].Location, "https://projectref.supabase.co/auth/v1/sso/saml/acs")
+	require.Equal(t, len(metadata.SPSSODescriptors[0].SingleLogoutServices), 1)
+	require.Equal(t, metadata.SPSSODescriptors[0].SingleLogoutServices[0].Location, "https://projectref.supabase.co/auth/v1/sso/saml/slo")
+
+	require.Equal(t, len(metadata.SPSSODescriptors[0].KeyDescriptors), 1)
+	require.Equal(t, metadata.SPSSODescriptors[0].KeyDescriptors[0].Use, "signing")
+
+	require.Equal(t, len(metadata.SPSSODescriptors[0].NameIDFormats), 2)
+	require.Equal(t, metadata.SPSSODescriptors[0].NameIDFormats[0], saml.EmailAddressNameIDFormat)
+	require.Equal(t, metadata.SPSSODescriptors[0].NameIDFormats[1], saml.PersistentNameIDFormat)
+}
diff --git a/api/samlassertion_test.go b/api/samlassertion_test.go
new file mode 100644
index 00000000..606c1901
--- /dev/null
+++ b/api/samlassertion_test.go
@@ -0,0 +1,221 @@
+package api
+
+import (
+	tst "testing"
+
+	"encoding/xml"
+
+	"github.com/crewjam/saml"
+	"github.com/netlify/gotrue/models"
+	"github.com/stretchr/testify/require"
+)
+
+func TestSAMLAssertionUserID(t *tst.T) {
+	type spec struct {
+		xml    string
+		userID string
+	}
+
+	examples := []spec{
+		{
+			xml: `<?xml version="1.0" encoding="UTF-8"?>
+<saml2:Assertion xmlns:saml2="urn:oasis:names:tc:SAML:2.0:assertion" xmlns:xsd="http://www.w3.org/2001/XMLSchema" ID="_72591c79da230cac1457d0ea0f2771ab" IssueInstant="2022-08-11T14:53:38.260Z" Version="2.0">
+	<saml2:Issuer>https://example.com/saml</saml2:Issuer>
+	<ds:Signature xmlns:ds="http://www.w3.org/2000/09/xmldsig#"></ds:Signature>
+	<saml2:Subject>
+		<saml2:NameID xmlns:saml2="urn:oasis:names:tc:SAML:2.0:assertion" Format="urn:oasis:names:tc:SAML:2.0:nameid-format:transient" NameQualifier="https://samltest.id/saml/idp" SPNameQualifier="http://localhost:9999/saml/metadata">transient-name-id</saml2:NameID>
+		<saml2:SubjectConfirmation Method="urn:oasis:names:tc:SAML:2.0:cm:bearer">
+			<saml2:SubjectConfirmationData Address="127.0.0.1" NotOnOrAfter="2022-08-11T14:58:38.272Z" Recipient="http://localhost:9999/saml/acs"/>
+		</saml2:SubjectConfirmation>
+	</saml2:Subject>
+	<saml2:Conditions NotBefore="2022-08-11T14:53:38.260Z" NotOnOrAfter="2022-08-11T14:58:38.260Z">
+		<saml2:AudienceRestriction>
+			<saml2:Audience>http://localhost:9999/saml/metadata</saml2:Audience>
+		</saml2:AudienceRestriction>
+	</saml2:Conditions>
+	<saml2:AuthnStatement AuthnInstant="2022-08-11T14:53:34.809Z" SessionIndex="_a5e14df3066529ca462930030712b65a">
+		<saml2:SubjectLocality Address="127.0.0.1"/>
+	<saml2:AuthnContext>
+		<saml2:AuthnContextClassRef>urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport</saml2:AuthnContextClassRef>
+	</saml2:AuthnContext>
+	</saml2:AuthnStatement>
+	<saml2:AttributeStatement>
+	</saml2:AttributeStatement>
+</saml2:Assertion>
+`,
+			userID: "",
+		},
+		{
+			xml: `<?xml version="1.0" encoding="UTF-8"?>
+<saml2:Assertion xmlns:saml2="urn:oasis:names:tc:SAML:2.0:assertion" xmlns:xsd="http://www.w3.org/2001/XMLSchema" ID="_72591c79da230cac1457d0ea0f2771ab" IssueInstant="2022-08-11T14:53:38.260Z" Version="2.0">
+	<saml2:Issuer>https://example.com/saml</saml2:Issuer>
+	<ds:Signature xmlns:ds="http://www.w3.org/2000/09/xmldsig#"></ds:Signature>
+	<saml2:Subject>
+		<saml2:NameID xmlns:saml2="urn:oasis:names:tc:SAML:2.0:assertion" Format="urn:oasis:names:tc:SAML:2.0:nameid-format:persistent" NameQualifier="https://example.com/saml" SPNameQualifier="http://localhost:9999/saml/metadata">persistent-name-id</saml2:NameID>
+		<saml2:SubjectConfirmation Method="urn:oasis:names:tc:SAML:2.0:cm:bearer">
+			<saml2:SubjectConfirmationData Address="79.125.170.79" NotOnOrAfter="2022-08-11T14:58:38.272Z" Recipient="http://localhost:9999/saml/acs"/>
+		</saml2:SubjectConfirmation>
+	</saml2:Subject>
+	<saml2:Conditions NotBefore="2022-08-11T14:53:38.260Z" NotOnOrAfter="2022-08-11T14:58:38.260Z">
+		<saml2:AudienceRestriction>
+			<saml2:Audience>http://localhost:9999/saml/metadata</saml2:Audience>
+		</saml2:AudienceRestriction>
+	</saml2:Conditions>
+	<saml2:AuthnStatement AuthnInstant="2022-08-11T14:53:34.809Z" SessionIndex="_a5e14df3066529ca462930030712b65a">
+		<saml2:SubjectLocality Address="127.0.0.1"/>
+	<saml2:AuthnContext>
+		<saml2:AuthnContextClassRef>urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport</saml2:AuthnContextClassRef>
+	</saml2:AuthnContext>
+	</saml2:AuthnStatement>
+	<saml2:AttributeStatement>
+	</saml2:AttributeStatement>
+</saml2:Assertion>
+`,
+			userID: "persistent-name-id",
+		},
+		{
+			xml: `<?xml version="1.0" encoding="UTF-8"?>
+<saml2:Assertion xmlns:saml2="urn:oasis:names:tc:SAML:2.0:assertion" xmlns:xsd="http://www.w3.org/2001/XMLSchema" ID="_72591c79da230cac1457d0ea0f2771ab" IssueInstant="2022-08-11T14:53:38.260Z" Version="2.0">
+	<saml2:Issuer>https://example.com/saml</saml2:Issuer>
+	<ds:Signature xmlns:ds="http://www.w3.org/2000/09/xmldsig#"></ds:Signature>
+	<saml2:Subject>
+		<saml2:NameID xmlns:saml2="urn:oasis:names:tc:SAML:2.0:assertion" Format="urn:oasis:names:tc:SAML:2.0:nameid-format:emailAddress" NameQualifier="https://example.com/saml" SPNameQualifier="http://localhost:9999/saml/metadata">name-id@example.com</saml2:NameID>
+		<saml2:SubjectConfirmation Method="urn:oasis:names:tc:SAML:2.0:cm:bearer">
+			<saml2:SubjectConfirmationData Address="79.125.170.79" NotOnOrAfter="2022-08-11T14:58:38.272Z" Recipient="http://localhost:9999/saml/acs"/>
+		</saml2:SubjectConfirmation>
+	</saml2:Subject>
+	<saml2:Conditions NotBefore="2022-08-11T14:53:38.260Z" NotOnOrAfter="2022-08-11T14:58:38.260Z">
+		<saml2:AudienceRestriction>
+			<saml2:Audience>http://localhost:9999/saml/metadata</saml2:Audience>
+		</saml2:AudienceRestriction>
+	</saml2:Conditions>
+	<saml2:AuthnStatement AuthnInstant="2022-08-11T14:53:34.809Z" SessionIndex="_a5e14df3066529ca462930030712b65a">
+		<saml2:SubjectLocality Address="127.0.0.1"/>
+	<saml2:AuthnContext>
+		<saml2:AuthnContextClassRef>urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport</saml2:AuthnContextClassRef>
+	</saml2:AuthnContext>
+	</saml2:AuthnStatement>
+	<saml2:AttributeStatement>
+	</saml2:AttributeStatement>
+</saml2:Assertion>
+`,
+			userID: "name-id@example.com",
+		},
+		{
+			xml: `<?xml version="1.0" encoding="UTF-8"?>
+<saml2:Assertion xmlns:saml2="urn:oasis:names:tc:SAML:2.0:assertion" xmlns:xsd="http://www.w3.org/2001/XMLSchema" ID="_72591c79da230cac1457d0ea0f2771ab" IssueInstant="2022-08-11T14:53:38.260Z" Version="2.0">
+	<saml2:Issuer>https://example.com/saml</saml2:Issuer>
+	<ds:Signature xmlns:ds="http://www.w3.org/2000/09/xmldsig#"></ds:Signature>
+	<saml2:Subject>
+		<saml2:NameID xmlns:saml2="urn:oasis:names:tc:SAML:2.0:assertion" Format="urn:oasis:names:tc:SAML:2.0:nameid-format:emailAddress" NameQualifier="https://example.com/saml" SPNameQualifier="http://localhost:9999/saml/metadata">name-id@example.com</saml2:NameID>
+		<saml2:SubjectConfirmation Method="urn:oasis:names:tc:SAML:2.0:cm:bearer">
+			<saml2:SubjectConfirmationData Address="79.125.170.79" NotOnOrAfter="2022-08-11T14:58:38.272Z" Recipient="http://localhost:9999/saml/acs"/>
+		</saml2:SubjectConfirmation>
+	</saml2:Subject>
+	<saml2:Conditions NotBefore="2022-08-11T14:53:38.260Z" NotOnOrAfter="2022-08-11T14:58:38.260Z">
+		<saml2:AudienceRestriction>
+			<saml2:Audience>http://localhost:9999/saml/metadata</saml2:Audience>
+		</saml2:AudienceRestriction>
+	</saml2:Conditions>
+	<saml2:AuthnStatement AuthnInstant="2022-08-11T14:53:34.809Z" SessionIndex="_a5e14df3066529ca462930030712b65a">
+		<saml2:SubjectLocality Address="127.0.0.1"/>
+	<saml2:AuthnContext>
+		<saml2:AuthnContextClassRef>urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport</saml2:AuthnContextClassRef>
+	</saml2:AuthnContext>
+	</saml2:AuthnStatement>
+	<saml2:AttributeStatement>
+		<saml2:Attribute Name="urn:oasis:names:tc:SAML:attribute:subject-id" NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:uri">
+			<saml2:AttributeValue xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:type="xsd:string">subject-id</saml2:AttributeValue>
+		</saml2:Attribute>
+	</saml2:AttributeStatement>
+</saml2:Assertion>
+`,
+			userID: "subject-id",
+		},
+	}
+
+	for i, example := range examples {
+		rawAssertion := saml.Assertion{}
+		require.NoError(t, xml.Unmarshal([]byte(example.xml), &rawAssertion))
+
+		assertion := SAMLAssertion{
+			&rawAssertion,
+		}
+
+		userID := assertion.UserID()
+
+		require.Equal(t, userID, example.userID, "example %d had different user ID", i)
+	}
+}
+
+func TestSAMLAssertionProcessing(t *tst.T) {
+	type spec struct {
+		xml      string
+		mapping  models.SAMLAttributeMapping
+		expected map[string]interface{}
+	}
+
+	examples := []spec{
+		{
+			xml: `<?xml version="1.0" encoding="UTF-8"?>
+<saml2:Assertion xmlns:saml2="urn:oasis:names:tc:SAML:2.0:assertion" xmlns:xsd="http://www.w3.org/2001/XMLSchema" ID="_72591c79da230cac1457d0ea0f2771ab" IssueInstant="2022-08-11T14:53:38.260Z" Version="2.0">
+	<saml2:AttributeStatement>
+		<saml2:Attribute Name="urn:oid:0.9.2342.19200300.100.1.3" FriendlyName="mail" NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:uri">
+			<saml2:AttributeValue xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:type="xsd:string">someone@example.com</saml2:AttributeValue>
+		</saml2:Attribute>
+	</saml2:AttributeStatement>
+</saml2:Assertion>
+`,
+			mapping: models.SAMLAttributeMapping{
+				Keys: map[string]models.SAMLAttribute{
+					"email": {
+						Name: "mail",
+					},
+				},
+			},
+			expected: map[string]interface{}{
+				"email": "someone@example.com",
+			},
+		},
+		{
+			xml: `<?xml version="1.0" encoding="UTF-8"?>
+<saml2:Assertion xmlns:saml2="urn:oasis:names:tc:SAML:2.0:assertion" xmlns:xsd="http://www.w3.org/2001/XMLSchema" ID="_72591c79da230cac1457d0ea0f2771ab" IssueInstant="2022-08-11T14:53:38.260Z" Version="2.0">
+	<saml2:AttributeStatement>
+		<saml2:Attribute Name="http://schemas.xmlsoap.org/claims/EmailAddress" FriendlyName="mail" NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:uri">
+			<saml2:AttributeValue xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:type="xsd:string">old-soap@example.com</saml2:AttributeValue>
+		</saml2:Attribute>
+		<saml2:Attribute Name="http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress" FriendlyName="mail" NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:uri">
+			<saml2:AttributeValue xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:type="xsd:string">soap@example.com</saml2:AttributeValue>
+		</saml2:Attribute>
+	</saml2:AttributeStatement>
+</saml2:Assertion>
+`,
+			mapping: models.SAMLAttributeMapping{
+				Keys: map[string]models.SAMLAttribute{
+					"email": {
+						Names: []string{
+							"http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress",
+							"http://schemas.xmlsoap.org/claims/EmailAddress",
+						},
+					},
+				},
+			},
+			expected: map[string]interface{}{
+				"email": "soap@example.com",
+			},
+		},
+	}
+
+	for i, example := range examples {
+		rawAssertion := saml.Assertion{}
+		require.NoError(t, xml.Unmarshal([]byte(example.xml), &rawAssertion))
+
+		assertion := SAMLAssertion{
+			&rawAssertion,
+		}
+
+		result := assertion.Process(example.mapping)
+
+		require.Equal(t, result, example.expected, "example %d had different processing", i)
+	}
+}
diff --git a/api/sso_test.go b/api/sso_test.go
new file mode 100644
index 00000000..158552c7
--- /dev/null
+++ b/api/sso_test.go
@@ -0,0 +1,520 @@
+package api
+
+import (
+	"bytes"
+	"encoding/json"
+	"fmt"
+	"io"
+	"net/http"
+	"net/http/httptest"
+	"testing"
+
+	jwt "github.com/golang-jwt/jwt"
+	"github.com/netlify/gotrue/conf"
+	"github.com/netlify/gotrue/models"
+	"github.com/stretchr/testify/require"
+	"github.com/stretchr/testify/suite"
+)
+
+type SSOTestSuite struct {
+	suite.Suite
+	API      *API
+	Config   *conf.GlobalConfiguration
+	AdminJWT string
+}
+
+func TestSSO(t *testing.T) {
+	api, config, err := setupAPIForTest()
+	require.NoError(t, err)
+
+	ts := &SSOTestSuite{
+		API:    api,
+		Config: config,
+	}
+	defer api.db.Close()
+
+	if config.SAML.Enabled {
+		suite.Run(t, ts)
+	}
+}
+
+func (ts *SSOTestSuite) SetupTest() {
+	models.TruncateAll(ts.API.db)
+
+	claims := &GoTrueClaims{
+		Role: "supabase_admin",
+	}
+	token, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(ts.Config.JWT.Secret))
+	require.NoError(ts.T(), err, "Error generating admin jwt")
+
+	ts.AdminJWT = token
+}
+
+func (ts *SSOTestSuite) TestNonAdminJWT() {
+	// TODO
+}
+
+func (ts *SSOTestSuite) TestAdminListEmptySSOProviders() {
+	req := httptest.NewRequest(http.MethodGet, "http://localhost/admin/sso/providers", nil)
+	req.Header.Set("Authorization", "Bearer "+ts.AdminJWT)
+	w := httptest.NewRecorder()
+
+	ts.API.handler.ServeHTTP(w, req)
+
+	require.Equal(ts.T(), http.StatusOK, w.Code)
+
+	body, err := io.ReadAll(w.Body)
+	require.NoError(ts.T(), err)
+
+	var result struct {
+		Items     []interface{} `json:"items"`
+		NextToken string        `json:"next_token"`
+	}
+
+	require.NoError(ts.T(), json.Unmarshal(body, &result))
+	require.Equal(ts.T(), len(result.Items), 0)
+	require.Equal(ts.T(), result.NextToken, "")
+}
+
+func (ts *SSOTestSuite) TestAdminGetSSOProviderNotExist() {
+	examples := []struct {
+		URL string
+	}{
+		{
+			URL: "http://localhost/admin/sso/providers/unknown-provider-resource-id",
+		},
+		{
+			URL: "http://localhost/admin/sso/providers/1",
+		},
+		{
+			URL: "http://localhost/admin/sso/providers/0",
+		},
+		{
+			URL: "http://localhost/admin/sso/providers/677477db-3f51-4038-bc05-c6bb9bdc3c32",
+		},
+	}
+
+	for _, example := range examples {
+		req := httptest.NewRequest(http.MethodGet, example.URL, nil)
+		req.Header.Set("Authorization", "Bearer "+ts.AdminJWT)
+		w := httptest.NewRecorder()
+
+		ts.API.handler.ServeHTTP(w, req)
+
+		require.Equal(ts.T(), http.StatusNotFound, w.Code)
+	}
+}
+
+func validSAMLIDPMetadata(entityID string) string {
+	return fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8"?><md:EntityDescriptor xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata" entityID="%s">
+  <md:IDPSSODescriptor WantAuthnRequestsSigned="false" protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">
+    <md:KeyDescriptor use="signing">
+      <ds:KeyInfo xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
+        <ds:X509Data>
+          <ds:X509Certificate>MIIDdDCCAlygAwIBAgIGAYKSjRZiMA0GCSqGSIb3DQEBCwUAMHsxFDASBgNVBAoTC0dvb2dsZSBJ
+bmMuMRYwFAYDVQQHEw1Nb3VudGFpbiBWaWV3MQ8wDQYDVQQDEwZHb29nbGUxGDAWBgNVBAsTD0dv
+b2dsZSBGb3IgV29yazELMAkGA1UEBhMCVVMxEzARBgNVBAgTCkNhbGlmb3JuaWEwHhcNMjIwODEy
+MTQ1NDU1WhcNMjcwODExMTQ1NDU1WjB7MRQwEgYDVQQKEwtHb29nbGUgSW5jLjEWMBQGA1UEBxMN
+TW91bnRhaW4gVmlldzEPMA0GA1UEAxMGR29vZ2xlMRgwFgYDVQQLEw9Hb29nbGUgRm9yIFdvcmsx
+CzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpDYWxpZm9ybmlhMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A
+MIIBCgKCAQEAlncFzErcnZm7ZWO71NZStnCIAoYNKf6Uw3LPLzcvk0YrA/eBC3PVDHSfahi+apGO
+Ytdq7IQUvBdto3rJTvP49fjyO0WLbAbiPC+dILt2Gx9kttxpSp99Bf+8ObL/fTy5Y2oHbJBfBX1V
+qfDQIY0fcej3AndFYUOE0gZXyeSbnROB8W1PzHxOc7rq1mlas0rvyja7AK4gwXjIwyIGsFDmHnve
+buqWOYMzOT9oD+iQq9BWYVHkXGZn0BXzKtnw9w8I3IxQdndUoCl95pYRIvdl1b0dWdO9cXtSsTkL
+kAa8B/mCQcF4W2M3t/yKtrcLcRTALg3/Hc+Xz+3BpY/fSDk1SwIDAQABMA0GCSqGSIb3DQEBCwUA
+A4IBAQCER02WLf6bKwTGVD/3VTntetIiETuPs46Dum8blbsg+2BYdAHIQcB9cLuMRosIw0nYj54m
+SfiyfoWGcx3CkMup1MtKyWu+SqDHl9Bpf+GFLG0ngKD/zB6xwpv/TCi+g/FBYe2TvzD6B1V0z7Vs
+Xf+Gc2TWBKmCuKf/g2AUt7IQLpOaqxuJVoZjp4sEMov6d3FnaoHQEd0lg+XmnYfLNtwe3QRSU0BD
+x6lVV4kXi0x0n198/gkjnA85rPZoZ6dmqHtkcM0Gabgg6KEE5ubSDlWDsdv27uANceCZAoxd1+in
+4/KqqkhynnbJs7Op5ZX8cckiHGGTGHNb35kys/XukuCo</ds:X509Certificate>
+        </ds:X509Data>
+      </ds:KeyInfo>
+    </md:KeyDescriptor>
+    <md:NameIDFormat>urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress</md:NameIDFormat>
+    <md:SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" Location="%s"/>
+    <md:SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="%s"/>
+  </md:IDPSSODescriptor>
+</md:EntityDescriptor>`, entityID, entityID, entityID)
+}
+
+func (ts *SSOTestSuite) TestAdminCreateSSOProvider() {
+	examples := []struct {
+		StatusCode int
+		Request    map[string]interface{}
+	}{
+		{
+			StatusCode: http.StatusBadRequest,
+			Request:    map[string]interface{}{},
+		},
+		{
+			StatusCode: http.StatusBadRequest,
+			Request: map[string]interface{}{
+				"type": "saml",
+			},
+		},
+		{
+			StatusCode: http.StatusBadRequest,
+			Request: map[string]interface{}{
+				"type": "oidc",
+			},
+		},
+		{
+			StatusCode: http.StatusCreated,
+			Request: map[string]interface{}{
+				"type":         "saml",
+				"metadata_xml": validSAMLIDPMetadata("https://accounts.google.com/o/saml2?idpid=EXAMPLE-A"),
+			},
+		},
+		{
+			StatusCode: http.StatusCreated,
+			Request: map[string]interface{}{
+				"type":         "saml",
+				"resource_id":  "abcdefgh",
+				"metadata_xml": validSAMLIDPMetadata("https://accounts.google.com/o/saml2?idpid=EXAMPLE-B"),
+			},
+		},
+		{
+			StatusCode: http.StatusCreated,
+			Request: map[string]interface{}{
+				"type":         "saml",
+				"metadata_xml": validSAMLIDPMetadata("https://accounts.google.com/o/saml2?idpid=EXAMPLE-DUPLICATE"),
+			},
+		},
+		{
+			StatusCode: http.StatusBadRequest,
+			Request: map[string]interface{}{
+				"type":         "saml",
+				"metadata_xml": validSAMLIDPMetadata("https://accounts.google.com/o/saml2?idpid=EXAMPLE-DUPLICATE"),
+			},
+		},
+		{
+			StatusCode: http.StatusCreated,
+			Request: map[string]interface{}{
+				"type":         "saml",
+				"resource_id":  "duplicate",
+				"metadata_xml": validSAMLIDPMetadata("https://accounts.google.com/o/saml2?idpid=EXAMPLE-DUPLICATE-RID-A"),
+			},
+		},
+		{
+			StatusCode: http.StatusBadRequest,
+			Request: map[string]interface{}{
+				"type":         "saml",
+				"resource_id":  "duplicate",
+				"metadata_xml": validSAMLIDPMetadata("https://accounts.google.com/o/saml2?idpid=EXAMPLE-DUPLICATE-RID-B"),
+			},
+		},
+		{
+			StatusCode: http.StatusCreated,
+			Request: map[string]interface{}{
+				"type":         "saml",
+				"metadata_xml": validSAMLIDPMetadata("https://accounts.google.com/o/saml2?idpid=EXAMPLE-WITH-DOMAIN-A"),
+				"domains": []string{
+					"example.com",
+				},
+			},
+		},
+		{
+			StatusCode: http.StatusBadRequest,
+			Request: map[string]interface{}{
+				"type":         "saml",
+				"metadata_xml": validSAMLIDPMetadata("https://accounts.google.com/o/saml2?idpid=EXAMPLE-WITH-DOMAIN-B"),
+				"domains": []string{
+					"example.com",
+				},
+			},
+		},
+		{
+			StatusCode: http.StatusBadRequest,
+			Request: map[string]interface{}{
+				"type":         "saml",
+				"resource_id":  "1",
+				"metadata_xml": validSAMLIDPMetadata("https://accounts.google.com/o/saml2?idpid=EXAMPLE-WITH-INVALID-RESOURCE-ID"),
+			},
+		},
+		{
+			StatusCode: http.StatusBadRequest,
+			Request: map[string]interface{}{
+				"type":         "saml",
+				"metadata_url": "https://accounts.google.com/o/saml2?idpid=EXAMPLE-WITH-METADATA-URL-TOO",
+				"metadata_xml": validSAMLIDPMetadata("https://accounts.google.com/o/saml2?idpid=EXAMPLE-WITH-METADATA-URL-TOO"),
+			},
+		},
+		{
+			StatusCode: http.StatusBadRequest,
+			Request: map[string]interface{}{
+				"type":         "saml",
+				"metadata_url": "http://accounts.google.com/o/saml2?idpid=EXAMPLE-WITH-METADATA-OVER-HTTP",
+			},
+		},
+		{
+			StatusCode: http.StatusBadRequest,
+			Request: map[string]interface{}{
+				"type":         "saml",
+				"metadata_url": "https://accounts.google.com\\o/saml2?idpid=EXAMPLE-WITH-INVALID-METADATA-URL",
+			},
+		},
+		// TODO: add example with metadata_url
+	}
+
+	for i, example := range examples {
+		body, err := json.Marshal(example.Request)
+		require.NoError(ts.T(), err)
+
+		req := httptest.NewRequest(http.MethodPost, "http://localhost/admin/sso/providers", bytes.NewBuffer(body))
+		req.Header.Set("Authorization", "Bearer "+ts.AdminJWT)
+		w := httptest.NewRecorder()
+
+		ts.API.handler.ServeHTTP(w, req)
+
+		response, err := io.ReadAll(w.Body)
+		require.NoError(ts.T(), err)
+
+		require.Equal(ts.T(), example.StatusCode, w.Code, "Example %d failed with body %q", i, response)
+
+		if example.StatusCode != http.StatusCreated {
+			continue
+		}
+
+		// now check if the provider can be queried (GET)
+		var provider struct {
+			ID string `json:"id"`
+		}
+
+		require.NoError(ts.T(), json.Unmarshal(response, &provider))
+
+		req = httptest.NewRequest(http.MethodGet, "http://localhost/admin/sso/providers/"+provider.ID, nil)
+		req.Header.Set("Authorization", "Bearer "+ts.AdminJWT)
+		w = httptest.NewRecorder()
+
+		ts.API.handler.ServeHTTP(w, req)
+
+		response, err = io.ReadAll(w.Body)
+		require.NoError(ts.T(), err)
+
+		require.Equal(ts.T(), http.StatusOK, w.Code)
+
+		originalProviderID := provider.ID
+		provider.ID = ""
+
+		require.NoError(ts.T(), json.Unmarshal(response, &provider))
+		require.Equal(ts.T(), provider.ID, originalProviderID)
+
+		// now check if the provider can be queried (List)
+		var providers struct {
+			Items []struct {
+				ID string `json:"id"`
+			} `json:"items"`
+		}
+
+		req = httptest.NewRequest(http.MethodGet, "http://localhost/admin/sso/providers", nil)
+		req.Header.Set("Authorization", "Bearer "+ts.AdminJWT)
+		w = httptest.NewRecorder()
+
+		ts.API.handler.ServeHTTP(w, req)
+
+		response, err = io.ReadAll(w.Body)
+		require.NoError(ts.T(), err)
+
+		require.NoError(ts.T(), json.Unmarshal(response, &providers))
+
+		contained := false
+		for _, listProvider := range providers.Items {
+			if listProvider.ID == provider.ID {
+				contained = true
+				break
+			}
+		}
+
+		require.True(ts.T(), contained)
+	}
+}
+
+func (ts *SSOTestSuite) TestAdminUpdateSSOProvider() {
+	providers := []struct {
+		ID      string
+		Request map[string]interface{}
+	}{
+		{
+			Request: map[string]interface{}{
+				"type":         "saml",
+				"metadata_xml": validSAMLIDPMetadata("https://accounts.google.com/o/saml2?idpid=EXAMPLE-A"),
+			},
+		},
+		{
+			Request: map[string]interface{}{
+				"type":         "saml",
+				"metadata_xml": validSAMLIDPMetadata("https://accounts.google.com/o/saml2?idpid=EXAMPLE-C"),
+				"domains": []string{
+					"example.com",
+				},
+			},
+		},
+		{
+			Request: map[string]interface{}{
+				"type":         "saml",
+				"resource_id":  "example-d",
+				"metadata_xml": validSAMLIDPMetadata("https://accounts.google.com/o/saml2?idpid=EXAMPLE-D"),
+			},
+		},
+	}
+
+	for i, example := range providers {
+		body, err := json.Marshal(example.Request)
+		require.NoError(ts.T(), err)
+
+		req := httptest.NewRequest(http.MethodPost, "http://localhost/admin/sso/providers", bytes.NewBuffer(body))
+		req.Header.Set("Authorization", "Bearer "+ts.AdminJWT)
+		w := httptest.NewRecorder()
+
+		ts.API.handler.ServeHTTP(w, req)
+
+		response, err := io.ReadAll(w.Body)
+		require.NoError(ts.T(), err)
+
+		var payload struct {
+			ID string `json:"id"`
+		}
+
+		require.NoError(ts.T(), json.Unmarshal(response, &payload))
+
+		providers[i].ID = payload.ID
+	}
+
+	examples := []struct {
+		ID      string
+		Status  int
+		Request map[string]interface{}
+	}{
+		{
+			ID:     providers[0].ID,
+			Status: http.StatusBadRequest, // changing entity ID
+			Request: map[string]interface{}{
+				"metadata_xml": validSAMLIDPMetadata("https://accounts.google.com/o/saml2?idpid=EXAMPLE-B"),
+			},
+		},
+		{
+			ID:     providers[0].ID,
+			Status: http.StatusBadRequest, // domain already exists
+			Request: map[string]interface{}{
+				"domains": []string{
+					"example.com",
+				},
+			},
+		},
+		{
+			ID:     providers[1].ID,
+			Status: http.StatusOK,
+			Request: map[string]interface{}{
+				"domains": []string{
+					"example.com",
+					"example.org",
+				},
+			},
+		},
+		{
+			ID:     providers[0].ID,
+			Status: http.StatusOK,
+			Request: map[string]interface{}{
+				"resource_id": "deadbeef",
+			},
+		},
+		{
+			ID:     providers[2].ID,
+			Status: http.StatusBadRequest,
+			Request: map[string]interface{}{
+				"resource_id": "deadbeef",
+			},
+		},
+	}
+
+	for _, example := range examples {
+		body, err := json.Marshal(example.Request)
+		require.NoError(ts.T(), err)
+
+		req := httptest.NewRequest(http.MethodPut, "http://localhost/admin/sso/providers/"+example.ID, bytes.NewBuffer(body))
+		req.Header.Set("Authorization", "Bearer "+ts.AdminJWT)
+		w := httptest.NewRecorder()
+
+		ts.API.handler.ServeHTTP(w, req)
+
+		require.Equal(ts.T(), w.Code, example.Status)
+	}
+}
+
+func (ts *SSOTestSuite) TestAdminDeleteSSOProvider() {
+	providers := []struct {
+		ID      string
+		Request map[string]interface{}
+	}{
+		{
+			Request: map[string]interface{}{
+				"type":         "saml",
+				"metadata_xml": validSAMLIDPMetadata("https://accounts.google.com/o/saml2?idpid=EXAMPLE-A"),
+			},
+		},
+	}
+
+	for i, example := range providers {
+		body, err := json.Marshal(example.Request)
+		require.NoError(ts.T(), err)
+
+		req := httptest.NewRequest(http.MethodPost, "http://localhost/admin/sso/providers", bytes.NewBuffer(body))
+		req.Header.Set("Authorization", "Bearer "+ts.AdminJWT)
+		w := httptest.NewRecorder()
+
+		ts.API.handler.ServeHTTP(w, req)
+
+		response, err := io.ReadAll(w.Body)
+		require.NoError(ts.T(), err)
+
+		var payload struct {
+			ID string `json:"id"`
+		}
+
+		require.NoError(ts.T(), json.Unmarshal(response, &payload))
+
+		providers[i].ID = payload.ID
+	}
+
+	examples := []struct {
+		ID     string
+		Status int
+	}{
+		{
+			ID:     providers[0].ID,
+			Status: http.StatusOK,
+		},
+	}
+
+	for _, example := range examples {
+		req := httptest.NewRequest(http.MethodDelete, "http://localhost/admin/sso/providers/"+example.ID, nil)
+		req.Header.Set("Authorization", "Bearer "+ts.AdminJWT)
+		w := httptest.NewRecorder()
+
+		ts.API.handler.ServeHTTP(w, req)
+
+		require.Equal(ts.T(), w.Code, example.Status)
+	}
+
+	check := []struct {
+		ID string
+	}{
+		{
+			ID: providers[0].ID,
+		},
+	}
+
+	for _, example := range check {
+		req := httptest.NewRequest(http.MethodGet, "http://localhost/admin/sso/providers/"+example.ID, nil)
+		req.Header.Set("Authorization", "Bearer "+ts.AdminJWT)
+		w := httptest.NewRecorder()
+
+		ts.API.handler.ServeHTTP(w, req)
+
+		require.Equal(ts.T(), http.StatusNotFound, w.Code)
+	}
+}
+
+func TestSSOCreateParamsValidation(t *testing.T) {
+	// TODO
+}
diff --git a/hack/test.env b/hack/test.env
index 28ba56ef..34dd4917 100644
--- a/hack/test.env
+++ b/hack/test.env
@@ -9,6 +9,7 @@ GOTRUE_DB_AUTOMIGRATE=true
 DATABASE_URL="postgres://supabase_auth_admin:root@localhost:5432/postgres"
 GOTRUE_API_HOST=localhost
 PORT=9999
+API_EXTERNAL_URL="http://localhost:9999"
 GOTRUE_LOG_SQL=none
 GOTRUE_LOG_LEVEL=warn
 GOTRUE_SITE_URL=https://example.netlify.com
@@ -83,10 +84,6 @@ GOTRUE_EXTERNAL_ZOOM_ENABLED=true
 GOTRUE_EXTERNAL_ZOOM_CLIENT_ID=testclientid
 GOTRUE_EXTERNAL_ZOOM_SECRET=testsecret
 GOTRUE_EXTERNAL_ZOOM_REDIRECT_URI=https://identity.services.netlify.com/callback
-GOTRUE_EXTERNAL_SAML_ENABLED=true
-GOTRUE_EXTERNAL_SAML_METADATA_URL=
-GOTRUE_EXTERNAL_SAML_API_BASE=http://localhost
-GOTRUE_EXTERNAL_SAML_NAME=TestSamlName
 GOTRUE_RATE_LIMIT_VERIFY="100000"
 GOTRUE_RATE_LIMIT_TOKEN_REFRESH="30"
 GOTRUE_TRACING_ENABLED=true
@@ -99,3 +96,6 @@ GOTRUE_SECURITY_CAPTCHA_PROVIDER="hcaptcha"
 GOTRUE_SECURITY_CAPTCHA_SECRET="0x0000000000000000000000000000000000000000"
 GOTRUE_SECURITY_CAPTCHA_TIMEOUT="10s"
 GOTRUE_MFA_ENABLED="true"
+GOTRUE_SAML_ENABLED="true"
+GOTRUE_SAML_PRIVATE_KEY="MIIEowIBAAKCAQEAszrVveMQcSsa0Y+zN1ZFb19cRS0jn4UgIHTprW2tVBmO2PABzjY3XFCfx6vPirMAPWBYpsKmXrvm1tr0A6DZYmA8YmJd937VUQ67fa6DMyppBYTjNgGEkEhmKuszvF3MARsIKCGtZqUrmS7UG4404wYxVppnr2EYm3RGtHlkYsXu20MBqSDXP47bQP+PkJqC3BuNGk3xt5UHl2FSFpTHelkI6lBynw16B+lUT1F96SERNDaMqi/TRsZdGe5mB/29ngC/QBMpEbRBLNRir5iUevKS7Pn4aph9Qjaxx/97siktK210FJT23KjHpgcUfjoQ6BgPBTLtEeQdRyDuc/CgfwIDAQABAoIBAGYDWOEpupQPSsZ4mjMnAYJwrp4ZISuMpEqVAORbhspVeb70bLKonT4IDcmiexCg7cQBcLQKGpPVM4CbQ0RFazXZPMVq470ZDeWDEyhoCfk3bGtdxc1Zc9CDxNMs6FeQs6r1beEZug6weG5J/yRn/qYxQife3qEuDMl+lzfl2EN3HYVOSnBmdt50dxRuX26iW3nqqbMRqYn9OHuJ1LvRRfYeyVKqgC5vgt/6Tf7DAJwGe0dD7q08byHV8DBZ0pnMVU0bYpf1GTgMibgjnLjK//EVWafFHtN+RXcjzGmyJrk3+7ZyPUpzpDjO21kpzUQLrpEkkBRnmg6bwHnSrBr8avECgYEA3pq1PTCAOuLQoIm1CWR9/dhkbJQiKTJevlWV8slXQLR50P0WvI2RdFuSxlWmA4xZej8s4e7iD3MYye6SBsQHygOVGc4efvvEZV8/XTlDdyj7iLVGhnEmu2r7AFKzy8cOvXx0QcLg+zNd7vxZv/8D3Qj9Jje2LjLHKM5n/dZ3RzUCgYEAzh5Lo2anc4WN8faLGt7rPkGQF+7/18ImQE11joHWa3LzAEy7FbeOGpE/vhOv5umq5M/KlWFIRahMEQv4RusieHWI19ZLIP+JwQFxWxS+cPp3xOiGcquSAZnlyVSxZ//dlVgaZq2o2MfrxECcovRlaknl2csyf+HjFFwKlNxHm2MCgYAr//R3BdEy0oZeVRndo2lr9YvUEmu2LOihQpWDCd0fQw0ZDA2kc28eysL2RROte95r1XTvq6IvX5a0w11FzRWlDpQ4J4/LlcQ6LVt+98SoFwew+/PWuyLmxLycUbyMOOpm9eSc4wJJZNvaUzMCSkvfMtmm5jgyZYMMQ9A2Ul/9SQKBgB9mfh9mhBwVPIqgBJETZMMXOdxrjI5SBYHGSyJqpT+5Q0vIZLfqPrvNZOiQFzwWXPJ+tV4Mc/YorW3rZOdo6tdvEGnRO6DLTTEaByrY/io3/gcBZXoSqSuVRmxleqFdWWRnB56c1hwwWLqNHU+1671FhL6pNghFYVK4suP6qu4BAoGBAMk+VipXcIlD67mfGrET/xDqiWWBZtgTzTMjTpODhDY1GZck1eb4CQMP5j5V3gFJ4cSgWDJvnWg8rcz0unz/q4aeMGl1rah5WNDWj1QKWMS6vJhMHM/rqN1WHWR0ZnV83svYgtg0zDnQKlLujqW4JmGXLMU7ur6a+e6lpa1fvLsP"
+
diff --git a/models/sessions_test.go b/models/sessions_test.go
index 2da9f825..c1d41405 100644
--- a/models/sessions_test.go
+++ b/models/sessions_test.go
@@ -75,7 +75,7 @@ func (ts *SessionsTestSuite) TestCalculateAALAndAMR() {
 	require.Equal(ts.T(), totalDistinctClaims, len(amr))
 	found := false
 	for _, claim := range session.AMRClaims {
-		if claim.AuthenticationMethod == TOTPSignIn.String() {
+		if claim.GetAuthenticationMethod() == TOTPSignIn.String() {
 			require.True(ts.T(), firstClaimAddedTime.Before(claim.UpdatedAt))
 			found = true
 		}
diff --git a/models/sso_test.go b/models/sso_test.go
new file mode 100644
index 00000000..d51ee7dd
--- /dev/null
+++ b/models/sso_test.go
@@ -0,0 +1,241 @@
+package models
+
+import (
+	tst "testing"
+
+	"github.com/netlify/gotrue/conf"
+	"github.com/netlify/gotrue/storage"
+	"github.com/netlify/gotrue/storage/test"
+	"github.com/stretchr/testify/require"
+	"github.com/stretchr/testify/suite"
+)
+
+type SSOTestSuite struct {
+	suite.Suite
+
+	db *storage.Connection
+}
+
+func (ts *SSOTestSuite) SetupTest() {
+	TruncateAll(ts.db)
+}
+
+func TestSSO(t *tst.T) {
+	globalConfig, err := conf.LoadGlobal(modelsTestConfig)
+	require.NoError(t, err)
+
+	conn, err := test.SetupDBConnection(globalConfig)
+	require.NoError(t, err)
+
+	ts := &SSOTestSuite{
+		db: conn,
+	}
+	defer ts.db.Close()
+
+	suite.Run(t, ts)
+}
+
+func (ts *SSOTestSuite) TestConstraints() {
+	resourceID := func(rid string) *string {
+		return &rid
+	}
+
+	type exampleSpec struct {
+		Provider *SSOProvider
+	}
+
+	examples := []exampleSpec{
+		{
+			Provider: &SSOProvider{
+				SAMLProvider: SAMLProvider{
+					EntityID:    "",
+					MetadataXML: "<example />",
+				},
+			},
+		},
+		{
+			Provider: &SSOProvider{
+				SAMLProvider: SAMLProvider{
+					EntityID:    "https://example.com/saml/metadata",
+					MetadataXML: "",
+				},
+			},
+		},
+		{
+			Provider: &SSOProvider{
+				SAMLProvider: SAMLProvider{
+					EntityID:    "https://example.com/saml/metadata",
+					MetadataXML: "<example />",
+				},
+				SSODomains: []SSODomain{
+					{
+						Domain: "",
+					},
+				},
+			},
+		},
+		{
+			Provider: &SSOProvider{
+				ResourceID: resourceID(""),
+			},
+		},
+	}
+
+	for i, example := range examples {
+		require.Error(ts.T(), ts.db.Eager().Create(example.Provider), "Example %d should have failed with error", i)
+	}
+}
+
+func (ts *SSOTestSuite) TestDomainUniqueness() {
+	require.NoError(ts.T(), ts.db.Eager().Create(&SSOProvider{
+		SAMLProvider: SAMLProvider{
+			EntityID:    "https://example.com/saml/metadata1",
+			MetadataXML: "<example />",
+		},
+		SSODomains: []SSODomain{
+			{
+				Domain: "example.com",
+			},
+		},
+	}))
+
+	require.Error(ts.T(), ts.db.Eager().Create(&SSOProvider{
+		SAMLProvider: SAMLProvider{
+			EntityID:    "https://example.com/saml/metadata2",
+			MetadataXML: "<example />",
+		},
+		SSODomains: []SSODomain{
+			{
+				Domain: "example.com",
+			},
+		},
+	}))
+}
+
+func (ts *SSOTestSuite) TestEntityIDUniqueness() {
+	require.NoError(ts.T(), ts.db.Eager().Create(&SSOProvider{
+		SAMLProvider: SAMLProvider{
+			EntityID:    "https://example.com/saml/metadata",
+			MetadataXML: "<example />",
+		},
+		SSODomains: []SSODomain{
+			{
+				Domain: "example.com",
+			},
+		},
+	}))
+
+	require.Error(ts.T(), ts.db.Eager().Create(&SSOProvider{
+		SAMLProvider: SAMLProvider{
+			EntityID:    "https://example.com/saml/metadata",
+			MetadataXML: "<example />",
+		},
+		SSODomains: []SSODomain{
+			{
+				Domain: "example.net",
+			},
+		},
+	}))
+}
+
+func (ts *SSOTestSuite) TestFindSSOProviderForEmailAddress() {
+	provider := &SSOProvider{
+		SAMLProvider: SAMLProvider{
+			EntityID:    "https://example.com/saml/metadata",
+			MetadataXML: "<example />",
+		},
+		SSODomains: []SSODomain{
+			{
+				Domain: "example.com",
+			},
+			{
+				Domain: "example.org",
+			},
+		},
+	}
+
+	require.NoError(ts.T(), ts.db.Eager().Create(provider), "provider creation failed")
+
+	type exampleSpec struct {
+		Address  string
+		Provider *SSOProvider
+	}
+
+	examples := []exampleSpec{
+		{
+			Address:  "someone@example.com",
+			Provider: provider,
+		},
+		{
+			Address:  "someone@example.org",
+			Provider: provider,
+		},
+		{
+			Address:  "someone@example.net",
+			Provider: nil,
+		},
+	}
+
+	for i, example := range examples {
+		rp, err := FindSSOProviderForEmailAddress(ts.db, example.Address)
+
+		if nil == example.Provider {
+			require.Nil(ts.T(), rp)
+			require.True(ts.T(), IsNotFoundError(err), "Example %d failed with error %w", i, err)
+		} else {
+			require.Nil(ts.T(), err, "Example %d failed with error %w", i, err)
+			require.Equal(ts.T(), rp.ID, example.Provider.ID)
+		}
+	}
+}
+
+func (ts *SSOTestSuite) TestFindSAMLProviderByEntityID() {
+	provider := &SSOProvider{
+		SAMLProvider: SAMLProvider{
+			EntityID:    "https://example.com/saml/metadata",
+			MetadataXML: "<example />",
+		},
+		SSODomains: []SSODomain{
+			{
+				Domain: "example.com",
+			},
+			{
+				Domain: "example.org",
+			},
+		},
+	}
+
+	require.NoError(ts.T(), ts.db.Eager().Create(provider))
+
+	type exampleSpec struct {
+		EntityID string
+		Provider *SSOProvider
+	}
+
+	examples := []exampleSpec{
+		{
+			EntityID: "https://example.com/saml/metadata",
+			Provider: provider,
+		},
+		{
+			EntityID: "https://example.com/saml/metadata/",
+			Provider: nil,
+		},
+		{
+			EntityID: "",
+			Provider: nil,
+		},
+	}
+
+	for i, example := range examples {
+		rp, err := FindSAMLProviderByEntityID(ts.db, example.EntityID)
+
+		if nil == example.Provider {
+			require.True(ts.T(), IsNotFoundError(err), "Example %d failed with error", i)
+			require.Nil(ts.T(), rp)
+		} else {
+			require.Nil(ts.T(), err, "Example %d failed with error %w", i, err)
+			require.Equal(ts.T(), rp.ID, example.Provider.ID)
+		}
+	}
+}
diff --git a/models/user_test.go b/models/user_test.go
index eb75a599..e6de8c6d 100644
--- a/models/user_test.go
+++ b/models/user_test.go
@@ -4,18 +4,18 @@ import (
 	"testing"
 
 	"github.com/netlify/gotrue/conf"
+	"github.com/netlify/gotrue/crypto"
 	"github.com/netlify/gotrue/storage"
 	"github.com/netlify/gotrue/storage/test"
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
 	"github.com/stretchr/testify/suite"
-	"golang.org/x/crypto/bcrypt"
 )
 
 const modelsTestConfig = "../hack/test.env"
 
 func init() {
-	PasswordHashCost = bcrypt.MinCost
+	crypto.PasswordHashCost = crypto.QuickHashCost
 }
 
 type UserTestSuite struct {