Repository: supabase/gotrue
Pull request #115 (2021-06-09 08:45:27)

Phone authentication 
## What kind of change does this PR introduce?
Allows the user to enable phone authentication. This feature will introduce 2 modifications to the existing gotrue API.

1. POST `/otp` endpoint will be used to request for an OTP (sms or magiclink).
2. POST `/signup` should support signups with phone number & password.
3. POST `/verify` will be used to check the type if the OTP received by the user matches the one sent.


ChronSyn: > 1. POST `/otp` endpoint will be used to request for an OTP (sms or magiclink).

Would this allow support for non-SMS TOTP, such as codes generated by authenticator apps (e.g. Google Authenticator, 1Password OTP, etc)?
kangmingtay: Hi @ChronSyn, glad you asked! As of now, not yet, but the next feature in the pipeline for auth would be MFA (#14) and this endpoint would be a step closer to that! 

Patch:
diff --git a/Makefile b/Makefile
index 12f971e4..5af36ca6 100644
--- a/Makefile
+++ b/Makefile
@@ -26,7 +26,7 @@ migrate_dev: ## Run database migrations for development.
 	hack/migrate.sh development
 
 migrate_supabase: ## Run database migrations for supabase development.
-	hack/migrate_postgres.sh postgres_development
+	hack/migrate_postgres.sh postgres
 
 migrate_test: ## Run database migrations for test.
 	hack/migrate.sh test
diff --git a/README.md b/README.md
index 3e22a01a..7f769fe1 100644
--- a/README.md
+++ b/README.md
@@ -172,7 +172,14 @@ The default group to assign all new users to.
 
 ### External Authentication Providers
 
+`EXTERNAL_EMAIL_DISABLED` - `bool`
+If set to true, disables email signup.
+
+`EXTERNAL_PHONE_DISABLED` - `bool`
+If set to true, disables phone signup.
+
 We support `apple`, `azure`, `bitbucket`, `discord`, `facebook`, `github`, `gitlab`, `google`, `twitch` and `twitter` for external authentication.
+
 Use the names as the keys underneath `external` to configure each separately.
 
 ```properties
@@ -408,6 +415,31 @@ Time between retries (in seconds).
 Which events should trigger a webhook. You can provide a comma separated list.
 For example to listen to all events, provide the values `validate,signup,login`.
 
+### Phone Auth
+
+`SMS_AUTOCONFIRM` - `bool`
+
+If you do not require phone confirmation, you may set this to `true`. Defaults to `false`.
+
+`SMS_MAX_FREQUENCY` - `number`
+
+Controls the minimum amount of time that must pass before sending another sms otp. The value is the number of seconds. Defaults to 60 (1 minute)).
+
+`SMS_OTP_EXP` - `number`
+
+Controls the duration an sms otp is valid for. 
+
+`SMS_OTP_LENGTH` - `number`
+
+Controls the number of digits of the sms otp sent. 
+
+`SMS_PROVIDER` - `string`
+
+* twilio
+  `SMS_TWILIO_ACCOUNT_SID`
+  `SMS_TWILIO_AUTH_TOKEN`
+  `SMS_TWILIO_MESSAGE_SERVICE_SID`
+
 ## Endpoints
 
 GoTrue exposes the following endpoints:
@@ -487,6 +519,27 @@ GoTrue exposes the following endpoints:
   }
   ```
 
+  Register a new user with a phone number and password.
+
+  ```json
+  {
+    "phone": "12345678", // follows the E.164 format
+    "password": "secret"
+  }
+  ```
+
+  Returns:
+
+  ```json
+  {
+    "id": "11111111-2222-3333-4444-5555555555555",
+    "phone": "12345678",
+    "confirmation_sent_at": "2016-05-15T20:49:40.882805774-07:00",
+    "created_at": "2016-05-15T19:53:12.368652374-07:00",
+    "updated_at": "2016-05-15T19:53:12.368652374-07:00"
+  }
+  ```
+
 ### **POST /invite**
 
   Invites a new user with an email.
@@ -520,14 +573,13 @@ GoTrue exposes the following endpoints:
 
 ### **POST /verify**
 
-  Verify a registration or a password recovery. Type can be `signup` or `recovery` or `invite`
+  Verify a registration or a password recovery. Type can be `signup` or `recovery` or `invite` 
   and the `token` is a token returned from either `/signup` or `/recover`.
 
   ```json
   {
     "type": "signup",
-    "token": "confirmation-code-delivered-in-email",
-    "redirect_to": "https://supabase.io"
+    "token": "confirmation-code-delivered-in-email"
   }
   ```
 
@@ -545,6 +597,27 @@ GoTrue exposes the following endpoints:
   }
   ```
 
+  Verify a phone signup or sms otp. Type should be set to `sms`.
+  ```json
+  {
+    "type": "sms",
+    "token": "confirmation-otp-delivered-in-sms",
+    "redirect_to": "https://supabase.io",
+    "phone": "phone-number-sms-otp-was-delivered-to"
+  }
+  ```
+
+  Returns:
+
+  ```json
+  {
+    "access_token": "jwt-token-representing-the-user",
+    "token_type": "bearer",
+    "expires_in": 3600,
+    "refresh_token": "a-refresh-token"
+  }
+  ```
+
 ### **GET /verify**
 
   Verify a registration or a password recovery. Type can be `signup` or `recovery` or `magiclink` or `invite`
@@ -570,7 +643,27 @@ GoTrue exposes the following endpoints:
   You can use the `type` param to redirect the user to a password set form in the case of `invite` or `recovery`,
   or show an account confirmed/welcome message in the case of `signup`, or direct them to some additional onboarding flow
 
-### **POST /magiclink**
+### **POST /otp**
+  One-Time-Password. Will deliver a magiclink or sms otp to the user depending on whether the request body contains an "email" or "phone" key. 
+
+  ```json
+  {
+    "phone": "12345678" // follows the E.164 format
+  }
+
+  OR 
+
+  // exactly the same as /magiclink 
+  {
+    "email": "email@example.com"
+  }
+  ```
+  Returns:
+  ```
+  {}
+  ```
+
+### **POST /magiclink** (recommended to use /otp instead. See above.)
 
   Magic Link. Will deliver a link (e.g. `/verify?type=magiclink&token=fgtyuf68ddqdaDd`) to the user based on
   email address which they can use to redeem an access_token.
@@ -675,7 +768,7 @@ GoTrue exposes the following endpoints:
 ### **PUT /user**
 
   Update a user (Requires authentication). Apart from changing email/password, this
-  method can be used to set custom user data.
+  method can be used to set custom user data. Changing the email will result in a magiclink being sent out.
 
   ```json
   {
@@ -695,7 +788,7 @@ GoTrue exposes the following endpoints:
   {
     "id": "11111111-2222-3333-4444-5555555555555",
     "email": "email@example.com",
-    "confirmation_sent_at": "2016-05-15T20:49:40.882805774-07:00",
+    "email_change_sent_at": "2016-05-15T20:49:40.882805774-07:00",
     "created_at": "2016-05-15T19:53:12.368652374-07:00",
     "updated_at": "2016-05-15T19:53:12.368652374-07:00"
   }
diff --git a/api/admin.go b/api/admin.go
index f439588f..ad92ce96 100644
--- a/api/admin.go
+++ b/api/admin.go
@@ -16,8 +16,10 @@ type adminUserParams struct {
 	Aud          string                 `json:"aud"`
 	Role         string                 `json:"role"`
 	Email        string                 `json:"email"`
+	Phone        string                 `json:"phone"`
 	Password     string                 `json:"password"`
-	Confirm      bool                   `json:"confirm"`
+	EmailConfirm bool                   `json:"email_confirm"`
+	PhoneConfirm bool                   `json:"phone_confirm"`
 	UserMetaData map[string]interface{} `json:"user_metadata"`
 	AppMetaData  map[string]interface{} `json:"app_metadata"`
 }
@@ -107,12 +109,18 @@ func (a *API) adminUserUpdate(w http.ResponseWriter, r *http.Request) error {
 			}
 		}
 
-		if params.Confirm {
+		if params.EmailConfirm {
 			if terr := user.Confirm(tx); terr != nil {
 				return terr
 			}
 		}
 
+		if params.PhoneConfirm {
+			if terr := user.ConfirmPhone(tx); terr != nil {
+				return terr
+			}
+		}
+
 		if params.Password != "" {
 			if len(params.Password) < config.PasswordMinLength {
 				return fmt.Errorf("Password should be at least %d characters", config.PasswordMinLength)
@@ -129,6 +137,12 @@ func (a *API) adminUserUpdate(w http.ResponseWriter, r *http.Request) error {
 			}
 		}
 
+		if params.Phone != "" {
+			if terr := user.SetPhone(tx, params.Phone); terr != nil {
+				return terr
+			}
+		}
+
 		if params.AppMetaData != nil {
 			if terr := user.UpdateAppMetaData(tx, params.AppMetaData); terr != nil {
 				return terr
@@ -144,6 +158,7 @@ func (a *API) adminUserUpdate(w http.ResponseWriter, r *http.Request) error {
 		if terr := models.NewAuditLogEntry(tx, instanceID, adminUser, models.UserModifiedAction, map[string]interface{}{
 			"user_id":    user.ID,
 			"user_email": user.Email,
+			"user_phone": user.Phone,
 		}); terr != nil {
 			return terr
 		}
@@ -167,25 +182,45 @@ func (a *API) adminUserCreate(w http.ResponseWriter, r *http.Request) error {
 		return err
 	}
 
-	if err := a.validateEmail(ctx, params.Email); err != nil {
-		return err
-	}
-
 	aud := a.requestAud(ctx, r)
 	if params.Aud != "" {
 		aud = params.Aud
 	}
 
-	if exists, err := models.IsDuplicatedEmail(a.db, instanceID, params.Email, aud); err != nil {
-		return internalServerError("Database error checking email").WithInternalError(err)
-	} else if exists {
-		return unprocessableEntityError(DuplicateEmailMsg)
+	if params.Email == "" && params.Phone == "" {
+		return unprocessableEntityError("Cannot create a user without either an email or phone")
+	}
+
+	if params.Email != "" {
+		if err := a.validateEmail(ctx, params.Email); err != nil {
+			return err
+		}
+		if exists, err := models.IsDuplicatedEmail(a.db, instanceID, params.Email, aud); err != nil {
+			return internalServerError("Database error checking email").WithInternalError(err)
+		} else if exists {
+			return unprocessableEntityError("Email address already registered by another user")
+		}
+	}
+
+	if params.Phone != "" {
+		params.Phone = a.formatPhoneNumber(params.Phone)
+		if isValid := a.validateE164Format(params.Phone); !isValid {
+			return unprocessableEntityError("Invalid phone format")
+		}
+		if exists, err := models.IsDuplicatedPhone(a.db, instanceID, params.Phone, aud); err != nil {
+			return internalServerError("Database error checking phone").WithInternalError(err)
+		} else if exists {
+			return unprocessableEntityError("Phone number already registered by another user")
+		}
 	}
 
 	user, err := models.NewUser(instanceID, params.Email, params.Password, aud, params.UserMetaData)
 	if err != nil {
 		return internalServerError("Error creating user").WithInternalError(err)
 	}
+	if params.Phone != "" {
+		user.Phone = storage.NullString(params.Phone)
+	}
 	if user.AppMetaData == nil {
 		user.AppMetaData = make(map[string]interface{})
 	}
@@ -196,6 +231,7 @@ func (a *API) adminUserCreate(w http.ResponseWriter, r *http.Request) error {
 		if terr := models.NewAuditLogEntry(tx, instanceID, adminUser, models.UserSignedUpAction, map[string]interface{}{
 			"user_id":    user.ID,
 			"user_email": user.Email,
+			"user_phone": user.Phone,
 		}); terr != nil {
 			return terr
 		}
@@ -212,12 +248,18 @@ func (a *API) adminUserCreate(w http.ResponseWriter, r *http.Request) error {
 			return terr
 		}
 
-		if params.Confirm {
+		if params.EmailConfirm {
 			if terr := user.Confirm(tx); terr != nil {
 				return terr
 			}
 		}
 
+		if params.PhoneConfirm {
+			if terr := user.ConfirmPhone(tx); terr != nil {
+				return terr
+			}
+		}
+
 		return nil
 	})
 
@@ -239,6 +281,7 @@ func (a *API) adminUserDelete(w http.ResponseWriter, r *http.Request) error {
 		if terr := models.NewAuditLogEntry(tx, instanceID, adminUser, models.UserDeletedAction, map[string]interface{}{
 			"user_id":    user.ID,
 			"user_email": user.Email,
+			"user_phone": user.Phone,
 		}); terr != nil {
 			return internalServerError("Error recording audit log entry").WithInternalError(terr)
 		}
diff --git a/api/api.go b/api/api.go
index bc97bc34..20905f84 100644
--- a/api/api.go
+++ b/api/api.go
@@ -116,9 +116,11 @@ func NewAPIWithVersion(ctx context.Context, globalConfig *conf.GlobalConfigurati
 
 		r.With(api.requireAdminCredentials).Post("/invite", api.Invite)
 
-		r.With(api.requireEmailProvider).Post("/signup", api.Signup)
+		r.Post("/signup", api.Signup)
 		r.With(api.requireEmailProvider).Post("/recover", api.Recover)
-		r.With(api.requireEmailProvider).Post("/magiclink", api.MagicLink)
+		r.Post("/magiclink", api.MagicLink)
+		r.Post("/otp", api.Otp)
+
 		r.With(api.requireEmailProvider).With(api.limitHandler(
 			// Allow requests at a rate of 30 per 5 minutes.
 			tollbooth.NewLimiter(30.0/(60*5), &limiter.ExpirableOptions{
@@ -126,7 +128,12 @@ func NewAPIWithVersion(ctx context.Context, globalConfig *conf.GlobalConfigurati
 			}).SetBurst(30),
 		)).Post("/token", api.Token)
 
-		r.Route("/verify", func(r *router) {
+		r.With(api.limitHandler(
+			// Allow requests at a rate of 30 per 5 minutes.
+			tollbooth.NewLimiter(30.0/(60*5), &limiter.ExpirableOptions{
+				DefaultExpirationTTL: time.Hour,
+			}).SetBurst(30),
+		)).Route("/verify", func(r *router) {
 			r.Get("/", api.Verify)
 			r.Post("/", api.Verify)
 		})
diff --git a/api/audit.go b/api/audit.go
index 3e04e207..ea123679 100644
--- a/api/audit.go
+++ b/api/audit.go
@@ -8,7 +8,7 @@ import (
 )
 
 var filterColumnMap = map[string][]string{
-	"author": []string{"actor_email", "actor_name"},
+	"author": []string{"actor_username", "actor_name"},
 	"action": []string{"action"},
 	"type":   []string{"log_type"},
 }
diff --git a/api/errors.go b/api/errors.go
index 0c62423a..4b8a9a4b 100644
--- a/api/errors.go
+++ b/api/errors.go
@@ -135,6 +135,24 @@ func httpError(code int, fmtString string, args ...interface{}) *HTTPError {
 	}
 }
 
+type OTPError struct {
+	Err             string `json:"error"`
+	Description     string `json:"error_description,omitempty`
+	InternalError   error  `json:"-"`
+	InternalMessage string `json:"-"`
+}
+
+func (e *OTPError) Error() string {
+	if e.InternalMessage != "" {
+		return e.InternalMessage
+	}
+	return fmt.Sprintf("%s: %s", e.Err, e.Description)
+}
+
+func otpError(err string, description string) *OTPError {
+	return &OTPError{Err: err, Description: description}
+}
+
 // Recoverer is a middleware that recovers from panics, logs the panic (and a
 // backtrace), and returns a HTTP 500 (Internal Server Error) status if
 // possible. Recoverer prints a request ID if one is provided.
diff --git a/api/external.go b/api/external.go
index 298cb220..61693f02 100644
--- a/api/external.go
+++ b/api/external.go
@@ -269,7 +269,7 @@ func (a *API) processInvite(ctx context.Context, tx *storage.Connection, userDat
 	var emails []string
 	for _, e := range userData.Emails {
 		emails = append(emails, e.Email)
-		if user.Email == e.Email {
+		if user.GetEmail() == e.Email {
 			emailData = &e
 			break
 		}
diff --git a/api/magic_link.go b/api/magic_link.go
index 309776ad..f260913f 100644
--- a/api/magic_link.go
+++ b/api/magic_link.go
@@ -21,6 +21,11 @@ type MagicLinkParams struct {
 func (a *API) MagicLink(w http.ResponseWriter, r *http.Request) error {
 	ctx := r.Context()
 	config := a.getConfig(ctx)
+
+	if config.External.Email.Disabled {
+		return badRequestError("Unsupported email provider")
+	}
+
 	instanceID := getInstanceID(ctx)
 	params := &MagicLinkParams{}
 	jsonDecoder := json.NewDecoder(r.Body)
diff --git a/api/otp.go b/api/otp.go
new file mode 100644
index 00000000..5896ca9a
--- /dev/null
+++ b/api/otp.go
@@ -0,0 +1,107 @@
+package api
+
+import (
+	"bytes"
+	"encoding/json"
+	"io/ioutil"
+	"net/http"
+	"strings"
+
+	"github.com/netlify/gotrue/models"
+	"github.com/netlify/gotrue/storage"
+	"github.com/sethvargo/go-password/password"
+)
+
+type OtpParams struct {
+	Email string `json:"email"`
+	Phone string `json:"phone"`
+}
+
+type SmsParams struct {
+	Phone string `json:"phone"`
+}
+
+func (a *API) Otp(w http.ResponseWriter, r *http.Request) error {
+	params := &OtpParams{}
+	body, err := ioutil.ReadAll(r.Body)
+	jsonDecoder := json.NewDecoder(bytes.NewReader(body))
+	if err = jsonDecoder.Decode(params); err != nil {
+		return badRequestError("Could not read verification params: %v", err)
+	}
+	if params.Email != "" && params.Phone != "" {
+		return badRequestError("Only an email address or phone number should be provided")
+	}
+
+	r.Body = ioutil.NopCloser(strings.NewReader(string(body)))
+	if params.Email != "" {
+		return a.MagicLink(w, r)
+	} else if params.Phone != "" {
+		return a.SmsOtp(w, r)
+	}
+
+	return otpError("unsupported_otp_type", "")
+}
+
+// SmsOtp sends the user an otp via sms
+func (a *API) SmsOtp(w http.ResponseWriter, r *http.Request) error {
+	ctx := r.Context()
+	config := a.getConfig(ctx)
+
+	if config.External.Phone.Disabled {
+		return badRequestError("Unsupported phone provider")
+	}
+
+	instanceID := getInstanceID(ctx)
+	params := &SmsParams{}
+	jsonDecoder := json.NewDecoder(r.Body)
+	if err := jsonDecoder.Decode(params); err != nil {
+		return badRequestError("Could not read sms otp params: %v", err)
+	}
+
+	params.Phone = a.formatPhoneNumber(params.Phone)
+
+	if isValid := a.validateE164Format(params.Phone); !isValid {
+		return badRequestError("Invalid format: Phone number should follow the E.164 format")
+	}
+
+	aud := a.requestAud(ctx, r)
+
+	user, uerr := models.FindUserByPhoneAndAudience(a.db, instanceID, params.Phone, aud)
+	if uerr != nil {
+		// if user does not exists, sign up the user
+		if models.IsNotFoundError(uerr) {
+			password, err := password.Generate(64, 10, 0, false, true)
+			if err != nil {
+				internalServerError("error creating user").WithInternalError(err)
+			}
+			newBodyContent := `{"phone":"` + params.Phone + `","password":"` + password + `"}`
+			r.Body = ioutil.NopCloser(strings.NewReader(newBodyContent))
+			r.ContentLength = int64(len(newBodyContent))
+
+			fakeResponse := &responseStub{}
+
+			if err := a.Signup(fakeResponse, r); err != nil {
+				return err
+			}
+			return sendJSON(w, http.StatusOK, make(map[string]string))
+		}
+		return internalServerError("Database error finding user").WithInternalError(uerr)
+	}
+
+	err := a.db.Transaction(func(tx *storage.Connection) error {
+		if err := models.NewAuditLogEntry(tx, instanceID, user, models.UserRecoveryRequestedAction, nil); err != nil {
+			return err
+		}
+
+		if err := a.sendPhoneConfirmation(tx, ctx, user, params.Phone); err != nil {
+			return badRequestError("Error sending sms otp: %v", err)
+		}
+		return nil
+	})
+
+	if err != nil {
+		return err
+	}
+
+	return sendJSON(w, http.StatusOK, make(map[string]string))
+}
diff --git a/api/phone.go b/api/phone.go
new file mode 100644
index 00000000..ab792fc1
--- /dev/null
+++ b/api/phone.go
@@ -0,0 +1,58 @@
+package api
+
+import (
+	"context"
+	"regexp"
+	"strings"
+	"time"
+
+	"github.com/netlify/gotrue/api/sms_provider"
+	"github.com/netlify/gotrue/crypto"
+	"github.com/netlify/gotrue/models"
+	"github.com/netlify/gotrue/storage"
+	"github.com/pkg/errors"
+)
+
+const e164Format = `^[1-9]\d{1,14}$`
+
+// validateE165Format checks if phone number follows the E.164 format
+func (a *API) validateE164Format(phone string) bool {
+	// match should never fail as long as regexp is valid
+	matched, _ := regexp.Match(e164Format, []byte(phone))
+	return matched
+}
+
+// formatPhoneNumber removes "+" and whitespaces in a phone number
+func (a *API) formatPhoneNumber(phone string) string {
+	return strings.ReplaceAll(strings.Trim(phone, "+"), " ", "")
+}
+
+func (a *API) sendPhoneConfirmation(tx *storage.Connection, ctx context.Context, user *models.User, phone string) error {
+	config := a.getConfig(ctx)
+
+	if user.ConfirmationSentAt != nil && !user.ConfirmationSentAt.Add(config.Sms.MaxFrequency).Before(time.Now()) {
+		return MaxFrequencyLimitError
+	}
+
+	now := time.Now()
+	oldToken := user.ConfirmationToken
+	otp, err := crypto.GenerateOtp(config.Sms.OtpLength)
+	if err != nil {
+		return internalServerError("error generating otp").WithInternalError(err)
+	}
+	user.ConfirmationToken = otp
+
+	smsProvider, err := sms_provider.GetSmsProvider(*config)
+	if err != nil {
+		return err
+	}
+
+	if serr := smsProvider.SendSms(phone, user.ConfirmationToken); serr != nil {
+		user.ConfirmationToken = oldToken
+		return serr
+	}
+
+	user.ConfirmationSentAt = &now
+
+	return errors.Wrap(tx.UpdateOnly(user, "confirmation_token", "confirmation_sent_at"), "Database error updating user for confirmation")
+}
diff --git a/api/settings.go b/api/settings.go
index 4e597630..9275a525 100644
--- a/api/settings.go
+++ b/api/settings.go
@@ -14,6 +14,7 @@ type ProviderSettings struct {
 	Twitch    bool `json:"twitch"`
 	Twitter   bool `json:"twitter"`
 	Email     bool `json:"email"`
+	Phone     bool `json:"phone"`
 	SAML      bool `json:"saml"`
 }
 
@@ -25,7 +26,9 @@ type Settings struct {
 	ExternalProviders ProviderSettings `json:"external"`
 	ExternalLabels    ProviderLabels   `json:"external_labels"`
 	DisableSignup     bool             `json:"disable_signup"`
-	Autoconfirm       bool             `json:"autoconfirm"`
+	MailerAutoconfirm bool             `json:"mailer_autoconfirm"`
+	PhoneAutoconfirm  bool             `json:"phone_autoconfirm"`
+	SmsProvider       string           `json:"sms_provider"`
 }
 
 func (a *API) Settings(w http.ResponseWriter, r *http.Request) error {
@@ -44,12 +47,16 @@ func (a *API) Settings(w http.ResponseWriter, r *http.Request) error {
 			Twitch:    config.External.Twitch.Enabled,
 			Twitter:   config.External.Twitter.Enabled,
 			Email:     !config.External.Email.Disabled,
+			Phone:     !config.External.Phone.Disabled,
 			SAML:      config.External.Saml.Enabled,
 		},
 		ExternalLabels: ProviderLabels{
 			SAML: config.External.Saml.Name,
 		},
-		DisableSignup: config.DisableSignup,
-		Autoconfirm:   config.Mailer.Autoconfirm,
+
+		DisableSignup:     config.DisableSignup,
+		MailerAutoconfirm: config.Mailer.Autoconfirm,
+		PhoneAutoconfirm:  config.Sms.Autoconfirm,
+		SmsProvider:       config.Sms.Provider,
 	})
 }
diff --git a/api/signup.go b/api/signup.go
index 4bc55bcf..bfe46d10 100644
--- a/api/signup.go
+++ b/api/signup.go
@@ -16,6 +16,7 @@ import (
 // SignupParams are the parameters the Signup endpoint accepts
 type SignupParams struct {
 	Email    string                 `json:"email"`
+	Phone    string                 `json:"phone"`
 	Password string                 `json:"password"`
 	Data     map[string]interface{} `json:"data"`
 	Provider string                 `json:"-"`
@@ -45,14 +46,41 @@ func (a *API) Signup(w http.ResponseWriter, r *http.Request) error {
 	if len(params.Password) < config.PasswordMinLength {
 		return unprocessableEntityError(fmt.Sprintf("Password should be at least %d characters", config.PasswordMinLength))
 	}
-
-	if err := a.validateEmail(ctx, params.Email); err != nil {
-		return err
+	if params.Email != "" && params.Phone != "" {
+		return unprocessableEntityError("Only an email address or phone number should be provided on signup.")
+	}
+	if params.Email != "" {
+		params.Provider = "email"
+	} else if params.Phone != "" {
+		params.Provider = "phone"
 	}
 
+	var user *models.User
 	instanceID := getInstanceID(ctx)
 	params.Aud = a.requestAud(ctx, r)
-	user, err := models.FindUserByEmailAndAudience(a.db, instanceID, params.Email, params.Aud)
+
+	switch params.Provider {
+	case "email":
+		if config.External.Email.Disabled {
+			return badRequestError("Unsupported email provider")
+		}
+		if err := a.validateEmail(ctx, params.Email); err != nil {
+			return err
+		}
+		user, err = models.FindUserByEmailAndAudience(a.db, instanceID, params.Email, params.Aud)
+	case "phone":
+		if config.External.Phone.Disabled {
+			return badRequestError("Unsupported phone provider")
+		}
+		params.Phone = a.formatPhoneNumber(params.Phone)
+		if isValid := a.validateE164Format(params.Phone); !isValid {
+			return unprocessableEntityError("Invalid phone number format")
+		}
+		user, err = models.FindUserByPhoneAndAudience(a.db, instanceID, params.Phone, params.Aud)
+	default:
+		return unprocessableEntityError("Signup provider must be either email or phone")
+	}
+
 	if err != nil && !models.IsNotFoundError(err) {
 		return internalServerError("Database error finding user").WithInternalError(err)
 	}
@@ -60,43 +88,65 @@ func (a *API) Signup(w http.ResponseWriter, r *http.Request) error {
 	err = a.db.Transaction(func(tx *storage.Connection) error {
 		var terr error
 		if user != nil {
-			if user.IsConfirmed() {
-				return unprocessableEntityError(DuplicateEmailMsg)
+			if params.Provider == "email" && user.IsConfirmed() {
+				return badRequestError("A user with this email address has already been registered")
+			}
+
+			if params.Provider == "phone" && user.IsPhoneConfirmed() {
+				return badRequestError("A user with this phone number has already been registered")
 			}
 
 			if err := user.UpdateUserMetaData(tx, params.Data); err != nil {
 				return internalServerError("Database error updating user").WithInternalError(err)
 			}
 		} else {
-			params.Provider = "email"
 			user, terr = a.signupNewUser(ctx, tx, params)
 			if terr != nil {
 				return terr
 			}
 		}
 
-		if config.Mailer.Autoconfirm {
-			if terr = models.NewAuditLogEntry(tx, instanceID, user, models.UserSignedUpAction, nil); terr != nil {
-				return terr
-			}
-			if terr = triggerEventHooks(ctx, tx, SignupEvent, user, instanceID, config); terr != nil {
-				return terr
-			}
-			if terr = user.Confirm(tx); terr != nil {
-				return internalServerError("Database error updating user").WithInternalError(terr)
+		if params.Provider == "email" && !user.IsConfirmed() {
+			if config.Mailer.Autoconfirm {
+				if terr = models.NewAuditLogEntry(tx, instanceID, user, models.UserSignedUpAction, nil); terr != nil {
+					return terr
+				}
+				if terr = triggerEventHooks(ctx, tx, SignupEvent, user, instanceID, config); terr != nil {
+					return terr
+				}
+				if terr = user.Confirm(tx); terr != nil {
+					return internalServerError("Database error updating user").WithInternalError(terr)
+				}
+			} else {
+				mailer := a.Mailer(ctx)
+				referrer := a.getReferrer(r)
+				if terr = sendConfirmation(tx, user, mailer, config.SMTP.MaxFrequency, referrer); terr != nil {
+					if errors.Is(terr, MaxFrequencyLimitError) {
+						now := time.Now()
+						left := user.ConfirmationSentAt.Add(config.SMTP.MaxFrequency).Sub(now) / time.Second
+						return tooManyRequestsError(fmt.Sprintf("For security purposes, you can only request this after %d seconds.", left))
+					}
+					return internalServerError("Error sending confirmation mail").WithInternalError(terr)
+				}
 			}
-		} else {
-			mailer := a.Mailer(ctx)
-			referrer := a.getReferrer(r)
-			if terr = sendConfirmation(tx, user, mailer, config.SMTP.MaxFrequency, referrer); terr != nil {
-				if errors.Is(terr, MaxFrequencyLimitError) {
-					now := time.Now()
-					left := user.ConfirmationSentAt.Add(config.SMTP.MaxFrequency).Sub(now) / time.Second
-					return tooManyRequestsError(fmt.Sprintf("For security purposes, you can only request this after %d seconds.", left))
+		} else if params.Provider == "phone" && !user.IsPhoneConfirmed() {
+			if config.Sms.Autoconfirm {
+				if terr = models.NewAuditLogEntry(tx, instanceID, user, models.UserSignedUpAction, nil); terr != nil {
+					return terr
+				}
+				if terr = triggerEventHooks(ctx, tx, SignupEvent, user, instanceID, config); terr != nil {
+					return terr
+				}
+				if terr = user.ConfirmPhone(tx); terr != nil {
+					return internalServerError("Database error updating user").WithInternalError(terr)
+				}
+			} else {
+				if terr = a.sendPhoneConfirmation(tx, ctx, user, params.Phone); terr != nil {
+					return badRequestError("Error sending confirmation sms: %v", terr)
 				}
-				return internalServerError("Error sending confirmation mail").WithInternalError(terr)
 			}
 		}
+
 		return nil
 	})
 
@@ -107,7 +157,8 @@ func (a *API) Signup(w http.ResponseWriter, r *http.Request) error {
 		return err
 	}
 
-	if user.IsConfirmed() {
+	// handles case where Mailer.Autoconfirm is true or Phone.Autoconfirm is true
+	if user.IsConfirmed() || user.IsPhoneConfirmed() {
 		var token *AccessTokenResponse
 		err = a.db.Transaction(func(tx *storage.Connection) error {
 			var terr error
@@ -137,6 +188,7 @@ func (a *API) Signup(w http.ResponseWriter, r *http.Request) error {
 		token.User = user
 		return sendJSON(w, http.StatusOK, token)
 	}
+
 	return sendJSON(w, http.StatusOK, user)
 }
 
@@ -144,7 +196,19 @@ func (a *API) signupNewUser(ctx context.Context, conn *storage.Connection, param
 	instanceID := getInstanceID(ctx)
 	config := a.getConfig(ctx)
 
-	user, err := models.NewUser(instanceID, params.Email, params.Password, params.Aud, params.Data)
+	var user *models.User
+	var err error
+	switch params.Provider {
+	case "email":
+		user, err = models.NewUser(instanceID, params.Email, params.Password, params.Aud, params.Data)
+	case "phone":
+		user, err = models.NewUser(instanceID, "", params.Password, params.Aud, params.Data)
+		user.Phone = storage.NullString(params.Phone)
+	default:
+		// handles external provider case
+		user, err = models.NewUser(instanceID, params.Email, params.Password, params.Aud, params.Data)
+	}
+
 	if err != nil {
 		return nil, internalServerError("Database error creating user").WithInternalError(err)
 	}
diff --git a/api/sms_provider/sms_provider.go b/api/sms_provider/sms_provider.go
new file mode 100644
index 00000000..82828d69
--- /dev/null
+++ b/api/sms_provider/sms_provider.go
@@ -0,0 +1,20 @@
+package sms_provider
+
+import (
+	"fmt"
+
+	"github.com/netlify/gotrue/conf"
+)
+
+type SmsProvider interface {
+	SendSms(phone, otp string) error
+}
+
+func GetSmsProvider(config conf.Configuration) (SmsProvider, error) {
+	switch name := config.Sms.Provider; name {
+	case "twilio":
+		return NewTwilioProvider(config.Sms.Twilio)
+	default:
+		return nil, fmt.Errorf("Sms Provider %s could not be found", name)
+	}
+}
diff --git a/api/sms_provider/twilio.go b/api/sms_provider/twilio.go
new file mode 100644
index 00000000..80968495
--- /dev/null
+++ b/api/sms_provider/twilio.go
@@ -0,0 +1,93 @@
+package sms_provider
+
+import (
+	"encoding/json"
+	"fmt"
+	"net/http"
+	"net/url"
+	"strings"
+
+	"github.com/netlify/gotrue/conf"
+)
+
+const (
+	defaultTwilioApiBase = "https://api.twilio.com"
+	apiVersion           = "2010-04-01"
+)
+
+type TwilioProvider struct {
+	Config  *conf.TwilioProviderConfiguration
+	APIPath string
+}
+
+type SmsStatus struct {
+	To           string `json:"to"`
+	From         string `json:"from"`
+	Status       string `json:"status"`
+	ErrorCode    string `json:"error_code"`
+	ErrorMessage string `json:"error_message"`
+	Body         string `json:"body"`
+}
+
+type twilioErrResponse struct {
+	Code     int    `json:"code"`
+	Message  string `json:"message"`
+	MoreInfo string `json:"more_info"`
+	Status   int    `json:"status"`
+}
+
+func (t twilioErrResponse) Error() string {
+	return fmt.Sprintf("%s More information: %s", t.Message, t.MoreInfo)
+}
+
+// Creates a SmsProvider with the Twilio Config
+func NewTwilioProvider(config conf.TwilioProviderConfiguration) (SmsProvider, error) {
+	apiPath := defaultTwilioApiBase + "/" + apiVersion + "/" + "Accounts" + "/" + config.AccountSid + "/Messages.json"
+	return &TwilioProvider{
+		Config:  &config,
+		APIPath: apiPath,
+	}, nil
+}
+
+// Send an SMS containing the OTP with Twilio's API
+func (t TwilioProvider) SendSms(phone string, otp string) error {
+	body := url.Values{
+		"To":      {"+" + phone}, // twilio api requires "+" extension to be included
+		"Channel": {"sms"},
+		"From":    {t.Config.MessageServiceSid},
+		"Body":    {otp},
+	}
+
+	client := &http.Client{}
+	r, err := http.NewRequest("POST", t.APIPath, strings.NewReader(body.Encode()))
+	if err != nil {
+		return err
+	}
+	r.Header.Add("Content-Type", "application/x-www-form-urlencoded")
+	r.SetBasicAuth(t.Config.AccountSid, t.Config.AuthToken)
+	res, err := client.Do(r)
+	if err != nil {
+		return err
+	}
+	if res.StatusCode == http.StatusBadRequest || res.StatusCode == http.StatusForbidden {
+		resp := &twilioErrResponse{}
+		if err := json.NewDecoder(res.Body).Decode(resp); err != nil {
+			return err
+		}
+		return resp
+	}
+	defer res.Body.Close()
+
+	// validate sms status
+	resp := &SmsStatus{}
+	derr := json.NewDecoder(res.Body).Decode(resp)
+	if derr != nil {
+		return derr
+	}
+
+	if resp.Status == "failed" || resp.Status == "undelivered" {
+		return fmt.Errorf("Twilio error: %v %v", resp.ErrorMessage, resp.ErrorCode)
+	}
+
+	return nil
+}
diff --git a/api/token.go b/api/token.go
index 8c9fa418..3c2767a9 100644
--- a/api/token.go
+++ b/api/token.go
@@ -17,6 +17,7 @@ import (
 type GoTrueClaims struct {
 	jwt.StandardClaims
 	Email        string                 `json:"email"`
+	Phone        string                 `json:"phone"`
 	AppMetaData  map[string]interface{} `json:"app_metadata"`
 	UserMetaData map[string]interface{} `json:"user_metadata"`
 	Role         string                 `json:"role"`
@@ -198,7 +199,8 @@ func generateAccessToken(user *models.User, expiresIn time.Duration, secret stri
 			Audience:  user.Aud,
 			ExpiresAt: time.Now().Add(expiresIn).Unix(),
 		},
-		Email:        user.Email,
+		Email:        user.GetEmail(),
+		Phone:        user.GetPhone(),
 		AppMetaData:  user.AppMetaData,
 		UserMetaData: user.UserMetaData,
 		Role:         user.Role,
diff --git a/api/user.go b/api/user.go
index d9627902..bdee1edd 100644
--- a/api/user.go
+++ b/api/user.go
@@ -17,6 +17,7 @@ type UserUpdateParams struct {
 	EmailChangeToken string                 `json:"email_change_token"`
 	Data             map[string]interface{} `json:"data"`
 	AppData          map[string]interface{} `json:"app_metadata,omitempty"`
+	Phone            string                 `json:"phone"`
 }
 
 // UserGet returns a user
@@ -116,7 +117,7 @@ func (a *API) UserUpdate(w http.ResponseWriter, r *http.Request) error {
 			if terr = user.ConfirmEmailChange(tx); terr != nil {
 				return internalServerError("Error updating user").WithInternalError(terr)
 			}
-		} else if params.Email != "" && params.Email != user.Email {
+		} else if params.Email != "" && params.Email != user.GetEmail() {
 			if terr = a.validateEmail(ctx, params.Email); terr != nil {
 				return terr
 			}
diff --git a/api/verify.go b/api/verify.go
index f16044f6..0607bf4d 100644
--- a/api/verify.go
+++ b/api/verify.go
@@ -24,6 +24,7 @@ const (
 	recoveryVerification  = "recovery"
 	inviteVerification    = "invite"
 	magicLinkVerification = "magiclink"
+	smsVerification       = "sms"
 )
 
 // VerifyParams are the parameters the Verify endpoint accepts
@@ -31,6 +32,7 @@ type VerifyParams struct {
 	Type       string `json:"type"`
 	Token      string `json:"token"`
 	Password   string `json:"password"`
+	Phone      string `json:"phone"`
 	RedirectTo string `json:"redirect_to"`
 }
 
@@ -77,6 +79,16 @@ func (a *API) Verify(w http.ResponseWriter, r *http.Request) error {
 			user, terr = a.signupVerify(ctx, tx, params)
 		case recoveryVerification, magicLinkVerification:
 			user, terr = a.recoverVerify(ctx, tx, params)
+		case smsVerification:
+			if params.Phone == "" {
+				return unprocessableEntityError("Sms Verification requires a phone number")
+			}
+			params.Phone = a.formatPhoneNumber(params.Phone)
+			if isValid := a.validateE164Format(params.Phone); !isValid {
+				return unprocessableEntityError("Invalid phone number format")
+			}
+			aud := a.requestAud(ctx, r)
+			user, terr = a.smsVerify(ctx, tx, params, aud)
 		default:
 			return unprocessableEntityError("Verify requires a verification type")
 		}
@@ -224,6 +236,45 @@ func (a *API) recoverVerify(ctx context.Context, conn *storage.Connection, param
 	return user, nil
 }
 
+func (a *API) smsVerify(ctx context.Context, conn *storage.Connection, params *VerifyParams, aud string) (*models.User, error) {
+	instanceID := getInstanceID(ctx)
+	config := a.getConfig(ctx)
+	user, err := models.FindUserByPhoneAndAudience(conn, instanceID, params.Phone, aud)
+	if err != nil {
+		if models.IsNotFoundError(err) {
+			return nil, notFoundError(err.Error()).WithInternalError(redirectWithQueryError)
+		}
+		return nil, internalServerError("Database error finding user").WithInternalError(err)
+	}
+	now := time.Now()
+	expiresAt := user.ConfirmationSentAt.Add(time.Second * time.Duration(config.Sms.OtpExp))
+
+	// check if token has expired or is invalid
+	if isOtpValid := now.Before(expiresAt) && params.Token == user.ConfirmationToken; !isOtpValid {
+		return nil, expiredTokenError("Otp has expired or is invalid")
+	}
+
+	err = conn.Transaction(func(tx *storage.Connection) error {
+		var terr error
+		if terr = models.NewAuditLogEntry(tx, instanceID, user, models.UserSignedUpAction, nil); terr != nil {
+			return terr
+		}
+
+		if terr = triggerEventHooks(ctx, tx, SignupEvent, user, instanceID, config); terr != nil {
+			return terr
+		}
+
+		if terr = user.ConfirmPhone(tx); terr != nil {
+			return internalServerError("Error confirming user").WithInternalError(terr)
+		}
+		return nil
+	})
+	if err != nil {
+		return nil, err
+	}
+	return user, nil
+}
+
 func (a *API) prepErrorRedirectURL(err *HTTPError, r *http.Request, rurl string) string {
 	q := url.Values{}
 
diff --git a/cmd/migrate_cmd.go b/cmd/migrate_cmd.go
index dac13435..13900c02 100644
--- a/cmd/migrate_cmd.go
+++ b/cmd/migrate_cmd.go
@@ -35,10 +35,8 @@ func migrate(cmd *cobra.Command, args []string) {
 		Dialect: globalConfig.DB.Driver,
 		URL:     globalConfig.DB.URL,
 	}
-	if globalConfig.DB.Namespace != "" {
-		deets.Options = map[string]string{
-			"Namespace": globalConfig.DB.Namespace + "_",
-		}
+	deets.Options = map[string]string{
+		"migration_table_name": "schema_migrations",
 	}
 
 	db, err := pop.NewConnection(deets)
diff --git a/cmd/root_cmd.go b/cmd/root_cmd.go
index 93e9624b..557591e1 100644
--- a/cmd/root_cmd.go
+++ b/cmd/root_cmd.go
@@ -12,6 +12,7 @@ var configFile = ""
 var rootCmd = cobra.Command{
 	Use: "gotrue",
 	Run: func(cmd *cobra.Command, args []string) {
+		migrate(&migrateCmd, args)
 		execWithConfig(cmd, serve)
 	},
 }
diff --git a/conf/configuration.go b/conf/configuration.go
index f6bc1b39..0bce993e 100644
--- a/conf/configuration.go
+++ b/conf/configuration.go
@@ -37,7 +37,6 @@ type SamlProviderConfiguration struct {
 type DBConfiguration struct {
 	Driver         string `json:"driver" required:"true"`
 	URL            string `json:"url" envconfig:"DATABASE_URL" required:"true"`
-	Namespace      string `json:"namespace"`
 	MigrationsPath string `json:"migrations_path" split_words:"true" default:"./migrations"`
 }
 
@@ -84,13 +83,14 @@ type ProviderConfiguration struct {
 	Azure       OAuthProviderConfiguration `json:"azure"`
 	Bitbucket   OAuthProviderConfiguration `json:"bitbucket"`
 	Discord     OAuthProviderConfiguration `json:"discord"`
+	Facebook    OAuthProviderConfiguration `json:"facebook"`
 	Github      OAuthProviderConfiguration `json:"github"`
 	Gitlab      OAuthProviderConfiguration `json:"gitlab"`
 	Google      OAuthProviderConfiguration `json:"google"`
-	Facebook    OAuthProviderConfiguration `json:"facebook"`
 	Twitter     OAuthProviderConfiguration `json:"twitter"`
 	Twitch      OAuthProviderConfiguration `json:"twitch"`
 	Email       EmailProviderConfiguration `json:"email"`
+	Phone       PhoneProviderConfiguration `json:"phone"`
 	Saml        SamlProviderConfiguration  `json:"saml"`
 	RedirectURL string                     `json:"redirect_url"`
 }
@@ -112,17 +112,37 @@ type MailerConfiguration struct {
 	URLPaths    EmailContentConfiguration `json:"url_paths"`
 }
 
+type PhoneProviderConfiguration struct {
+	Disabled bool `json:"disabled"`
+}
+
+type SmsProviderConfiguration struct {
+	Autoconfirm  bool                        `json:"autoconfirm"`
+	MaxFrequency time.Duration               `json:"max_frequency" split_words:"true"`
+	OtpExp       uint                        `json:"otp_exp" split_words:"true"`
+	OtpLength    int                         `json:"otp_length" split_words:"true"`
+	Provider     string                      `json:"provider"`
+	Twilio       TwilioProviderConfiguration `json:"twilio"`
+}
+
+type TwilioProviderConfiguration struct {
+	AccountSid        string `json:"account_sid" split_words:"true"`
+	AuthToken         string `json:"auth_token" split_words:"true"`
+	MessageServiceSid string `json:"message_service_sid" split_words:"true"`
+}
+
 // Configuration holds all the per-instance configuration.
 type Configuration struct {
-	SiteURL           string                `json:"site_url" split_words:"true" required:"true"`
-	URIAllowList      []string              `json:"uri_allow_list" split_words:"true"`
-	PasswordMinLength int                   `json:"password_min_length" default:"6"`
-	JWT               JWTConfiguration      `json:"jwt"`
-	SMTP              SMTPConfiguration     `json:"smtp"`
-	Mailer            MailerConfiguration   `json:"mailer"`
-	External          ProviderConfiguration `json:"external"`
-	DisableSignup     bool                  `json:"disable_signup" split_words:"true"`
-	Webhook           WebhookConfig         `json:"webhook" split_words:"true"`
+	SiteURL           string                   `json:"site_url" split_words:"true" required:"true"`
+	URIAllowList      []string                 `json:"uri_allow_list" split_words:"true"`
+	PasswordMinLength int                      `json:"password_min_length" default:"6"`
+	JWT               JWTConfiguration         `json:"jwt"`
+	SMTP              SMTPConfiguration        `json:"smtp"`
+	Mailer            MailerConfiguration      `json:"mailer"`
+	External          ProviderConfiguration    `json:"external"`
+	Sms               SmsProviderConfiguration `json:"sms"`
+	DisableSignup     bool                     `json:"disable_signup" split_words:"true"`
+	Webhook           WebhookConfig            `json:"webhook" split_words:"true"`
 	Cookie            struct {
 		Key      string `json:"key"`
 		Duration int    `json:"duration"`
@@ -228,6 +248,19 @@ func (config *Configuration) ApplyDefaults() {
 		config.SMTP.MaxFrequency = 1 * time.Minute
 	}
 
+	if config.Sms.MaxFrequency == 0 {
+		config.Sms.MaxFrequency = 1 * time.Minute
+	}
+
+	if config.Sms.OtpExp == 0 {
+		config.Sms.OtpExp = 60
+	}
+
+	if config.Sms.OtpLength == 0 || config.Sms.OtpLength < 6 || config.Sms.OtpLength > 10 {
+		// 6-digit otp by default
+		config.Sms.OtpLength = 6
+	}
+
 	if config.Cookie.Key == "" {
 		config.Cookie.Key = "nf_jwt"
 	}
diff --git a/crypto/crypto.go b/crypto/crypto.go
index 7278c9ce..3c788f95 100644
--- a/crypto/crypto.go
+++ b/crypto/crypto.go
@@ -3,8 +3,14 @@ package crypto
 import (
 	"crypto/rand"
 	"encoding/base64"
+	"fmt"
 	"io"
+	"math"
+	"math/big"
+	"strconv"
 	"strings"
+
+	"github.com/pkg/errors"
 )
 
 // SecureToken creates a new random token
@@ -19,3 +25,15 @@ func SecureToken() string {
 func removePadding(token string) string {
 	return strings.TrimRight(token, "=")
 }
+
+// GenerateOtp generates a random n digit otp
+func GenerateOtp(digits int) (string, error) {
+	upper := math.Pow10(digits)
+	val, err := rand.Int(rand.Reader, big.NewInt(int64(upper)))
+	if err != nil {
+		return "", errors.WithMessage(err, "Error generating otp")
+	}
+	expr := "%0" + strconv.Itoa(digits) + "v"
+	otp := fmt.Sprintf(expr, val.String())
+	return otp, nil
+}
diff --git a/go.mod b/go.mod
index cc96cca7..b01cbe0c 100644
--- a/go.mod
+++ b/go.mod
@@ -33,6 +33,7 @@ require (
 	github.com/opentracing/opentracing-go v1.1.0
 	github.com/patrickmn/go-cache v2.1.0+incompatible // indirect
 	github.com/pkg/errors v0.9.1
+	github.com/pquerna/otp v1.3.0 // indirect
 	github.com/rogpeppe/go-internal v1.7.0 // indirect
 	github.com/rs/cors v1.6.0
 	github.com/russellhaering/gosaml2 v0.6.0
diff --git a/go.sum b/go.sum
index 3251a706..e4103c0a 100644
--- a/go.sum
+++ b/go.sum
@@ -62,6 +62,8 @@ github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kB
 github.com/bitly/go-simplejson v0.5.0/go.mod h1:cXHtHw4XUPsvGaxgjIAn8PhEWG9NfngEKAMDJEczWVA=
 github.com/bketelsen/crypt v0.0.3-0.20200106085610-5cbc8cc4026c/go.mod h1:MKsuJmJgSg28kpZDP6UIiPt0e0Oz0kqKNGyRaWEPv84=
 github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869/go.mod h1:Ekp36dRnpXw/yCqJaO+ZrUyxD+3VXMFFr56k5XYrpB4=
+github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI=
+github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
 github.com/bugsnag/bugsnag-go v1.5.3/go.mod h1:2oa8nejYd4cQ/b0hMIopN0lCRxU0bueqREvZLWFrtK8=
 github.com/bugsnag/panicwrap v1.2.0/go.mod h1:D/8v3kj0zr8ZAKg1AQ6crr+5VwKN5eIywRkfhyM/+dE=
 github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
@@ -460,6 +462,8 @@ github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE
 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
 github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI=
+github.com/pquerna/otp v1.3.0 h1:oJV/SkzR33anKXwQU3Of42rL4wbrffP4uvUf1SvS5Xs=
+github.com/pquerna/otp v1.3.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
 github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=
 github.com/prometheus/client_golang v0.9.2/go.mod h1:OsXs2jCmiKlQ1lTBmv21f2mNfw4xf/QclQDMrYNZzcM=
 github.com/prometheus/client_golang v0.9.3/go.mod h1:/TN21ttK/J9q6uSwhBd54HahCDft0ttaMvbicHlPoso=
diff --git a/hack/database.yml b/hack/database.yml
index 983ec17d..cc41d3e0 100644
--- a/hack/database.yml
+++ b/hack/database.yml
@@ -16,7 +16,7 @@ test:
 
 postgres_development: 
   dialect: "postgres"
-  database: "gotrue_postgres_development"
+  database: "postgres"
   host: {{ envOr "POSTGRES_HOST" "127.0.0.1"  }}
   port: {{ envOr "POSTGRES_PORT" "5432"  }}
   user: {{ envOr "POSTGRES_USER"  "postgres"  }}
diff --git a/hack/init_postgres.sql b/hack/init_postgres.sql
new file mode 100644
index 00000000..e9a0649f
--- /dev/null
+++ b/hack/init_postgres.sql
@@ -0,0 +1,113 @@
+CREATE USER supabase_admin LOGIN CREATEROLE CREATEDB REPLICATION BYPASSRLS;
+CREATE SCHEMA IF NOT EXISTS auth AUTHORIZATION supabase_admin;
+
+-- auth.users definition
+
+CREATE TABLE auth.users (
+	instance_id uuid NULL,
+	id uuid NOT NULL UNIQUE,
+	aud varchar(255) NULL,
+	"role" varchar(255) NULL,
+	email varchar(255) NULL UNIQUE,
+	encrypted_password varchar(255) NULL,
+	confirmed_at timestamptz NULL,
+	invited_at timestamptz NULL,
+	confirmation_token varchar(255) NULL,
+	confirmation_sent_at timestamptz NULL,
+	recovery_token varchar(255) NULL,
+	recovery_sent_at timestamptz NULL,
+	email_change_token varchar(255) NULL,
+	email_change varchar(255) NULL,
+	email_change_sent_at timestamptz NULL,
+	last_sign_in_at timestamptz NULL,
+	raw_app_meta_data jsonb NULL,
+	raw_user_meta_data jsonb NULL,
+	is_super_admin bool NULL,
+	created_at timestamptz NULL,
+	updated_at timestamptz NULL,
+	CONSTRAINT users_pkey PRIMARY KEY (id)
+);
+CREATE INDEX users_instance_id_email_idx ON auth.users USING btree (instance_id, email);
+CREATE INDEX users_instance_id_idx ON auth.users USING btree (instance_id);
+comment on table auth.users is 'Auth: Stores user login data within a secure schema.';
+
+-- auth.refresh_tokens definition
+
+CREATE TABLE auth.refresh_tokens (
+	instance_id uuid NULL,
+	id bigserial NOT NULL,
+	"token" varchar(255) NULL,
+	user_id varchar(255) NULL,
+	revoked bool NULL,
+	created_at timestamptz NULL,
+	updated_at timestamptz NULL,
+	CONSTRAINT refresh_tokens_pkey PRIMARY KEY (id)
+);
+CREATE INDEX refresh_tokens_instance_id_idx ON auth.refresh_tokens USING btree (instance_id);
+CREATE INDEX refresh_tokens_instance_id_user_id_idx ON auth.refresh_tokens USING btree (instance_id, user_id);
+CREATE INDEX refresh_tokens_token_idx ON auth.refresh_tokens USING btree (token);
+comment on table auth.refresh_tokens is 'Auth: Store of tokens used to refresh JWT tokens once they expire.';
+
+-- auth.instances definition
+
+CREATE TABLE auth.instances (
+	id uuid NOT NULL,
+	uuid uuid NULL,
+	raw_base_config text NULL,
+	created_at timestamptz NULL,
+	updated_at timestamptz NULL,
+	CONSTRAINT instances_pkey PRIMARY KEY (id)
+);
+comment on table auth.instances is 'Auth: Manages users across multiple sites.';
+
+-- auth.audit_log_entries definition
+
+CREATE TABLE auth.audit_log_entries (
+	instance_id uuid NULL,
+	id uuid NOT NULL,
+	payload json NULL,
+	created_at timestamptz NULL,
+	CONSTRAINT audit_log_entries_pkey PRIMARY KEY (id)
+);
+CREATE INDEX audit_logs_instance_id_idx ON auth.audit_log_entries USING btree (instance_id);
+comment on table auth.audit_log_entries is 'Auth: Audit trail for user actions.';
+
+-- auth.schema_migrations definition
+
+CREATE TABLE auth.schema_migrations (
+	"version" varchar(255) NOT NULL,
+	CONSTRAINT schema_migrations_pkey PRIMARY KEY ("version")
+);
+comment on table auth.schema_migrations is 'Auth: Manages updates to the auth system.';
+
+INSERT INTO auth.schema_migrations (version)
+VALUES  ('20171026211738'),
+        ('20171026211808'),
+        ('20171026211834'),
+        ('20180103212743'),
+        ('20180108183307'),
+        ('20180119214651'),
+        ('20180125194653');
+		
+-- Gets the User ID from the request cookie
+create or replace function auth.uid() returns uuid as $$
+  select nullif(current_setting('request.jwt.claim.sub', true), '')::uuid;
+$$ language sql stable;
+
+-- Gets the User ID from the request cookie
+create or replace function auth.role() returns text as $$
+  select nullif(current_setting('request.jwt.claim.role', true), '')::text;
+$$ language sql stable;
+
+-- Supabase super admin
+CREATE USER supabase_auth_admin NOINHERIT CREATEROLE LOGIN NOREPLICATION;
+GRANT ALL PRIVILEGES ON SCHEMA auth TO supabase_auth_admin;
+GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA auth TO supabase_auth_admin;
+GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA auth TO supabase_auth_admin;
+ALTER USER supabase_auth_admin SET search_path = "auth";
+ALTER USER supabase_auth_admin with password 'root';
+ALTER table "auth".users OWNER TO supabase_auth_admin;
+ALTER table "auth".refresh_tokens OWNER TO supabase_auth_admin;
+ALTER table "auth".audit_log_entries OWNER TO supabase_auth_admin;
+ALTER table "auth".instances OWNER TO supabase_auth_admin;
+ALTER table "auth".schema_migrations OWNER TO supabase_auth_admin;
\ No newline at end of file
diff --git a/hack/migrate_postgres.sh b/hack/migrate_postgres.sh
index 952d0b2d..59a8e80c 100755
--- a/hack/migrate_postgres.sh
+++ b/hack/migrate_postgres.sh
@@ -6,10 +6,7 @@ DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
 DATABASE="$DIR/database.yml"
 
 export GOTRUE_DB_DRIVER="postgres"
-export GOTRUE_DB_DATABASE_URL="postgres://postgres:root@localhost:5432/gotrue_$DB_ENV?sslmode=disable"
-export GOTRUE_DB_MIGRATIONS_PATH=$DIR/../migrations_postgres
+export GOTRUE_DB_DATABASE_URL="postgres://postgres:root@localhost:5432/$DB_ENV?sslmode=disable"
+export GOTRUE_DB_MIGRATIONS_PATH=$DIR/../migrations
 
-echo soda -v
-soda drop -d -e $DB_ENV -c $DATABASE
-soda create -d -e $DB_ENV -c $DATABASE
 go run main.go migrate -c $DIR/test.env
diff --git a/hack/postgresd.sh b/hack/postgresd.sh
index 604be457..3d6ee658 100755
--- a/hack/postgresd.sh
+++ b/hack/postgresd.sh
@@ -10,4 +10,5 @@ docker run --name gotrue_postgresql \
 	-e POSTGRES_PASSWORD=root \
 	-e POSTGRES_DB=postgres \
 	--volume postgres_data:/var/lib/postgresql/data \
+	--volume "$(pwd)"/hack/init_postgres.sql:/docker-entrypoint-initdb.d/init.sql \
 	-d postgres:13
diff --git a/mailer/template.go b/mailer/template.go
index 6e363d18..f95a63a9 100644
--- a/mailer/template.go
+++ b/mailer/template.go
@@ -71,7 +71,7 @@ func (m *TemplateMailer) InviteMail(user *models.User, referrerURL string) error
 	}
 
 	return m.Mailer.Mail(
-		user.Email,
+		user.GetEmail(),
 		string(withDefault(m.Config.Mailer.Subjects.Invite, "You have been invited")),
 		m.Config.Mailer.Templates.Invite,
 		defaultInviteMail,
@@ -101,7 +101,7 @@ func (m *TemplateMailer) ConfirmationMail(user *models.User, referrerURL string)
 	}
 
 	return m.Mailer.Mail(
-		user.Email,
+		user.GetEmail(),
 		string(withDefault(m.Config.Mailer.Subjects.Confirmation, "Confirm Your Signup")),
 		m.Config.Mailer.Templates.Confirmation,
 		defaultConfirmationMail,
@@ -123,7 +123,7 @@ func (m *TemplateMailer) EmailChangeMail(user *models.User, referrerURL string)
 	data := map[string]interface{}{
 		"SiteURL":         m.Config.SiteURL,
 		"ConfirmationURL": url,
-		"Email":           user.Email,
+		"Email":           user.GetEmail(),
 		"NewEmail":        user.EmailChange,
 		"Token":           user.EmailChangeToken,
 		"Data":            user.UserMetaData,
@@ -160,7 +160,7 @@ func (m *TemplateMailer) RecoveryMail(user *models.User, referrerURL string) err
 	}
 
 	return m.Mailer.Mail(
-		user.Email,
+		user.GetEmail(),
 		string(withDefault(m.Config.Mailer.Subjects.Recovery, "Reset Your Password")),
 		m.Config.Mailer.Templates.Recovery,
 		defaultRecoveryMail,
@@ -190,7 +190,7 @@ func (m *TemplateMailer) MagicLinkMail(user *models.User, referrerURL string) er
 	}
 
 	return m.Mailer.Mail(
-		user.Email,
+		user.GetEmail(),
 		string(withDefault(m.Config.Mailer.Subjects.MagicLink, "Your Magic Link")),
 		m.Config.Mailer.Templates.MagicLink,
 		defaultMagicLinkMail,
@@ -201,7 +201,7 @@ func (m *TemplateMailer) MagicLinkMail(user *models.User, referrerURL string) er
 // Send can be used to send one-off emails to users
 func (m TemplateMailer) Send(user *models.User, subject, body string, data map[string]interface{}) error {
 	return m.Mailer.Mail(
-		user.Email,
+		user.GetEmail(),
 		subject,
 		"",
 		body,
diff --git a/migrations/20171026211738_create_users.down.sql b/migrations/20171026211738_create_users.down.sql
deleted file mode 100644
index 78db0619..00000000
--- a/migrations/20171026211738_create_users.down.sql
+++ /dev/null
@@ -1,1 +0,0 @@
-DROP TABLE IF EXISTS `{{ index .Options "Namespace" }}users`;
diff --git a/migrations/20171026211738_create_users.up.sql b/migrations/20171026211738_create_users.up.sql
deleted file mode 100644
index ae57bd10..00000000
--- a/migrations/20171026211738_create_users.up.sql
+++ /dev/null
@@ -1,26 +0,0 @@
-CREATE TABLE IF NOT EXISTS `{{ index .Options "Namespace" }}users` (
-  `instance_id` varchar(255) DEFAULT NULL,
-  `id` varchar(255) NOT NULL,
-  `aud` varchar(255) DEFAULT NULL,
-  `role` varchar(255) DEFAULT NULL,
-  `email` varchar(255) DEFAULT NULL,
-  `encrypted_password` varchar(255) DEFAULT NULL,
-  `confirmed_at` timestamp NULL DEFAULT NULL,
-  `invited_at` timestamp NULL DEFAULT NULL,
-  `confirmation_token` varchar(255) DEFAULT NULL,
-  `confirmation_sent_at` timestamp NULL DEFAULT NULL,
-  `recovery_token` varchar(255) DEFAULT NULL,
-  `recovery_sent_at` timestamp NULL DEFAULT NULL,
-  `email_change_token` varchar(255) DEFAULT NULL,
-  `email_change` varchar(255) DEFAULT NULL,
-  `email_change_sent_at` timestamp NULL DEFAULT NULL,
-  `last_sign_in_at` timestamp NULL DEFAULT NULL,
-  `raw_app_meta_data` text DEFAULT NULL,
-  `raw_user_meta_data` text DEFAULT NULL,
-  `is_super_admin` tinyint(1) DEFAULT NULL,
-  `created_at` timestamp NULL DEFAULT NULL,
-  `updated_at` timestamp NULL DEFAULT NULL,
-  PRIMARY KEY (`id`),
-  KEY `users_instance_id_idx` (`instance_id`),
-  KEY `users_instance_id_email_idx` (`instance_id`,`email`)
-) ENGINE=InnoDB DEFAULT CHARSET=utf8;
diff --git a/migrations/20171026211808_create_instances.down.sql b/migrations/20171026211808_create_instances.down.sql
deleted file mode 100644
index 64ffa664..00000000
--- a/migrations/20171026211808_create_instances.down.sql
+++ /dev/null
@@ -1,1 +0,0 @@
-DROP TABLE IF EXISTS `{{ index .Options "Namespace" }}instances`;
diff --git a/migrations/20171026211808_create_instances.up.sql b/migrations/20171026211808_create_instances.up.sql
deleted file mode 100644
index bd25f0e7..00000000
--- a/migrations/20171026211808_create_instances.up.sql
+++ /dev/null
@@ -1,9 +0,0 @@
-CREATE TABLE IF NOT EXISTS `{{ index .Options "Namespace" }}instances` (
-  `id` varchar(255) NOT NULL,
-  `uuid` varchar(255) DEFAULT NULL,
-  `raw_base_config` longtext,
-  `created_at` timestamp NULL DEFAULT NULL,
-  `updated_at` timestamp NULL DEFAULT NULL,
-  `deleted_at` timestamp NULL DEFAULT NULL,
-  PRIMARY KEY (`id`)
-) ENGINE=InnoDB DEFAULT CHARSET=utf8;
diff --git a/migrations/20171026211834_create_refresh_tokens.down.sql b/migrations/20171026211834_create_refresh_tokens.down.sql
deleted file mode 100644
index 8f1478a4..00000000
--- a/migrations/20171026211834_create_refresh_tokens.down.sql
+++ /dev/null
@@ -1,1 +0,0 @@
-DROP TABLE IF EXISTS `{{ index .Options "Namespace" }}refresh_tokens`;
diff --git a/migrations/20171026211834_create_refresh_tokens.up.sql b/migrations/20171026211834_create_refresh_tokens.up.sql
deleted file mode 100644
index fe07dacd..00000000
--- a/migrations/20171026211834_create_refresh_tokens.up.sql
+++ /dev/null
@@ -1,12 +0,0 @@
-CREATE TABLE IF NOT EXISTS `{{ index .Options "Namespace" }}refresh_tokens` (
-  `instance_id` varchar(255) DEFAULT NULL,
-  `id` bigint(20) NOT NULL AUTO_INCREMENT,
-  `token` varchar(255) DEFAULT NULL,
-  `user_id` varchar(255) DEFAULT NULL,
-  `revoked` tinyint(1) DEFAULT NULL,
-  `created_at` timestamp NULL DEFAULT NULL,
-  PRIMARY KEY (`id`),
-  KEY `refresh_tokens_instance_id_idx` (`instance_id`),
-  KEY `refresh_tokens_instance_id_user_id_idx` (`instance_id`,`user_id`),
-  KEY `refresh_tokens_token_idx` (`token`)
-) ENGINE=InnoDB DEFAULT CHARSET=utf8;
diff --git a/migrations/20180103212743_json_user_metadata.down.sql b/migrations/20180103212743_json_user_metadata.down.sql
deleted file mode 100644
index 6ad631bd..00000000
--- a/migrations/20180103212743_json_user_metadata.down.sql
+++ /dev/null
@@ -1,3 +0,0 @@
-ALTER TABLE `{{ index .Options "Namespace" }}users` 
-CHANGE COLUMN `raw_app_meta_data` `raw_app_meta_data` text DEFAULT NULL ,
-CHANGE COLUMN `raw_user_meta_data` `raw_user_meta_data` text DEFAULT NULL ;
diff --git a/migrations/20180103212743_json_user_metadata.up.sql b/migrations/20180103212743_json_user_metadata.up.sql
deleted file mode 100644
index 0fc9644a..00000000
--- a/migrations/20180103212743_json_user_metadata.up.sql
+++ /dev/null
@@ -1,8 +0,0 @@
-ALTER TABLE `{{ index .Options "Namespace" }}users` CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-
-UPDATE `{{ index .Options "Namespace" }}users` SET `raw_app_meta_data` = '{}' WHERE `raw_app_meta_data` = '';
-UPDATE `{{ index .Options "Namespace" }}users` SET `raw_user_meta_data` = '{}' WHERE `raw_user_meta_data` = '';
-
-ALTER TABLE `{{ index .Options "Namespace" }}users` 
-CHANGE COLUMN `raw_app_meta_data` `raw_app_meta_data` JSON NULL DEFAULT NULL ,
-CHANGE COLUMN `raw_user_meta_data` `raw_user_meta_data` JSON NULL DEFAULT NULL ;
diff --git a/migrations/20180108183307_drop_instance_deleted_at.down.sql b/migrations/20180108183307_drop_instance_deleted_at.down.sql
deleted file mode 100644
index 6400c13b..00000000
--- a/migrations/20180108183307_drop_instance_deleted_at.down.sql
+++ /dev/null
@@ -1,1 +0,0 @@
-ALTER TABLE `{{ index .Options "Namespace" }}instances` ADD `deleted_at` timestamp NULL DEFAULT NULL AFTER `updated_at`;
diff --git a/migrations/20180108183307_drop_instance_deleted_at.up.sql b/migrations/20180108183307_drop_instance_deleted_at.up.sql
deleted file mode 100644
index a79b020a..00000000
--- a/migrations/20180108183307_drop_instance_deleted_at.up.sql
+++ /dev/null
@@ -1,1 +0,0 @@
-ALTER TABLE `{{ index .Options "Namespace" }}instances` DROP `deleted_at`;
diff --git a/migrations/20180119214651_create_audit_log_entries.down.sql b/migrations/20180119214651_create_audit_log_entries.down.sql
deleted file mode 100644
index 46c71538..00000000
--- a/migrations/20180119214651_create_audit_log_entries.down.sql
+++ /dev/null
@@ -1,1 +0,0 @@
-DROP TABLE IF EXISTS `{{ index .Options "Namespace" }}audit_log_entries`;
\ No newline at end of file
diff --git a/migrations/20180119214651_create_audit_log_entries.up.sql b/migrations/20180119214651_create_audit_log_entries.up.sql
deleted file mode 100644
index f8a8a9a1..00000000
--- a/migrations/20180119214651_create_audit_log_entries.up.sql
+++ /dev/null
@@ -1,8 +0,0 @@
-CREATE TABLE IF NOT EXISTS `{{ index .Options "Namespace" }}audit_log_entries` (
-  `instance_id` varchar(255) DEFAULT NULL,
-  `id` varchar(255) NOT NULL,
-  `payload` JSON NULL DEFAULT NULL,
-  `created_at` timestamp NULL DEFAULT NULL,
-  PRIMARY KEY (`id`),
-  KEY `audit_logs_instance_id_idx` (`instance_id`)
-) ENGINE=InnoDB DEFAULT CHARSET=utf8;
diff --git a/migrations/20180125194653_add_refresh_tokens_updated_at.down.sql b/migrations/20180125194653_add_refresh_tokens_updated_at.down.sql
deleted file mode 100644
index 2a322ce2..00000000
--- a/migrations/20180125194653_add_refresh_tokens_updated_at.down.sql
+++ /dev/null
@@ -1,1 +0,0 @@
-ALTER TABLE `{{ index .Options "Namespace" }}refresh_tokens` DROP `updated_at`;
diff --git a/migrations/20180125194653_add_refresh_tokens_updated_at.up.sql b/migrations/20180125194653_add_refresh_tokens_updated_at.up.sql
deleted file mode 100644
index 9feeac12..00000000
--- a/migrations/20180125194653_add_refresh_tokens_updated_at.up.sql
+++ /dev/null
@@ -1,2 +0,0 @@
-ALTER TABLE `{{ index .Options "Namespace" }}refresh_tokens` ADD `updated_at` timestamp NULL DEFAULT NULL AFTER `created_at`;
-UPDATE `{{ index .Options "Namespace" }}refresh_tokens` SET `updated_at` = `created_at` WHERE `updated_at` IS NULL;
diff --git a/migrations/20210710035447_alter_users.up.sql b/migrations/20210710035447_alter_users.up.sql
new file mode 100644
index 00000000..4778aec0
--- /dev/null
+++ b/migrations/20210710035447_alter_users.up.sql
@@ -0,0 +1,11 @@
+-- alter user schema
+
+ALTER TABLE auth.users 
+ADD COLUMN phone VARCHAR(15) NULL UNIQUE DEFAULT NULL,
+ADD COLUMN phone_confirmed_at timestamptz NULL DEFAULT NULL,
+ADD COLUMN phone_change VARCHAR(15) NULL DEFAULT '',
+ADD COLUMN phone_change_token VARCHAR(255) NULL DEFAULT '',
+ADD COLUMN phone_change_sent_at timestamptz NULL DEFAULT NULL;
+
+ALTER TABLE auth.users
+RENAME COLUMN confirmed_at TO email_confirmed_at;
diff --git a/migrations/schema.sql b/migrations/schema.sql
deleted file mode 100644
index 91e1d4fe..00000000
--- a/migrations/schema.sql
+++ /dev/null
@@ -1,131 +0,0 @@
--- MySQL dump 10.13  Distrib 5.7.19, for osx10.13 (x86_64)
---
--- Host: 127.0.0.1    Database: gotrue_development
--- ------------------------------------------------------
--- Server version	5.7.20
-
-/*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */;
-/*!40101 SET @OLD_CHARACTER_SET_RESULTS=@@CHARACTER_SET_RESULTS */;
-/*!40101 SET @OLD_COLLATION_CONNECTION=@@COLLATION_CONNECTION */;
-/*!40101 SET NAMES utf8 */;
-/*!40103 SET @OLD_TIME_ZONE=@@TIME_ZONE */;
-/*!40103 SET TIME_ZONE='+00:00' */;
-/*!40014 SET @OLD_UNIQUE_CHECKS=@@UNIQUE_CHECKS, UNIQUE_CHECKS=0 */;
-/*!40014 SET @OLD_FOREIGN_KEY_CHECKS=@@FOREIGN_KEY_CHECKS, FOREIGN_KEY_CHECKS=0 */;
-/*!40101 SET @OLD_SQL_MODE=@@SQL_MODE, SQL_MODE='NO_AUTO_VALUE_ON_ZERO' */;
-/*!40111 SET @OLD_SQL_NOTES=@@SQL_NOTES, SQL_NOTES=0 */;
-
---
--- Table structure for table `audit_log_entries`
---
-
-DROP TABLE IF EXISTS `audit_log_entries`;
-/*!40101 SET @saved_cs_client     = @@character_set_client */;
-/*!40101 SET character_set_client = utf8 */;
-CREATE TABLE `audit_log_entries` (
-  `instance_id` varchar(255) DEFAULT NULL,
-  `id` varchar(255) NOT NULL,
-  `payload` json DEFAULT NULL,
-  `created_at` timestamp NULL DEFAULT NULL,
-  PRIMARY KEY (`id`),
-  KEY `audit_logs_instance_id_idx` (`instance_id`)
-) ENGINE=InnoDB DEFAULT CHARSET=utf8;
-/*!40101 SET character_set_client = @saved_cs_client */;
-
---
--- Table structure for table `instances`
---
-
-DROP TABLE IF EXISTS `instances`;
-/*!40101 SET @saved_cs_client     = @@character_set_client */;
-/*!40101 SET character_set_client = utf8 */;
-CREATE TABLE `instances` (
-  `id` varchar(255) NOT NULL,
-  `uuid` varchar(255) DEFAULT NULL,
-  `raw_base_config` longtext,
-  `created_at` timestamp NULL DEFAULT NULL,
-  `updated_at` timestamp NULL DEFAULT NULL,
-  PRIMARY KEY (`id`)
-) ENGINE=InnoDB DEFAULT CHARSET=utf8;
-/*!40101 SET character_set_client = @saved_cs_client */;
-
---
--- Table structure for table `refresh_tokens`
---
-
-DROP TABLE IF EXISTS `refresh_tokens`;
-/*!40101 SET @saved_cs_client     = @@character_set_client */;
-/*!40101 SET character_set_client = utf8 */;
-CREATE TABLE `refresh_tokens` (
-  `instance_id` varchar(255) DEFAULT NULL,
-  `id` bigint(20) NOT NULL AUTO_INCREMENT,
-  `token` varchar(255) DEFAULT NULL,
-  `user_id` varchar(255) DEFAULT NULL,
-  `revoked` tinyint(1) DEFAULT NULL,
-  `created_at` timestamp NULL DEFAULT NULL,
-  `updated_at` timestamp NULL DEFAULT NULL,
-  PRIMARY KEY (`id`),
-  KEY `refresh_tokens_instance_id_idx` (`instance_id`),
-  KEY `refresh_tokens_instance_id_user_id_idx` (`instance_id`,`user_id`),
-  KEY `refresh_tokens_token_idx` (`token`)
-) ENGINE=InnoDB DEFAULT CHARSET=utf8;
-/*!40101 SET character_set_client = @saved_cs_client */;
-
---
--- Table structure for table `schema_migration`
---
-
-DROP TABLE IF EXISTS `schema_migration`;
-/*!40101 SET @saved_cs_client     = @@character_set_client */;
-/*!40101 SET character_set_client = utf8 */;
-CREATE TABLE `schema_migration` (
-  `version` varchar(255) NOT NULL,
-  UNIQUE KEY `version_idx` (`version`)
-) ENGINE=InnoDB DEFAULT CHARSET=utf8;
-/*!40101 SET character_set_client = @saved_cs_client */;
-
---
--- Table structure for table `users`
---
-
-DROP TABLE IF EXISTS `users`;
-/*!40101 SET @saved_cs_client     = @@character_set_client */;
-/*!40101 SET character_set_client = utf8 */;
-CREATE TABLE `users` (
-  `instance_id` varchar(255) DEFAULT NULL,
-  `id` varchar(255) NOT NULL,
-  `aud` varchar(255) DEFAULT NULL,
-  `role` varchar(255) DEFAULT NULL,
-  `email` varchar(255) DEFAULT NULL,
-  `encrypted_password` varchar(255) DEFAULT NULL,
-  `confirmed_at` timestamp NULL DEFAULT NULL,
-  `invited_at` timestamp NULL DEFAULT NULL,
-  `confirmation_token` varchar(255) DEFAULT NULL,
-  `confirmation_sent_at` timestamp NULL DEFAULT NULL,
-  `recovery_token` varchar(255) DEFAULT NULL,
-  `recovery_sent_at` timestamp NULL DEFAULT NULL,
-  `email_change_token` varchar(255) DEFAULT NULL,
-  `email_change` varchar(255) DEFAULT NULL,
-  `email_change_sent_at` timestamp NULL DEFAULT NULL,
-  `last_sign_in_at` timestamp NULL DEFAULT NULL,
-  `raw_app_meta_data` json DEFAULT NULL,
-  `raw_user_meta_data` json DEFAULT NULL,
-  `is_super_admin` tinyint(1) DEFAULT NULL,
-  `created_at` timestamp NULL DEFAULT NULL,
-  `updated_at` timestamp NULL DEFAULT NULL,
-  PRIMARY KEY (`id`),
-  KEY `users_instance_id_idx` (`instance_id`),
-  KEY `users_instance_id_email_idx` (`instance_id`,`email`)
-) ENGINE=InnoDB DEFAULT CHARSET=utf8;
-/*!40101 SET character_set_client = @saved_cs_client */;
-/*!40103 SET TIME_ZONE=@OLD_TIME_ZONE */;
-
-/*!40101 SET SQL_MODE=@OLD_SQL_MODE */;
-/*!40014 SET FOREIGN_KEY_CHECKS=@OLD_FOREIGN_KEY_CHECKS */;
-/*!40014 SET UNIQUE_CHECKS=@OLD_UNIQUE_CHECKS */;
-/*!40101 SET CHARACTER_SET_CLIENT=@OLD_CHARACTER_SET_CLIENT */;
-/*!40101 SET CHARACTER_SET_RESULTS=@OLD_CHARACTER_SET_RESULTS */;
-/*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */;
-/*!40111 SET SQL_NOTES=@OLD_SQL_NOTES */;
-
--- Dump completed on 2018-01-19 17:00:48
\ No newline at end of file
diff --git a/migrations_postgres/20210611093040_create_supabase_admin_roles.postgres.down.sql b/migrations_postgres/20210611093040_create_supabase_admin_roles.postgres.down.sql
deleted file mode 100644
index eccfa1fa..00000000
--- a/migrations_postgres/20210611093040_create_supabase_admin_roles.postgres.down.sql
+++ /dev/null
@@ -1,2 +0,0 @@
-DROP OWNED BY supabase_admin;
-DROP USER supabase_admin;
\ No newline at end of file
diff --git a/migrations_postgres/20210611093040_create_supabase_admin_roles.postgres.up.sql b/migrations_postgres/20210611093040_create_supabase_admin_roles.postgres.up.sql
deleted file mode 100644
index f242efb6..00000000
--- a/migrations_postgres/20210611093040_create_supabase_admin_roles.postgres.up.sql
+++ /dev/null
@@ -1,3 +0,0 @@
--- Supabase admin
-CREATE USER supabase_admin LOGIN;
-GRANT postgres TO supabase_admin;
\ No newline at end of file
diff --git a/migrations_postgres/20210611103042_create_auth_schemas.postgres.down.sql b/migrations_postgres/20210611103042_create_auth_schemas.postgres.down.sql
deleted file mode 100644
index d99b7e49..00000000
--- a/migrations_postgres/20210611103042_create_auth_schemas.postgres.down.sql
+++ /dev/null
@@ -1,1 +0,0 @@
-DROP SCHEMA IF EXISTS auth CASCADE;
\ No newline at end of file
diff --git a/migrations_postgres/20210611103042_create_auth_schemas.postgres.up.sql b/migrations_postgres/20210611103042_create_auth_schemas.postgres.up.sql
deleted file mode 100644
index e135e4e2..00000000
--- a/migrations_postgres/20210611103042_create_auth_schemas.postgres.up.sql
+++ /dev/null
@@ -1,1 +0,0 @@
-CREATE SCHEMA IF NOT EXISTS auth AUTHORIZATION supabase_admin;
diff --git a/migrations_postgres/20210611113127_create_users.postgres.down.sql b/migrations_postgres/20210611113127_create_users.postgres.down.sql
deleted file mode 100644
index ba83a12a..00000000
--- a/migrations_postgres/20210611113127_create_users.postgres.down.sql
+++ /dev/null
@@ -1,1 +0,0 @@
-DROP TABLE IF EXISTS auth.users CASCADE;
\ No newline at end of file
diff --git a/migrations_postgres/20210611113127_create_users.postgres.up.sql b/migrations_postgres/20210611113127_create_users.postgres.up.sql
deleted file mode 100644
index 010f2878..00000000
--- a/migrations_postgres/20210611113127_create_users.postgres.up.sql
+++ /dev/null
@@ -1,32 +0,0 @@
--- auth.users definition
-
-CREATE TABLE IF NOT EXISTS auth.users (
-	instance_id uuid NULL,
-	id uuid NOT NULL UNIQUE,
-	aud varchar(255) NULL,
-	"role" varchar(255) NULL,
-	email varchar(255) NULL UNIQUE,
-	encrypted_password varchar(255) NULL,
-	email_confirmed_at timestamptz NULL,
-	invited_at timestamptz NULL,
-	confirmation_token varchar(255) NULL,
-	confirmation_sent_at timestamptz NULL,
-	recovery_token varchar(255) NULL,
-	recovery_sent_at timestamptz NULL,
-	email_change_token varchar(255) NULL,
-	email_change varchar(255) NULL,
-	email_change_sent_at timestamptz NULL,
-	phone_change_token varchar(255) NULL,
-	phone_change varchar(255) NULL,
-	phone_change_sent_at timestamptz NULL,
-	last_sign_in_at timestamptz NULL,
-	raw_app_meta_data jsonb NULL,
-	raw_user_meta_data jsonb NULL,
-	is_super_admin bool NULL,
-	created_at timestamptz NULL,
-	updated_at timestamptz NULL,
-	CONSTRAINT users_pkey PRIMARY KEY (id)
-);
-CREATE INDEX users_instance_id_email_idx ON auth.users USING btree (instance_id, email);
-CREATE INDEX users_instance_id_idx ON auth.users USING btree (instance_id);
-comment on table auth.users is 'Auth: Stores user login data within a secure schema.';
\ No newline at end of file
diff --git a/migrations_postgres/20210611113141_create_instances.postgres.down.sql b/migrations_postgres/20210611113141_create_instances.postgres.down.sql
deleted file mode 100644
index 66eb08bb..00000000
--- a/migrations_postgres/20210611113141_create_instances.postgres.down.sql
+++ /dev/null
@@ -1,1 +0,0 @@
-DROP TABLE IF EXISTS instances CASCADE;
\ No newline at end of file
diff --git a/migrations_postgres/20210611113141_create_instances.postgres.up.sql b/migrations_postgres/20210611113141_create_instances.postgres.up.sql
deleted file mode 100644
index 7aa78917..00000000
--- a/migrations_postgres/20210611113141_create_instances.postgres.up.sql
+++ /dev/null
@@ -1,11 +0,0 @@
--- auth.instances definition
-
-CREATE TABLE IF NOT EXISTS auth.instances (
-	id uuid NOT NULL,
-	uuid uuid NULL,
-	raw_base_config text NULL,
-	created_at timestamptz NULL,
-	updated_at timestamptz NULL,
-	CONSTRAINT instances_pkey PRIMARY KEY (id)
-);
-comment on table auth.instances is 'Auth: Manages users across multiple sites.';
\ No newline at end of file
diff --git a/migrations_postgres/20210611113153_create_refresh_tokens.postgres.down.sql b/migrations_postgres/20210611113153_create_refresh_tokens.postgres.down.sql
deleted file mode 100644
index 2f8a1449..00000000
--- a/migrations_postgres/20210611113153_create_refresh_tokens.postgres.down.sql
+++ /dev/null
@@ -1,1 +0,0 @@
-DROP TABLE IF EXISTS refresh_tokens CASCADE;
\ No newline at end of file
diff --git a/migrations_postgres/20210611113153_create_refresh_tokens.postgres.up.sql b/migrations_postgres/20210611113153_create_refresh_tokens.postgres.up.sql
deleted file mode 100644
index 80941063..00000000
--- a/migrations_postgres/20210611113153_create_refresh_tokens.postgres.up.sql
+++ /dev/null
@@ -1,16 +0,0 @@
--- auth.refresh_tokens definition
-
-CREATE TABLE IF NOT EXISTS auth.refresh_tokens (
-	instance_id uuid NULL,
-	id bigserial NOT NULL,
-	"token" varchar(255) NULL,
-	user_id varchar(255) NULL,
-	revoked bool NULL,
-	created_at timestamptz NULL,
-	updated_at timestamptz NULL,
-	CONSTRAINT refresh_tokens_pkey PRIMARY KEY (id)
-);
-CREATE INDEX refresh_tokens_instance_id_idx ON auth.refresh_tokens USING btree (instance_id);
-CREATE INDEX refresh_tokens_instance_id_user_id_idx ON auth.refresh_tokens USING btree (instance_id, user_id);
-CREATE INDEX refresh_tokens_token_idx ON auth.refresh_tokens USING btree (token);
-comment on table auth.refresh_tokens is 'Auth: Store of tokens used to refresh JWT tokens once they expire.';
diff --git a/migrations_postgres/20210611130240_create_audit_log_entries.postgres.down.sql b/migrations_postgres/20210611130240_create_audit_log_entries.postgres.down.sql
deleted file mode 100644
index f9ce656f..00000000
--- a/migrations_postgres/20210611130240_create_audit_log_entries.postgres.down.sql
+++ /dev/null
@@ -1,1 +0,0 @@
-DROP TABLE IF EXISTS auth.audit_log_entries CASCADE;
\ No newline at end of file
diff --git a/migrations_postgres/20210611130240_create_audit_log_entries.postgres.up.sql b/migrations_postgres/20210611130240_create_audit_log_entries.postgres.up.sql
deleted file mode 100644
index 1c191ae2..00000000
--- a/migrations_postgres/20210611130240_create_audit_log_entries.postgres.up.sql
+++ /dev/null
@@ -1,11 +0,0 @@
--- auth.audit_log_entries definition
-
-CREATE TABLE IF NOT EXISTS auth.audit_log_entries (
-	instance_id uuid NULL,
-	id uuid NOT NULL,
-	payload json NULL,
-	created_at timestamptz NULL,
-	CONSTRAINT audit_log_entries_pkey PRIMARY KEY (id)
-);
-CREATE INDEX audit_logs_instance_id_idx ON auth.audit_log_entries USING btree (instance_id);
-comment on table auth.audit_log_entries is 'Auth: Audit trail for user actions.';
\ No newline at end of file
diff --git a/migrations_postgres/20210611130740_create_uid_functions.postgres.down.sql b/migrations_postgres/20210611130740_create_uid_functions.postgres.down.sql
deleted file mode 100644
index d1d97c63..00000000
--- a/migrations_postgres/20210611130740_create_uid_functions.postgres.down.sql
+++ /dev/null
@@ -1,1 +0,0 @@
-DROP FUNCTION IF EXISTS auth.uid() CASCADE;
\ No newline at end of file
diff --git a/migrations_postgres/20210611130740_create_uid_functions.postgres.up.sql b/migrations_postgres/20210611130740_create_uid_functions.postgres.up.sql
deleted file mode 100644
index 35579470..00000000
--- a/migrations_postgres/20210611130740_create_uid_functions.postgres.up.sql
+++ /dev/null
@@ -1,5 +0,0 @@
--- Gets the User ID from the request cookie
-
-create or replace function auth.uid() returns uuid as $$
-  select nullif(current_setting('request.jwt.claim.sub', true), '')::uuid;
-$$ language sql stable;
diff --git a/migrations_postgres/20210611130804_create_role_functions.postgres.down.sql b/migrations_postgres/20210611130804_create_role_functions.postgres.down.sql
deleted file mode 100644
index 9c617bfc..00000000
--- a/migrations_postgres/20210611130804_create_role_functions.postgres.down.sql
+++ /dev/null
@@ -1,1 +0,0 @@
-DROP FUNCTION IF EXISTS auth.role() CASCADE;
\ No newline at end of file
diff --git a/migrations_postgres/20210611130804_create_role_functions.postgres.up.sql b/migrations_postgres/20210611130804_create_role_functions.postgres.up.sql
deleted file mode 100644
index 3ccfa18e..00000000
--- a/migrations_postgres/20210611130804_create_role_functions.postgres.up.sql
+++ /dev/null
@@ -1,4 +0,0 @@
--- Gets the User ID from the request cookie
-create or replace function auth.role() returns text as $$
-  select nullif(current_setting('request.jwt.claim.role', true), '')::text;
-$$ language sql stable;
\ No newline at end of file
diff --git a/migrations_postgres/20210611131123_create_totp_secrets.postgres.down.sql b/migrations_postgres/20210611131123_create_totp_secrets.postgres.down.sql
deleted file mode 100644
index f6701087..00000000
--- a/migrations_postgres/20210611131123_create_totp_secrets.postgres.down.sql
+++ /dev/null
@@ -1,1 +0,0 @@
-DROP TABLE IF EXISTS auth.totp_secrets CASCADE;
\ No newline at end of file
diff --git a/migrations_postgres/20210611131123_create_totp_secrets.postgres.up.sql b/migrations_postgres/20210611131123_create_totp_secrets.postgres.up.sql
deleted file mode 100644
index 9f691def..00000000
--- a/migrations_postgres/20210611131123_create_totp_secrets.postgres.up.sql
+++ /dev/null
@@ -1,15 +0,0 @@
--- auth.totp_secrets definition
-
-CREATE TABLE IF NOT EXISTS auth.totp_auth(
-    instance_id uuid NULL,
-    id bigserial NOT NULL,
-    user_id uuid NOT NULL UNIQUE,
-    encrypted_url bytea NULL,
-    otp_last_requested_at timestamptz NULL,
-    created_at timestamptz NULL,
-	updated_at timestamptz NULL,
-    CONSTRAINT totp_secrets_pkey PRIMARY KEY (id)
-);
-CREATE INDEX totp_auth_instance_id_idx ON auth.totp_auth USING btree (instance_id);
-CREATE INDEX totp_auth_instance_id_user_id_idx ON auth.totp_auth USING btree (instance_id, user_id);
-comment on table auth.totp_auth is 'Auth: Store of totp secrets used to generate otp once they expire.';
\ No newline at end of file
diff --git a/migrations_postgres/20210611133028_create_add_supabase_auth_admin_roles.postgres.down.sql b/migrations_postgres/20210611133028_create_add_supabase_auth_admin_roles.postgres.down.sql
deleted file mode 100644
index cdd801c2..00000000
--- a/migrations_postgres/20210611133028_create_add_supabase_auth_admin_roles.postgres.down.sql
+++ /dev/null
@@ -1,2 +0,0 @@
-DROP OWNED BY supabase_auth_admin;
-DROP USER supabase_auth_admin;
\ No newline at end of file
diff --git a/migrations_postgres/20210611133028_create_add_supabase_auth_admin_roles.postgres.up.sql b/migrations_postgres/20210611133028_create_add_supabase_auth_admin_roles.postgres.up.sql
deleted file mode 100644
index dad7d7c1..00000000
--- a/migrations_postgres/20210611133028_create_add_supabase_auth_admin_roles.postgres.up.sql
+++ /dev/null
@@ -1,7 +0,0 @@
--- Supabase auth admin
-CREATE USER supabase_auth_admin NOINHERIT CREATEROLE LOGIN NOREPLICATION;
-GRANT ALL PRIVILEGES ON SCHEMA auth TO supabase_auth_admin;
-GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA auth TO supabase_auth_admin;
-GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA auth TO supabase_auth_admin;
-ALTER USER supabase_auth_admin SET search_path = "auth";
-ALTER USER supabase_auth_admin with password 'root';
\ No newline at end of file
diff --git a/migrations_postgres/20210611154701_create_add_phone_to_users.postgres.down.sql b/migrations_postgres/20210611154701_create_add_phone_to_users.postgres.down.sql
deleted file mode 100644
index d474d385..00000000
--- a/migrations_postgres/20210611154701_create_add_phone_to_users.postgres.down.sql
+++ /dev/null
@@ -1,5 +0,0 @@
--- Add phone, phone_confirmed_at columns to auth.users
-
-ALTER TABLE auth.users 
-DROP COLUMN phone,
-DROP COLUMN phone_confirmed_at;
\ No newline at end of file
diff --git a/migrations_postgres/20210611154701_create_add_phone_to_users.postgres.up.sql b/migrations_postgres/20210611154701_create_add_phone_to_users.postgres.up.sql
deleted file mode 100644
index 0a36084b..00000000
--- a/migrations_postgres/20210611154701_create_add_phone_to_users.postgres.up.sql
+++ /dev/null
@@ -1,5 +0,0 @@
--- Add phone, phone_confirmed_at columns to auth.users
-
-ALTER TABLE auth.users 
-ADD COLUMN phone VARCHAR(15) UNIQUE,
-ADD COLUMN phone_confirmed_at timestamptz;
\ No newline at end of file
diff --git a/migrations_postgres/schema.sql b/migrations_postgres/schema.sql
deleted file mode 100644
index bc3d348f..00000000
--- a/migrations_postgres/schema.sql
+++ /dev/null
@@ -1,44 +0,0 @@
---
--- PostgreSQL database dump
---
-
--- Dumped from database version 13.3 (Debian 13.3-1.pgdg100+1)
--- Dumped by pg_dump version 13.3
-
-SET statement_timeout = 0;
-SET lock_timeout = 0;
-SET idle_in_transaction_session_timeout = 0;
-SET client_encoding = 'UTF8';
-SET standard_conforming_strings = on;
-SELECT pg_catalog.set_config('search_path', '', false);
-SET check_function_bodies = false;
-SET xmloption = content;
-SET client_min_messages = warning;
-SET row_security = off;
-
-SET default_tablespace = '';
-
-SET default_table_access_method = heap;
-
---
--- Name: schema_migration; Type: TABLE; Schema: public; Owner: postgres
---
-
-CREATE TABLE public.schema_migration (
-    version character varying(14) NOT NULL
-);
-
-
-ALTER TABLE public.schema_migration OWNER TO postgres;
-
---
--- Name: schema_migration_version_idx; Type: INDEX; Schema: public; Owner: postgres
---
-
-CREATE UNIQUE INDEX schema_migration_version_idx ON public.schema_migration USING btree (version);
-
-
---
--- PostgreSQL database dump complete
---
-
diff --git a/models/audit_log_entry.go b/models/audit_log_entry.go
index a44b3908..1c79be5a 100644
--- a/models/audit_log_entry.go
+++ b/models/audit_log_entry.go
@@ -7,7 +7,6 @@ import (
 
 	"github.com/gofrs/uuid"
 	"github.com/netlify/gotrue/storage"
-	"github.com/netlify/gotrue/storage/namespace"
 	"github.com/pkg/errors"
 )
 
@@ -57,11 +56,6 @@ type AuditLogEntry struct {
 
 func (AuditLogEntry) TableName() string {
 	tableName := "audit_log_entries"
-
-	if namespace.GetNamespace() != "" {
-		return namespace.GetNamespace() + "_" + tableName
-	}
-
 	return tableName
 }
 
@@ -70,15 +64,19 @@ func NewAuditLogEntry(tx *storage.Connection, instanceID uuid.UUID, actor *User,
 	if err != nil {
 		return errors.Wrap(err, "Error generating unique id")
 	}
+	username := actor.GetEmail()
+	if actor.GetPhone() != "" {
+		username = actor.GetPhone()
+	}
 	l := AuditLogEntry{
 		InstanceID: instanceID,
 		ID:         id,
 		Payload: JSONMap{
-			"timestamp":   time.Now().UTC().Format(time.RFC3339),
-			"actor_id":    actor.ID,
-			"actor_email": actor.Email,
-			"action":      action,
-			"log_type":    actionLogTypeMap[action],
+			"timestamp":      time.Now().UTC().Format(time.RFC3339),
+			"actor_id":       actor.ID,
+			"actor_username": username,
+			"action":         action,
+			"log_type":       actionLogTypeMap[action],
 		},
 	}
 
diff --git a/models/errors.go b/models/errors.go
index 108b40a5..f568364f 100644
--- a/models/errors.go
+++ b/models/errors.go
@@ -11,6 +11,8 @@ func IsNotFoundError(err error) bool {
 		return true
 	case InstanceNotFoundError:
 		return true
+	case TotpSecretNotFoundError:
+		return true
 	}
 	return false
 }
@@ -42,3 +44,9 @@ type InstanceNotFoundError struct{}
 func (e InstanceNotFoundError) Error() string {
 	return "Instance not found"
 }
+
+type TotpSecretNotFoundError struct{}
+
+func (e TotpSecretNotFoundError) Error() string {
+	return "Totp Secret not found"
+}
diff --git a/models/instance.go b/models/instance.go
index 241e6d69..009c456a 100644
--- a/models/instance.go
+++ b/models/instance.go
@@ -8,7 +8,6 @@ import (
 	"github.com/gofrs/uuid"
 	"github.com/netlify/gotrue/conf"
 	"github.com/netlify/gotrue/storage"
-	"github.com/netlify/gotrue/storage/namespace"
 	"github.com/pkg/errors"
 )
 
@@ -27,11 +26,6 @@ type Instance struct {
 
 func (Instance) TableName() string {
 	tableName := "instances"
-
-	if namespace.GetNamespace() != "" {
-		return namespace.GetNamespace() + "_" + tableName
-	}
-
 	return tableName
 }
 
diff --git a/models/refresh_token.go b/models/refresh_token.go
index e2c074e2..ac229d67 100644
--- a/models/refresh_token.go
+++ b/models/refresh_token.go
@@ -7,7 +7,6 @@ import (
 	"github.com/gofrs/uuid"
 	"github.com/netlify/gotrue/crypto"
 	"github.com/netlify/gotrue/storage"
-	"github.com/netlify/gotrue/storage/namespace"
 	"github.com/pkg/errors"
 )
 
@@ -27,11 +26,6 @@ type RefreshToken struct {
 
 func (RefreshToken) TableName() string {
 	tableName := "refresh_tokens"
-
-	if namespace.GetNamespace() != "" {
-		return namespace.GetNamespace() + "_" + tableName
-	}
-
 	return tableName
 }
 
diff --git a/models/user.go b/models/user.go
index 684f2331..879441bd 100644
--- a/models/user.go
+++ b/models/user.go
@@ -8,7 +8,6 @@ import (
 	"github.com/gobuffalo/pop/v5"
 	"github.com/gofrs/uuid"
 	"github.com/netlify/gotrue/storage"
-	"github.com/netlify/gotrue/storage/namespace"
 	"github.com/pkg/errors"
 	"golang.org/x/crypto/bcrypt"
 )
@@ -22,12 +21,15 @@ type User struct {
 	InstanceID uuid.UUID `json:"-" db:"instance_id"`
 	ID         uuid.UUID `json:"id" db:"id"`
 
-	Aud               string     `json:"aud" db:"aud"`
-	Role              string     `json:"role" db:"role"`
-	Email             string     `json:"email" db:"email"`
-	EncryptedPassword string     `json:"-" db:"encrypted_password"`
-	ConfirmedAt       *time.Time `json:"confirmed_at,omitempty" db:"confirmed_at"`
-	InvitedAt         *time.Time `json:"invited_at,omitempty" db:"invited_at"`
+	Aud               string             `json:"aud" db:"aud"`
+	Role              string             `json:"role" db:"role"`
+	Email             storage.NullString `json:"email" db:"email"`
+	EncryptedPassword string             `json:"-" db:"encrypted_password"`
+	EmailConfirmedAt  *time.Time         `json:"email_confirmed_at,omitempty" db:"email_confirmed_at"`
+	InvitedAt         *time.Time         `json:"invited_at,omitempty" db:"invited_at"`
+
+	Phone            storage.NullString `json:"phone" db:"phone"`
+	PhoneConfirmedAt *time.Time         `json:"phone_confirmed_at,omitempty" db:"phone_confirmed_at"`
 
 	ConfirmationToken  string     `json:"-" db:"confirmation_token"`
 	ConfirmationSentAt *time.Time `json:"confirmation_sent_at,omitempty" db:"confirmation_sent_at"`
@@ -39,6 +41,10 @@ type User struct {
 	EmailChange       string     `json:"new_email,omitempty" db:"email_change"`
 	EmailChangeSentAt *time.Time `json:"email_change_sent_at,omitempty" db:"email_change_sent_at"`
 
+	PhoneChangeToken  string     `json:"-" db:"phone_change_token"`
+	PhoneChange       string     `json:"new_phone,omitempty" db:"phone_change"`
+	PhoneChangeSentAt *time.Time `json:"phone_change_sent_at,omitempty" db:"phone_change_sent_at"`
+
 	LastSignInAt *time.Time `json:"last_sign_in_at,omitempty" db:"last_sign_in_at"`
 
 	AppMetaData  JSONMap `json:"app_metadata" db:"raw_app_meta_data"`
@@ -51,6 +57,7 @@ type User struct {
 }
 
 // NewUser initializes a new user from an email, password and user data.
+// TODO: Refactor NewUser to take in phone as an arg
 func NewUser(instanceID uuid.UUID, email, password, aud string, userData map[string]interface{}) (*User, error) {
 	id, err := uuid.NewV4()
 	if err != nil {
@@ -65,7 +72,7 @@ func NewUser(instanceID uuid.UUID, email, password, aud string, userData map[str
 		InstanceID:        instanceID,
 		ID:                id,
 		Aud:               aud,
-		Email:             strings.ToLower(email),
+		Email:             storage.NullString(strings.ToLower(email)),
 		UserMetaData:      userData,
 		EncryptedPassword: pw,
 	}
@@ -83,11 +90,6 @@ func NewSystemUser(instanceID uuid.UUID, aud string) *User {
 
 func (User) TableName() string {
 	tableName := "users"
-
-	if namespace.GetNamespace() != "" {
-		return namespace.GetNamespace() + "_" + tableName
-	}
-
 	return tableName
 }
 
@@ -108,8 +110,8 @@ func (u *User) BeforeSave(tx *pop.Connection) error {
 		return errors.New("Cannot persist system user")
 	}
 
-	if u.ConfirmedAt != nil && u.ConfirmedAt.IsZero() {
-		u.ConfirmedAt = nil
+	if u.EmailConfirmedAt != nil && u.EmailConfirmedAt.IsZero() {
+		u.EmailConfirmedAt = nil
 	}
 	if u.InvitedAt != nil && u.InvitedAt.IsZero() {
 		u.InvitedAt = nil
@@ -123,16 +125,25 @@ func (u *User) BeforeSave(tx *pop.Connection) error {
 	if u.EmailChangeSentAt != nil && u.EmailChangeSentAt.IsZero() {
 		u.EmailChangeSentAt = nil
 	}
+	if u.PhoneChangeSentAt != nil && u.PhoneChangeSentAt.IsZero() {
+		u.PhoneChangeSentAt = nil
+	}
 	if u.LastSignInAt != nil && u.LastSignInAt.IsZero() {
 		u.LastSignInAt = nil
 	}
 	return nil
 }
 
-// IsConfirmed checks if a user has already being
+// IsConfirmed checks if a user has already been
 // registered and confirmed.
 func (u *User) IsConfirmed() bool {
-	return u.ConfirmedAt != nil
+	return u.EmailConfirmedAt != nil
+}
+
+// IsPhoneConfirmed checks if a user's phone has already been
+// registered and confirmed.
+func (u *User) IsPhoneConfirmed() bool {
+	return u.PhoneConfirmedAt != nil
 }
 
 // SetRole sets the users Role to roleName
@@ -146,6 +157,14 @@ func (u *User) HasRole(roleName string) bool {
 	return u.Role == roleName
 }
 
+func (u *User) GetEmail() string {
+	return string(u.Email)
+}
+
+func (u *User) GetPhone() string {
+	return string(u.Phone)
+}
+
 // UpdateUserMetaData sets all user data from a map of updates,
 // ensuring that it doesn't override attributes that are not
 // in the provided map.
@@ -181,10 +200,15 @@ func (u *User) UpdateAppMetaData(tx *storage.Connection, updates map[string]inte
 }
 
 func (u *User) SetEmail(tx *storage.Connection, email string) error {
-	u.Email = email
+	u.Email = storage.NullString(email)
 	return tx.UpdateOnly(u, "email")
 }
 
+func (u *User) SetPhone(tx *storage.Connection, phone string) error {
+	u.Phone = storage.NullString(phone)
+	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), bcrypt.DefaultCost)
@@ -203,18 +227,31 @@ func (u *User) UpdatePassword(tx *storage.Connection, password string) error {
 	return tx.UpdateOnly(u, "encrypted_password")
 }
 
+func (u *User) UpdatePhone(tx *storage.Connection, phone string) error {
+	u.Phone = storage.NullString(phone)
+	return tx.UpdateOnly(u, "phone")
+}
+
 // Authenticate a user from a password
 func (u *User) Authenticate(password string) bool {
 	err := bcrypt.CompareHashAndPassword([]byte(u.EncryptedPassword), []byte(password))
 	return err == nil
 }
 
-// Confirm resets the confimation token and the confirm timestamp
+// Confirm resets the confimation token and sets the confirm timestamp
 func (u *User) Confirm(tx *storage.Connection) error {
 	u.ConfirmationToken = ""
 	now := time.Now()
-	u.ConfirmedAt = &now
-	return tx.UpdateOnly(u, "confirmation_token", "confirmed_at")
+	u.EmailConfirmedAt = &now
+	return tx.UpdateOnly(u, "confirmation_token", "email_confirmed_at")
+}
+
+// ConfirmPhone resets the confimation token and sets the confirm timestamp
+func (u *User) ConfirmPhone(tx *storage.Connection) error {
+	u.ConfirmationToken = ""
+	now := time.Now()
+	u.PhoneConfirmedAt = &now
+	return tx.UpdateOnly(u, "confirmation_token", "phone_confirmed_at")
 }
 
 // UpdateLastSignInAt update field last_sign_in_at for user according to specified field
@@ -224,12 +261,22 @@ func (u *User) UpdateLastSignInAt(tx *storage.Connection) error {
 
 // ConfirmEmailChange confirm the change of email for a user
 func (u *User) ConfirmEmailChange(tx *storage.Connection) error {
-	u.Email = u.EmailChange
+	u.Email = storage.NullString(u.EmailChange)
 	u.EmailChange = ""
 	u.EmailChangeToken = ""
 	return tx.UpdateOnly(u, "email", "email_change", "email_change_token")
 }
 
+// ConfirmPhoneChange confirms the change of phone for a user
+func (u *User) ConfirmPhoneChange(tx *storage.Connection) error {
+	u.Phone = storage.NullString(u.PhoneChange)
+	u.PhoneChange = ""
+	u.PhoneChangeToken = ""
+	now := time.Now()
+	u.PhoneConfirmedAt = &now
+	return tx.UpdateOnly(u, "phone", "phone_change", "phone_change_token", "phone_confirmed_at")
+}
+
 // Recover resets the recovery token
 func (u *User) Recover(tx *storage.Connection) error {
 	u.RecoveryToken = ""
@@ -268,6 +315,11 @@ func FindUserByEmailAndAudience(tx *storage.Connection, instanceID uuid.UUID, em
 	return findUser(tx, "instance_id = ? and LOWER(email) = ? and aud = ?", instanceID, strings.ToLower(email), aud)
 }
 
+// FindUserByEmailAndAudience finds a user with the matching email and audience.
+func FindUserByPhoneAndAudience(tx *storage.Connection, instanceID uuid.UUID, phone, aud string) (*User, error) {
+	return findUser(tx, "instance_id = ? and phone = ? and aud = ?", instanceID, phone, aud)
+}
+
 // FindUserByID finds a user matching the provided ID.
 func FindUserByID(tx *storage.Connection, id uuid.UUID) (*User, error) {
 	return findUser(tx, "id = ?", id)
@@ -329,6 +381,11 @@ func FindUsersInAudience(tx *storage.Connection, instanceID uuid.UUID, aud strin
 	return users, err
 }
 
+// FindUserWithPhoneAndPhoneChangeToken finds a user with the matching phone and phone change token
+func FindUserWithPhoneAndPhoneChangeToken(tx *storage.Connection, phone, token string) (*User, error) {
+	return findUser(tx, "phone = ? and phone_change_token = ?", phone, token)
+}
+
 // IsDuplicatedEmail returns whether a user exists with a matching email and audience.
 func IsDuplicatedEmail(tx *storage.Connection, instanceID uuid.UUID, email, aud string) (bool, error) {
 	_, err := FindUserByEmailAndAudience(tx, instanceID, email, aud)
@@ -340,3 +397,14 @@ func IsDuplicatedEmail(tx *storage.Connection, instanceID uuid.UUID, email, aud
 	}
 	return true, nil
 }
+
+func IsDuplicatedPhone(tx *storage.Connection, instanceID uuid.UUID, phone, aud string) (bool, error) {
+	_, err := FindUserByPhoneAndAudience(tx, instanceID, phone, aud)
+	if err != nil {
+		if IsNotFoundError(err) {
+			return false, nil
+		}
+		return false, err
+	}
+	return true, nil
+}
diff --git a/storage/dial.go b/storage/dial.go
index 6b807685..e176851e 100644
--- a/storage/dial.go
+++ b/storage/dial.go
@@ -9,7 +9,6 @@ import (
 	"github.com/gobuffalo/pop/v5"
 	"github.com/gobuffalo/pop/v5/columns"
 	"github.com/netlify/gotrue/conf"
-	"github.com/netlify/gotrue/storage/namespace"
 	"github.com/pkg/errors"
 	"github.com/sirupsen/logrus"
 )
@@ -39,11 +38,6 @@ func Dial(config *conf.GlobalConfiguration) (*Connection, error) {
 	if err := db.Open(); err != nil {
 		return nil, errors.Wrap(err, "checking database connection")
 	}
-
-	if config.DB.Namespace != "" {
-		namespace.SetNamespace(config.DB.Namespace)
-	}
-
 	if logrus.StandardLogger().Level == logrus.DebugLevel {
 		pop.Debug = true
 	}
diff --git a/storage/helper.go b/storage/helper.go
new file mode 100644
index 00000000..2df3a167
--- /dev/null
+++ b/storage/helper.go
@@ -0,0 +1,27 @@
+package storage
+
+import (
+	"database/sql/driver"
+	"errors"
+)
+
+type NullString string
+
+func (s *NullString) Scan(value interface{}) error {
+	if value == nil {
+		*s = ""
+		return nil
+	}
+	strVal, ok := value.(string)
+	if !ok {
+		return errors.New("Column is not a string")
+	}
+	*s = NullString(strVal)
+	return nil
+}
+func (s NullString) Value() (driver.Value, error) {
+	if len(s) == 0 { // if nil or empty string
+		return nil, nil
+	}
+	return string(s), nil
+}
diff --git a/storage/namespace/namespace.go b/storage/namespace/namespace.go
deleted file mode 100644
index a2ade7c2..00000000
--- a/storage/namespace/namespace.go
+++ /dev/null
@@ -1,11 +0,0 @@
-package namespace
-
-var namespace string
-
-func SetNamespace(ns string) {
-	namespace = ns
-}
-
-func GetNamespace() string {
-	return namespace
-}


Test patch:
diff --git a/api/admin_test.go b/api/admin_test.go
index 94e23d3b..0fd81481 100644
--- a/api/admin_test.go
+++ b/api/admin_test.go
@@ -10,9 +10,9 @@ import (
 	"time"
 
 	jwt "github.com/dgrijalva/jwt-go"
+	"github.com/gofrs/uuid"
 	"github.com/netlify/gotrue/conf"
 	"github.com/netlify/gotrue/models"
-	"github.com/gofrs/uuid"
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
 	"github.com/stretchr/testify/suite"
@@ -53,6 +53,7 @@ func (ts *AdminTestSuite) makeSuperAdmin(email string) string {
 	require.NoError(ts.T(), err, "Error making new user")
 
 	u.IsSuperAdmin = true
+	u.Role = "supabase_admin"
 	require.NoError(ts.T(), ts.API.db.Create(u), "Error creating user")
 
 	token, err := generateAccessToken(u, time.Second*time.Duration(ts.Config.JWT.Exp), ts.Config.JWT.Secret)
@@ -69,7 +70,7 @@ func (ts *AdminTestSuite) makeSuperAdmin(email string) string {
 
 func (ts *AdminTestSuite) makeSystemUser() string {
 	u := models.NewSystemUser(uuid.Nil, ts.Config.JWT.Aud)
-
+	u.Role = "service_role"
 	token, err := generateAccessToken(u, time.Second*time.Duration(ts.Config.JWT.Exp), ts.Config.JWT.Secret)
 	require.NoError(ts.T(), err, "Error generating access token")
 
@@ -174,8 +175,8 @@ func (ts *AdminTestSuite) TestAdminUsers_SortAsc() {
 	require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&data))
 
 	require.Len(ts.T(), data.Users, 2)
-	assert.Equal(ts.T(), "test@example.com", data.Users[0].Email)
-	assert.Equal(ts.T(), "test1@example.com", data.Users[1].Email)
+	assert.Equal(ts.T(), "test@example.com", data.Users[0].GetEmail())
+	assert.Equal(ts.T(), "test1@example.com", data.Users[1].GetEmail())
 }
 
 // TestAdminUsers tests API /admin/users route
@@ -202,8 +203,8 @@ func (ts *AdminTestSuite) TestAdminUsers_SortDesc() {
 	require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&data))
 
 	require.Len(ts.T(), data.Users, 2)
-	assert.Equal(ts.T(), "test1@example.com", data.Users[0].Email)
-	assert.Equal(ts.T(), "test@example.com", data.Users[1].Email)
+	assert.Equal(ts.T(), "test1@example.com", data.Users[0].GetEmail())
+	assert.Equal(ts.T(), "test@example.com", data.Users[1].GetEmail())
 }
 
 // TestAdminUsers tests API /admin/users route
@@ -261,6 +262,7 @@ func (ts *AdminTestSuite) TestAdminUserCreate() {
 	var buffer bytes.Buffer
 	require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]interface{}{
 		"email":    "test1@example.com",
+		"phone":    "123456789",
 		"password": "test1",
 	}))
 
@@ -275,7 +277,8 @@ func (ts *AdminTestSuite) TestAdminUserCreate() {
 
 	data := models.User{}
 	require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&data))
-	assert.Equal(ts.T(), "test1@example.com", data.Email)
+	assert.Equal(ts.T(), "test1@example.com", data.GetEmail())
+	assert.Equal(ts.T(), "123456789", data.GetPhone())
 	assert.Equal(ts.T(), "email", data.AppMetaData["provider"])
 }
 
@@ -428,7 +431,7 @@ func (ts *AdminTestSuite) TestAdminUserCreateWithManagementToken() {
 	require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&data))
 
 	assert.NotNil(ts.T(), data.ID)
-	assert.Equal(ts.T(), "test2@example.com", data.Email)
+	assert.Equal(ts.T(), "test2@example.com", data.GetEmail())
 }
 
 func (ts *AdminTestSuite) TestAdminUserCreateWithDisabledEmailLogin() {
diff --git a/api/verify_test.go b/api/verify_test.go
index b574b382..0329fd66 100644
--- a/api/verify_test.go
+++ b/api/verify_test.go
@@ -45,6 +45,7 @@ func (ts *VerifyTestSuite) SetupTest() {
 
 	// Create user
 	u, err := models.NewUser(ts.instanceID, "test@example.com", "password", ts.Config.JWT.Aud, nil)
+	u.Phone = "12345678"
 	require.NoError(ts.T(), err, "Error creating test user model")
 	require.NoError(ts.T(), ts.API.db.Create(u), "Error saving new test user")
 }
@@ -122,6 +123,57 @@ func (ts *VerifyTestSuite) TestExpiredConfirmationToken() {
 	assert.Equal(ts.T(), http.StatusGone, w.Code, w.Body.String())
 }
 
+func (ts *VerifyTestSuite) TestInvalidSmsOtp() {
+	u, err := models.FindUserByPhoneAndAudience(ts.API.db, ts.instanceID, "12345678", ts.Config.JWT.Aud)
+	require.NoError(ts.T(), err)
+	u.ConfirmationToken = "123456"
+	sentTime := time.Now().Add(-48 * time.Hour)
+	u.ConfirmationSentAt = &sentTime
+	require.NoError(ts.T(), ts.API.db.Update(u))
+
+	// Request body for expired OTP
+	var buffer bytes.Buffer
+	require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]interface{}{
+		"type":  smsVerification,
+		"token": u.ConfirmationToken,
+		"phone": u.GetPhone(),
+	}))
+
+	// Setup request
+	req := httptest.NewRequest(http.MethodPost, "http://localhost/verify", &buffer)
+	req.Header.Set("Content-Type", "application/json")
+
+	// Setup response recorder
+	w := httptest.NewRecorder()
+
+	ts.API.handler.ServeHTTP(w, req)
+
+	assert.Equal(ts.T(), http.StatusGone, w.Code, w.Body.String())
+
+	// Reset confirmation sent at
+	sentTime = time.Now()
+	u.ConfirmationSentAt = &sentTime
+	require.NoError(ts.T(), ts.API.db.Update(u))
+
+	// Request Body for invalid otp
+	require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]interface{}{
+		"type":  smsVerification,
+		"token": "654321",
+		"phone": u.GetPhone(),
+	}))
+
+	// Setup request
+	req = httptest.NewRequest(http.MethodPost, "http://localhost/verify", &buffer)
+	req.Header.Set("Content-Type", "application/json")
+
+	// Setup response recorder
+	w = httptest.NewRecorder()
+
+	ts.API.handler.ServeHTTP(w, req)
+
+	assert.Equal(ts.T(), http.StatusGone, w.Code, w.Body.String())
+}
+
 func (ts *VerifyTestSuite) TestExpiredRecoveryToken() {
 	u, err := models.FindUserByEmailAndAudience(ts.API.db, ts.instanceID, "test@example.com", ts.Config.JWT.Aud)
 	require.NoError(ts.T(), err)
@@ -146,7 +198,7 @@ func (ts *VerifyTestSuite) TestExpiredRecoveryToken() {
 
 	ts.API.handler.ServeHTTP(w, req)
 
-	assert.Equal(ts.T(), http.StatusGone, w.Code, w.Body.String())
+	assert.Equal(ts.T(), http.StatusFound, w.Code, w.Body.String())
 }
 
 func (ts *VerifyTestSuite) TestVerifyPermitedCustomUri() {
diff --git a/hack/test.env b/hack/test.env
index 6b6e850e..bad1aef9 100644
--- a/hack/test.env
+++ b/hack/test.env
@@ -1,14 +1,17 @@
 GOTRUE_JWT_SECRET=testsecret
 GOTRUE_JWT_EXP=3600
-GOTRUE_JWT_AUD=api.netlify.com
-GOTRUE_DB_DRIVER=mysql
+GOTRUE_JWT_AUD="authenticated"
+GOTRUE_JWT_ADMIN_ROLES="supabase_admin,service_role"
+GOTRUE_JWT_DEFAULT_GROUP_NAME="authenticated"
+GOTRUE_DB_DRIVER=postgres
+DB_NAMESPACE="auth"
 GOTRUE_DB_AUTOMIGRATE=true
-GOTRUE_DB_NAMESPACE=test
-DATABASE_URL="root@tcp(127.0.0.1:3306)/gotrue_test?parseTime=true&sql_mode=TRADITIONAL&multiStatements=true"
+DATABASE_URL="postgres://supabase_auth_admin:root@localhost:5432/postgres"
 GOTRUE_API_HOST=localhost
 PORT=9999
 GOTRUE_LOG_LEVEL=debug
 GOTRUE_SITE_URL=https://example.netlify.com
+GOTRUE_URI_ALLOW_LIST="http://localhost:3000"
 GOTRUE_OPERATOR_TOKEN=foobar
 GOTRUE_EXTERNAL_AZURE_ENABLED=true
 GOTRUE_EXTERNAL_AZURE_CLIENT_ID=testclientid
diff --git a/models/user_test.go b/models/user_test.go
index 3b2a9862..752adf44 100644
--- a/models/user_test.go
+++ b/models/user_test.go
@@ -87,11 +87,11 @@ func (ts *UserTestSuite) TestFindUserByConfirmationToken() {
 func (ts *UserTestSuite) TestFindUserByEmailAndAudience() {
 	u := ts.createUser()
 
-	n, err := FindUserByEmailAndAudience(ts.db, u.InstanceID, u.Email, "test")
+	n, err := FindUserByEmailAndAudience(ts.db, u.InstanceID, u.GetEmail(), "test")
 	require.NoError(ts.T(), err)
 	require.Equal(ts.T(), u.ID, n.ID)
 
-	_, err = FindUserByEmailAndAudience(ts.db, u.InstanceID, u.Email, "invalid")
+	_, err = FindUserByEmailAndAudience(ts.db, u.InstanceID, u.GetEmail(), "invalid")
 	require.EqualError(ts.T(), err, UserNotFoundError{}.Error())
 }