Repository: supabase/gotrue
Pull request #626 (2022-08-23 14:15:27)

chore: Add gosec scanning (exlcudes G104)
Adds [gosec](https://github.com/securego/gosec) scanning. Fixes all errors except _G104 - Unhandled error_ since they're dealing mostly with `Close()` errors on readers. I shall fix them in a follow-up PR.


Patch:
diff --git a/Makefile b/Makefile
index f1c60862..68e6a061 100644
--- a/Makefile
+++ b/Makefile
@@ -1,24 +1,24 @@
-.PHONY: all build deps image lint migrate test vet
-CHECK_FILES?=$$(go list ./... | grep -v /vendor/)
+.PHONY: all build deps dev-deps image migrate test vet sec format
+CHECK_FILES?=./...
 FLAGS?=-ldflags "-X github.com/netlify/gotrue/cmd.Version=`git describe --tags`"
 DEV_DOCKER_COMPOSE:=docker-compose-dev.yml
 
 help: ## Show this help.
 	@awk 'BEGIN {FS = ":.*?## "} /^[a-zA-Z_-]+:.*?## / {sub("\\\\n",sprintf("\n%22c"," "), $$2);printf "\033[36m%-20s\033[0m %s\n", $$1, $$2}' $(MAKEFILE_LIST)
 
-all: lint vet build ## Run the tests and build the binary.
+all: vet sec build ## Run the tests and build the binary.
 
-build: ## Build the binary.
+build: deps ## Build the binary.
 	go build $(FLAGS)
 	GOOS=linux GOARCH=arm64 go build $(FLAGS) -o gotrue-arm64
 
-deps: ## Install dependencies.
+dev-deps: deps ## Install developer dependencies
 	@go install github.com/gobuffalo/pop/soda@latest
-	@go install golang.org/x/lint/golint@latest
-	@go mod download
+	@go install github.com/securego/gosec/v2/cmd/gosec@latest
 
-lint: ## Lint the code.
-	golint $(CHECK_FILES)
+deps: ## Install dependencies.
+	@go mod download
+	@go mod verify
 
 migrate_dev: ## Run database migrations for development.
 	hack/migrate.sh postgres
@@ -26,12 +26,15 @@ migrate_dev: ## Run database migrations for development.
 migrate_test: ## Run database migrations for test.
 	hack/migrate.sh postgres
 
-test: ## Run tests.
+test: build ## Run tests.
 	go test $(CHECK_FILES) -coverprofile=coverage.out -coverpkg ./... -p 1 -race -v -count=1
 
 vet: # Vet the code
 	go vet $(CHECK_FILES)
 
+sec: # Check for security vulnerabilities
+	gosec -tests -exclude=G104 $(CHECK_FILES)
+
 dev: ## Run the development containers
 	docker-compose -f $(DEV_DOCKER_COMPOSE) up
 
diff --git a/SECURITY.md b/SECURITY.md
new file mode 100644
index 00000000..9c9828ba
--- /dev/null
+++ b/SECURITY.md
@@ -0,0 +1,60 @@
+# Security Policy
+
+GoTrue is a project maintained by [Supabase](https://supabase.com). Below is
+our security policy.
+
+Contact: security@supabase.io 
+Canonical: https://supabase.com/.well-known/security.txt
+
+At Supabase, we consider the security of our systems a top priority. But no
+matter how much effort we put into system security, there can still be
+vulnerabilities present.
+
+If you discover a vulnerability, we would like to know about it so we can take
+steps to address it as quickly as possible. We would like to ask you to help us
+better protect our clients and our systems.
+
+Out of scope vulnerabilities:
+
+- Clickjacking on pages with no sensitive actions.
+- Unauthenticated/logout/login CSRF.
+- Attacks requiring MITM or physical access to a user's device.
+- Any activity that could lead to the disruption of our service (DoS).
+- Content spoofing and text injection issues without showing an attack
+  vector/without being able to modify HTML/CSS.
+- Email spoofing
+- Missing DNSSEC, CAA, CSP headers
+- Lack of Secure or HTTP only flag on non-sensitive cookies
+- Deadlinks
+
+Please do the following:
+
+- E-mail your findings to security@supabase.io. 
+- Do not run automated scanners on our infrastructure or dashboard. If you wish
+  to do this, contact us and we will set up a sandbox for you. 
+- Do not take advantage of the vulnerability or problem you have discovered,
+  for example by downloading more data than necessary to demonstrate the
+  vulnerability or deleting or modifying other people's data,
+- Do not reveal the problem to others until it has been resolved,
+- Do not use attacks on physical security, social engineering, distributed
+  denial of service, spam or applications of third parties, and
+- Do provide sufficient information to reproduce the problem, so we will be
+  able to resolve it as quickly as possible. Usually, the IP address or the URL
+  of the affected system and a description of the vulnerability will be
+  sufficient, but complex vulnerabilities may require further explanation.
+
+What we promise:
+
+- We will respond to your report within 3 business days with our evaluation of
+  the report and an expected resolution date,
+- If you have followed the instructions above, we will not take any legal
+  action against you in regard to the report,
+- We will handle your report with strict confidentiality, and not pass on your
+  personal details to third parties without your permission,
+- We will keep you informed of the progress towards resolving the problem,
+- In the public information concerning the problem reported, we will give your
+  name as the discoverer of the problem (unless you desire otherwise), and
+
+We strive to resolve all problems as quickly as possible, and we would like to
+play an active role in the ultimate publication on the problem after it is
+resolved.
diff --git a/api/admin.go b/api/admin.go
index b147b917..346a45f6 100644
--- a/api/admin.go
+++ b/api/admin.go
@@ -50,10 +50,16 @@ func (a *API) loadUser(w http.ResponseWriter, r *http.Request) (context.Context,
 
 func (a *API) getAdminParams(r *http.Request) (*adminUserParams, error) {
 	params := adminUserParams{}
-	err := json.NewDecoder(r.Body).Decode(&params)
+
+	body, err := getBodyBytes(r)
 	if err != nil {
+		return nil, badRequestError("Could not read body").WithInternalError(err)
+	}
+
+	if err := json.Unmarshal(body, &params); err != nil {
 		return nil, badRequestError("Could not decode admin user params: %v", err)
 	}
+
 	return &params, nil
 }
 
diff --git a/api/api.go b/api/api.go
index d6b778ab..ad9cce9f 100644
--- a/api/api.go
+++ b/api/api.go
@@ -40,8 +40,9 @@ type API struct {
 func (a *API) ListenAndServe(hostAndPort string) {
 	log := logrus.WithField("component", "api")
 	server := &http.Server{
-		Addr:    hostAndPort,
-		Handler: a.handler,
+		Addr:              hostAndPort,
+		Handler:           a.handler,
+		ReadHeaderTimeout: 2 * time.Second, // to mitigate a Slowloris attack
 	}
 
 	done := make(chan struct{})
@@ -152,13 +153,11 @@ func NewAPIWithVersion(ctx context.Context, globalConfig *conf.GlobalConfigurati
 
 		r.With(api.requireAuthentication).Post("/logout", api.Logout)
 
-		r.Route("/reauthenticate", func(r *router) {
-			r.Use(api.requireAuthentication)
+		r.With(api.requireAuthentication).Route("/reauthenticate", func(r *router) {
 			r.Get("/", api.Reauthenticate)
 		})
 
-		r.Route("/user", func(r *router) {
-			r.Use(api.requireAuthentication)
+		r.With(api.requireAuthentication).Route("/user", func(r *router) {
 			r.Get("/", api.UserGet)
 			r.With(sharedLimiter).Put("/", api.UserUpdate)
 		})
diff --git a/api/auth.go b/api/auth.go
index bab0e1c0..6c42066c 100644
--- a/api/auth.go
+++ b/api/auth.go
@@ -2,10 +2,12 @@ package api
 
 import (
 	"context"
+	"errors"
 	"fmt"
 	"net/http"
 	"time"
 
+	"github.com/gofrs/uuid"
 	jwt "github.com/golang-jwt/jwt"
 	"github.com/netlify/gotrue/models"
 	"github.com/netlify/gotrue/storage"
@@ -20,7 +22,16 @@ func (a *API) requireAuthentication(w http.ResponseWriter, r *http.Request) (con
 		return nil, err
 	}
 
-	return a.parseJWTClaims(token, r, w)
+	ctx, err := a.parseJWTClaims(token, r, w)
+	if err != nil {
+		return ctx, err
+	}
+
+	ctx, err = a.maybeLoadUserOrSession(ctx)
+	if err != nil {
+		return ctx, err
+	}
+	return ctx, err
 }
 
 func (a *API) requireAdmin(ctx context.Context, w http.ResponseWriter, r *http.Request) (context.Context, error) {
@@ -44,10 +55,6 @@ func (a *API) requireAdmin(ctx context.Context, w http.ResponseWriter, r *http.R
 
 func (a *API) extractBearerToken(w http.ResponseWriter, r *http.Request) (string, error) {
 	authHeader := r.Header.Get("Authorization")
-	if authHeader == "" {
-		return "", unauthorizedError("This endpoint requires a Bearer token")
-	}
-
 	matches := bearerRegexp.FindStringSubmatch(authHeader)
 	if len(matches) != 2 {
 		return "", unauthorizedError("This endpoint requires a Bearer token")
@@ -71,3 +78,41 @@ func (a *API) parseJWTClaims(bearer string, r *http.Request, w http.ResponseWrit
 
 	return withToken(ctx, token), nil
 }
+
+func (a *API) maybeLoadUserOrSession(ctx context.Context) (context.Context, error) {
+	claims := getClaims(ctx)
+	if claims == nil {
+		return ctx, errors.New("invalid token")
+	}
+
+	if claims.Subject == "" {
+		return nil, errors.New("invalid claim: subject missing")
+	}
+
+	var user *models.User
+	if claims.Subject != "" {
+		userId, err := uuid.FromString(claims.Subject)
+		if err != nil {
+			return ctx, err
+		}
+		user, err = models.FindUserByID(a.db, userId)
+		if err != nil {
+			return ctx, err
+		}
+		ctx = withUser(ctx, user)
+	}
+
+	var session *models.Session
+	if claims.SessionId != "" {
+		sessionId, err := uuid.FromString(claims.SessionId)
+		if err != nil {
+			return ctx, err
+		}
+		session, err = models.FindSessionById(a.db, sessionId)
+		if err != nil {
+			return ctx, err
+		}
+		ctx = withSession(ctx, session)
+	}
+	return ctx, nil
+}
diff --git a/api/context.go b/api/context.go
index d0150b93..1543b5ba 100644
--- a/api/context.go
+++ b/api/context.go
@@ -20,6 +20,7 @@ const (
 	signatureKey            = contextKey("signature")
 	externalProviderTypeKey = contextKey("external_provider_type")
 	userKey                 = contextKey("user")
+	sessionKey              = contextKey("session")
 	externalReferrerKey     = contextKey("external_referrer")
 	functionHooksKey        = contextKey("function_hooks")
 	adminUserKey            = contextKey("admin_user")
@@ -65,12 +66,12 @@ func getRequestID(ctx context.Context) string {
 	return obj.(string)
 }
 
-// withUser adds the user id to the context.
+// withUser adds the user to the context.
 func withUser(ctx context.Context, u *models.User) context.Context {
 	return context.WithValue(ctx, userKey, u)
 }
 
-// getUser reads the user id from the context.
+// getUser reads the user from the context.
 func getUser(ctx context.Context) *models.User {
 	obj := ctx.Value(userKey)
 	if obj == nil {
@@ -79,6 +80,20 @@ func getUser(ctx context.Context) *models.User {
 	return obj.(*models.User)
 }
 
+// withSession adds the session to the context.
+func withSession(ctx context.Context, s *models.Session) context.Context {
+	return context.WithValue(ctx, sessionKey, s)
+}
+
+// getSession reads the session from the context.
+func getSession(ctx context.Context) *models.Session {
+	obj := ctx.Value(sessionKey)
+	if obj == nil {
+		return nil
+	}
+	return obj.(*models.Session)
+}
+
 // withSignature adds the provided request ID to the context.
 func withSignature(ctx context.Context, id string) context.Context {
 	return context.WithValue(ctx, signatureKey, id)
diff --git a/api/external.go b/api/external.go
index 21b37e90..ed84659d 100644
--- a/api/external.go
+++ b/api/external.go
@@ -316,10 +316,10 @@ func (a *API) processInvite(r *http.Request, ctx context.Context, tx *storage.Co
 
 	var emailData *provider.Email
 	var emails []string
-	for _, e := range userData.Emails {
+	for i, e := range userData.Emails {
 		emails = append(emails, e.Email)
 		if user.GetEmail() == e.Email {
-			emailData = &e
+			emailData = &userData.Emails[i]
 			break
 		}
 	}
diff --git a/api/helpers.go b/api/helpers.go
index d2be35ff..55324f45 100644
--- a/api/helpers.go
+++ b/api/helpers.go
@@ -1,9 +1,11 @@
 package api
 
 import (
+	"bytes"
 	"context"
 	"encoding/json"
 	"fmt"
+	"io"
 	"net"
 	"net/http"
 	"net/http/httptrace"
@@ -13,7 +15,6 @@ import (
 	"github.com/gofrs/uuid"
 	"github.com/netlify/gotrue/conf"
 	"github.com/netlify/gotrue/models"
-	"github.com/netlify/gotrue/storage"
 	"github.com/pkg/errors"
 	"github.com/sirupsen/logrus"
 )
@@ -49,23 +50,6 @@ func sendJSON(w http.ResponseWriter, status int, obj interface{}) error {
 	return err
 }
 
-func getUserFromClaims(ctx context.Context, conn *storage.Connection) (*models.User, error) {
-	claims := getClaims(ctx)
-	if claims == nil {
-		return nil, errors.New("Invalid token")
-	}
-
-	if claims.Subject == "" {
-		return nil, errors.New("Invalid claim: id")
-	}
-
-	userID, err := uuid.FromString(claims.Subject)
-	if err != nil {
-		return nil, errors.New("Invalid user ID")
-	}
-	return models.FindUserByID(conn, userID)
-}
-
 func (a *API) isAdmin(ctx context.Context, u *models.User, aud string) bool {
 	config := a.config
 	if aud == "" {
@@ -274,3 +258,22 @@ func isStringInSlice(checkValue string, list []string) bool {
 	}
 	return false
 }
+
+// getBodyBytes returns a byte array of the request's Body.
+func getBodyBytes(req *http.Request) ([]byte, error) {
+	if req.Body == nil || req.Body == http.NoBody {
+		return nil, nil
+	}
+
+	originalBody := req.Body
+	defer originalBody.Close()
+
+	buf, err := io.ReadAll(originalBody)
+	if err != nil {
+		return nil, err
+	}
+
+	req.Body = io.NopCloser(bytes.NewReader(buf))
+
+	return buf, nil
+}
diff --git a/api/invite.go b/api/invite.go
index 96752198..97f5e4d1 100644
--- a/api/invite.go
+++ b/api/invite.go
@@ -21,9 +21,12 @@ func (a *API) Invite(w http.ResponseWriter, r *http.Request) error {
 	adminUser := getAdminUser(ctx)
 	params := &InviteParams{}
 
-	jsonDecoder := json.NewDecoder(r.Body)
-	err := jsonDecoder.Decode(params)
+	body, err := getBodyBytes(r)
 	if err != nil {
+		return badRequestError("Could not read body").WithInternalError(err)
+	}
+
+	if err := json.Unmarshal(body, params); err != nil {
 		return badRequestError("Could not read Invite params: %v", err)
 	}
 
diff --git a/api/logout.go b/api/logout.go
index 25224541..c95508d6 100644
--- a/api/logout.go
+++ b/api/logout.go
@@ -14,16 +14,17 @@ func (a *API) Logout(w http.ResponseWriter, r *http.Request) error {
 
 	a.clearCookieTokens(config, w)
 
-	u, err := getUserFromClaims(ctx, a.db)
-	if err != nil {
-		return unauthorizedError("Invalid user").WithInternalError(err)
-	}
+	s := getSession(ctx)
+	u := getUser(ctx)
 
-	err = a.db.Transaction(func(tx *storage.Connection) error {
+	err := a.db.Transaction(func(tx *storage.Connection) error {
 		if terr := models.NewAuditLogEntry(r, tx, u, models.LogoutAction, "", nil); terr != nil {
 			return terr
 		}
-		return models.Logout(tx, u.ID)
+		if s != nil {
+			return models.Logout(tx, u.ID)
+		}
+		return models.LogoutAllRefreshTokens(tx, u.ID)
 	})
 	if err != nil {
 		return internalServerError("Error logging out user").WithInternalError(err)
diff --git a/api/mail.go b/api/mail.go
index 42b4569c..cace797f 100644
--- a/api/mail.go
+++ b/api/mail.go
@@ -38,10 +38,14 @@ func (a *API) GenerateLink(w http.ResponseWriter, r *http.Request) error {
 	adminUser := getAdminUser(ctx)
 
 	params := &GenerateLinkParams{}
-	jsonDecoder := json.NewDecoder(r.Body)
 
-	if err := jsonDecoder.Decode(params); err != nil {
-		return badRequestError("Could not read body: %v", err)
+	body, err := getBodyBytes(r)
+	if err != nil {
+		return badRequestError("Could not read body").WithInternalError(err)
+	}
+
+	if err := json.Unmarshal(body, params); err != nil {
+		return badRequestError("Could not parse JSON: %v", err)
 	}
 
 	if err := a.validateEmail(ctx, params.Email); err != nil {
diff --git a/api/middleware.go b/api/middleware.go
index c4c925c3..a7122dee 100644
--- a/api/middleware.go
+++ b/api/middleware.go
@@ -17,10 +17,6 @@ import (
 	jwt "github.com/golang-jwt/jwt"
 )
 
-const (
-	jwsSignatureHeaderName = "x-nf-sign"
-)
-
 type FunctionHooks map[string][]string
 
 type NetlifyMicroserviceClaims struct {
@@ -52,15 +48,6 @@ func (f *FunctionHooks) UnmarshalJSON(b []byte) error {
 	return nil
 }
 
-func (a *API) loadJWSSignatureHeader(w http.ResponseWriter, r *http.Request) (context.Context, error) {
-	ctx := r.Context()
-	signature := r.Header.Get(jwsSignatureHeaderName)
-	if signature == "" {
-		return nil, badRequestError("Operator microservice headers missing")
-	}
-	return withSignature(ctx, signature), nil
-}
-
 func (a *API) limitHandler(lmt *limiter.Limiter) middlewareHandler {
 	return func(w http.ResponseWriter, req *http.Request) (context.Context, error) {
 		c := req.Context()
@@ -114,13 +101,12 @@ func (a *API) limitEmailSentHandler() middlewareHandler {
 }
 
 func (a *API) requireAdminCredentials(w http.ResponseWriter, req *http.Request) (context.Context, error) {
-	ctx := req.Context()
 	t, err := a.extractBearerToken(w, req)
 	if err != nil || t == "" {
 		return nil, err
 	}
 
-	ctx, err = a.parseJWTClaims(t, req, w)
+	ctx, err := a.parseJWTClaims(t, req, w)
 	if err != nil {
 		return nil, err
 	}
@@ -145,6 +131,10 @@ func (a *API) verifyCaptcha(w http.ResponseWriter, req *http.Request) (context.C
 	if !config.Security.Captcha.Enabled {
 		return ctx, nil
 	}
+	if _, err := a.requireAdminCredentials(w, req); err == nil {
+		// skip captcha validation if authorization header contains an admin role
+		return ctx, nil
+	}
 	if config.Security.Captcha.Provider != "hcaptcha" {
 		logrus.WithField("provider", config.Security.Captcha.Provider).Warn("Unsupported captcha provider")
 		return nil, internalServerError("server misconfigured")
@@ -164,8 +154,8 @@ func (a *API) verifyCaptcha(w http.ResponseWriter, req *http.Request) (context.C
 	} else if verificationResult == security.UserRequestFailed {
 		return nil, badRequestError("request disallowed")
 	}
-	if verificationResult == security.SuccessfullyVerified {
-		return ctx, nil
+	if verificationResult != security.SuccessfullyVerified {
+		return nil, internalServerError("")
 	}
-	return nil, internalServerError("")
+	return ctx, nil
 }
diff --git a/api/otp.go b/api/otp.go
index 364d315f..da626f5d 100644
--- a/api/otp.go
+++ b/api/otp.go
@@ -3,9 +3,8 @@ package api
 import (
 	"bytes"
 	"encoding/json"
-	"io/ioutil"
+	"io"
 	"net/http"
-	"strings"
 
 	"github.com/netlify/gotrue/api/sms_provider"
 	"github.com/netlify/gotrue/models"
@@ -36,20 +35,18 @@ func (a *API) Otp(w http.ResponseWriter, r *http.Request) error {
 		params.Metadata = make(map[string]interface{})
 	}
 
-	body, err := ioutil.ReadAll(r.Body)
+	body, err := getBodyBytes(r)
 	if err != nil {
 		return err
 	}
-	jsonDecoder := json.NewDecoder(bytes.NewReader(body))
-	if err = jsonDecoder.Decode(params); err != nil {
+
+	if err = json.Unmarshal(body, 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 ok, err := a.shouldCreateUser(r, params); !ok {
 		return badRequestError("Signups not allowed for otp")
 	} else if err != nil {
@@ -76,8 +73,13 @@ func (a *API) SmsOtp(w http.ResponseWriter, r *http.Request) error {
 	var err error
 
 	params := &SmsParams{}
-	jsonDecoder := json.NewDecoder(r.Body)
-	if err := jsonDecoder.Decode(params); err != nil {
+
+	body, err := getBodyBytes(r)
+	if err != nil {
+		return badRequestError("Could not read body").WithInternalError(err)
+	}
+
+	if err := json.Unmarshal(body, params); err != nil {
 		return badRequestError("Could not read sms otp params: %v", err)
 	}
 	if params.Metadata == nil {
@@ -109,7 +111,7 @@ func (a *API) SmsOtp(w http.ResponseWriter, r *http.Request) error {
 			if err != nil {
 				return badRequestError("Could not parse metadata: %v", err)
 			}
-			r.Body = ioutil.NopCloser(bytes.NewReader(newBodyContent))
+			r.Body = io.NopCloser(bytes.NewReader(newBodyContent))
 
 			fakeResponse := &responseStub{}
 
@@ -126,7 +128,7 @@ func (a *API) SmsOtp(w http.ResponseWriter, r *http.Request) error {
 				if err != nil {
 					return badRequestError("Could not parse metadata: %v", err)
 				}
-				r.Body = ioutil.NopCloser(bytes.NewReader(newBodyContent))
+				r.Body = io.NopCloser(bytes.NewReader(newBodyContent))
 				return a.SmsOtp(w, r)
 			}
 
diff --git a/api/provider/apple.go b/api/provider/apple.go
index e04585a4..99df769a 100644
--- a/api/provider/apple.go
+++ b/api/provider/apple.go
@@ -20,13 +20,13 @@ import (
 const (
 	defaultAppleAPIBase = "appleid.apple.com"
 	authEndpoint        = "/auth/authorize"
-	tokenEndpoint       = "/auth/token"
+	tokenEndpoint       = "/auth/token" //#nosec G101 -- Not a secret value.
 
 	scopeEmail = "email"
 	scopeName  = "name"
 
 	appleAudOrIss                  = "https://appleid.apple.com"
-	idTokenVerificationKeyEndpoint = "/auth/keys"
+	idTokenVerificationKeyEndpoint = "/auth/keys" //#nosec G101 -- Not a secret value.
 )
 
 // AppleProvider stores the custom config for apple provider
diff --git a/api/provider/facebook.go b/api/provider/facebook.go
index 156552b2..00325315 100644
--- a/api/provider/facebook.go
+++ b/api/provider/facebook.go
@@ -14,7 +14,7 @@ import (
 
 const (
 	defaultFacebookAuthBase  = "www.facebook.com"
-	defaultFacebookTokenBase = "graph.facebook.com"
+	defaultFacebookTokenBase = "graph.facebook.com" //#nosec G101 -- Not a secret value.
 	defaultFacebookAPIBase   = "graph.facebook.com"
 )
 
diff --git a/api/provider/twitter.go b/api/provider/twitter.go
index 005f6b57..0068d790 100644
--- a/api/provider/twitter.go
+++ b/api/provider/twitter.go
@@ -19,7 +19,7 @@ const (
 	defaultTwitterAPIBase = "api.twitter.com"
 	requestURL            = "/oauth/request_token"
 	authenticateURL       = "/oauth/authenticate"
-	tokenURL              = "/oauth/access_token"
+	tokenURL              = "/oauth/access_token" //#nosec G101 -- Not a secret value.
 	endpointProfile       = "/1.1/account/verify_credentials.json"
 )
 
diff --git a/api/reauthenticate.go b/api/reauthenticate.go
index 0d7291be..57eee0b3 100644
--- a/api/reauthenticate.go
+++ b/api/reauthenticate.go
@@ -6,7 +6,6 @@ import (
 	"fmt"
 	"net/http"
 
-	"github.com/gofrs/uuid"
 	"github.com/netlify/gotrue/api/sms_provider"
 	"github.com/netlify/gotrue/conf"
 	"github.com/netlify/gotrue/models"
@@ -20,19 +19,7 @@ func (a *API) Reauthenticate(w http.ResponseWriter, r *http.Request) error {
 	ctx := r.Context()
 	config := a.config
 
-	claims := getClaims(ctx)
-	userID, err := uuid.FromString(claims.Subject)
-	if err != nil {
-		return badRequestError("Could not read User ID claim")
-	}
-	user, err := models.FindUserByID(a.db, userID)
-	if err != nil {
-		if models.IsNotFoundError(err) {
-			return notFoundError(err.Error())
-		}
-		return internalServerError("Database error finding user").WithInternalError(err)
-	}
-
+	user := getUser(ctx)
 	email, phone := user.GetEmail(), user.GetPhone()
 
 	if email == "" && phone == "" {
@@ -49,7 +36,7 @@ func (a *API) Reauthenticate(w http.ResponseWriter, r *http.Request) error {
 		}
 	}
 
-	err = a.db.Transaction(func(tx *storage.Connection) error {
+	err := a.db.Transaction(func(tx *storage.Connection) error {
 		if terr := models.NewAuditLogEntry(r, tx, user, models.UserReauthenticateAction, "", nil); terr != nil {
 			return terr
 		}
diff --git a/api/recover.go b/api/recover.go
index 66327554..cdaf4b5d 100644
--- a/api/recover.go
+++ b/api/recover.go
@@ -19,9 +19,13 @@ func (a *API) Recover(w http.ResponseWriter, r *http.Request) error {
 	ctx := r.Context()
 	config := a.config
 	params := &RecoverParams{}
-	jsonDecoder := json.NewDecoder(r.Body)
-	err := jsonDecoder.Decode(params)
+
+	body, err := getBodyBytes(r)
 	if err != nil {
+		return badRequestError("Could not read body").WithInternalError(err)
+	}
+
+	if err := json.Unmarshal(body, params); err != nil {
 		return badRequestError("Could not read verification params: %v", err)
 	}
 
diff --git a/api/signup.go b/api/signup.go
index 1a31a170..a2ea8f5a 100644
--- a/api/signup.go
+++ b/api/signup.go
@@ -35,9 +35,13 @@ func (a *API) Signup(w http.ResponseWriter, r *http.Request) error {
 	}
 
 	params := &SignupParams{}
-	jsonDecoder := json.NewDecoder(r.Body)
-	err := jsonDecoder.Decode(params)
+
+	body, err := getBodyBytes(r)
 	if err != nil {
+		return badRequestError("Could not read body").WithInternalError(err)
+	}
+
+	if err := json.Unmarshal(body, params); err != nil {
 		return badRequestError("Could not read Signup params: %v", err)
 	}
 
diff --git a/api/token.go b/api/token.go
index cad41ae3..c6f36768 100644
--- a/api/token.go
+++ b/api/token.go
@@ -26,6 +26,7 @@ type GoTrueClaims struct {
 	AppMetaData  map[string]interface{} `json:"app_metadata"`
 	UserMetaData map[string]interface{} `json:"user_metadata"`
 	Role         string                 `json:"role"`
+	SessionId    string                 `json:"session_id"`
 }
 
 // AccessTokenResponse represents an OAuth2 success response
@@ -165,8 +166,12 @@ func (a *API) Token(w http.ResponseWriter, r *http.Request) error {
 func (a *API) ResourceOwnerPasswordGrant(ctx context.Context, w http.ResponseWriter, r *http.Request) error {
 	params := &PasswordGrantParams{}
 
-	jsonDecoder := json.NewDecoder(r.Body)
-	if err := jsonDecoder.Decode(params); err != nil {
+	body, err := getBodyBytes(r)
+	if err != nil {
+		return badRequestError("Could not read body").WithInternalError(err)
+	}
+
+	if err := json.Unmarshal(body, params); err != nil {
 		return badRequestError("Could not read password grant params: %v", err)
 	}
 
@@ -178,7 +183,6 @@ func (a *API) ResourceOwnerPasswordGrant(ctx context.Context, w http.ResponseWri
 	}
 	var user *models.User
 	var provider string
-	var err error
 	if params.Email != "" {
 		provider = "email"
 		if !config.External.Email.Enabled {
@@ -248,8 +252,12 @@ func (a *API) RefreshTokenGrant(ctx context.Context, w http.ResponseWriter, r *h
 
 	params := &RefreshTokenGrantParams{}
 
-	jsonDecoder := json.NewDecoder(r.Body)
-	if err := jsonDecoder.Decode(params); err != nil {
+	body, err := getBodyBytes(r)
+	if err != nil {
+		return badRequestError("Could not read body").WithInternalError(err)
+	}
+
+	if err := json.Unmarshal(body, params); err != nil {
 		return badRequestError("Could not read refresh token grant params: %v", err)
 	}
 
@@ -328,7 +336,7 @@ func (a *API) RefreshTokenGrant(ctx context.Context, w http.ResponseWriter, r *h
 			}
 		}
 
-		tokenString, terr = generateAccessToken(user, time.Second*time.Duration(config.JWT.Exp), config.JWT.Secret)
+		tokenString, terr = generateAccessToken(user, newToken.SessionId.UUID.String(), time.Second*time.Duration(config.JWT.Exp), config.JWT.Secret)
 		if terr != nil {
 			return internalServerError("error generating jwt token").WithInternalError(terr)
 		}
@@ -359,8 +367,12 @@ func (a *API) IdTokenGrant(ctx context.Context, w http.ResponseWriter, r *http.R
 
 	params := &IdTokenGrantParams{}
 
-	jsonDecoder := json.NewDecoder(r.Body)
-	if err := jsonDecoder.Decode(params); err != nil {
+	body, err := getBodyBytes(r)
+	if err != nil {
+		return badRequestError("Could not read body").WithInternalError(err)
+	}
+
+	if err := json.Unmarshal(body, params); err != nil {
 		return badRequestError("Could not read id token grant params: %v", err)
 	}
 
@@ -373,7 +385,6 @@ func (a *API) IdTokenGrant(ctx context.Context, w http.ResponseWriter, r *http.R
 	}
 
 	var verifier *oidc.IDTokenVerifier
-	var err error
 	if params.Provider != "" {
 		verifier, err = params.getVerifier(ctx, a.config)
 	} else if params.ClientID != "" && params.Issuer != "" {
@@ -526,7 +537,7 @@ func (a *API) IdTokenGrant(ctx context.Context, w http.ResponseWriter, r *http.R
 	return sendJSON(w, http.StatusOK, token)
 }
 
-func generateAccessToken(user *models.User, expiresIn time.Duration, secret string) (string, error) {
+func generateAccessToken(user *models.User, sessionId string, expiresIn time.Duration, secret string) (string, error) {
 	claims := &GoTrueClaims{
 		StandardClaims: jwt.StandardClaims{
 			Subject:   user.ID.String(),
@@ -538,6 +549,7 @@ func generateAccessToken(user *models.User, expiresIn time.Duration, secret stri
 		AppMetaData:  user.AppMetaData,
 		UserMetaData: user.UserMetaData,
 		Role:         user.Role,
+		SessionId:    sessionId,
 	}
 
 	token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
@@ -560,7 +572,7 @@ func (a *API) issueRefreshToken(ctx context.Context, conn *storage.Connection, u
 			return internalServerError("Database error granting user").WithInternalError(terr)
 		}
 
-		tokenString, terr = generateAccessToken(user, time.Second*time.Duration(config.JWT.Exp), config.JWT.Secret)
+		tokenString, terr = generateAccessToken(user, refreshToken.SessionId.UUID.String(), time.Second*time.Duration(config.JWT.Exp), config.JWT.Secret)
 		if terr != nil {
 			return internalServerError("error generating jwt token").WithInternalError(terr)
 		}
diff --git a/api/user.go b/api/user.go
index 81660104..8849201b 100644
--- a/api/user.go
+++ b/api/user.go
@@ -4,7 +4,6 @@ import (
 	"encoding/json"
 	"net/http"
 
-	"github.com/gofrs/uuid"
 	"github.com/netlify/gotrue/api/sms_provider"
 	"github.com/netlify/gotrue/logger"
 	"github.com/netlify/gotrue/models"
@@ -29,24 +28,12 @@ func (a *API) UserGet(w http.ResponseWriter, r *http.Request) error {
 		return badRequestError("Could not read claims")
 	}
 
-	userID, err := uuid.FromString(claims.Subject)
-	if err != nil {
-		return badRequestError("Could not read User ID claim")
-	}
-
 	aud := a.requestAud(ctx, r)
 	if aud != claims.Audience {
 		return badRequestError("Token audience doesn't match request audience")
 	}
 
-	user, err := models.FindUserByID(a.db, userID)
-	if err != nil {
-		if models.IsNotFoundError(err) {
-			return notFoundError(err.Error())
-		}
-		return internalServerError("Database error finding user").WithInternalError(err)
-	}
-
+	user := getUser(ctx)
 	return sendJSON(w, http.StatusOK, user)
 }
 
@@ -56,26 +43,17 @@ func (a *API) UserUpdate(w http.ResponseWriter, r *http.Request) error {
 	config := a.config
 
 	params := &UserUpdateParams{}
-	jsonDecoder := json.NewDecoder(r.Body)
-	err := jsonDecoder.Decode(params)
-	if err != nil {
-		return badRequestError("Could not read User Update params: %v", err)
-	}
 
-	claims := getClaims(ctx)
-	userID, err := uuid.FromString(claims.Subject)
+	body, err := getBodyBytes(r)
 	if err != nil {
-		return badRequestError("Could not read User ID claim")
+		return badRequestError("Could not read body").WithInternalError(err)
 	}
 
-	user, err := models.FindUserByID(a.db, userID)
-	if err != nil {
-		if models.IsNotFoundError(err) {
-			return notFoundError(err.Error())
-		}
-		return internalServerError("Database error finding user").WithInternalError(err)
+	if err := json.Unmarshal(body, params); err != nil {
+		return badRequestError("Could not read User Update params: %v", err)
 	}
 
+	user := getUser(ctx)
 	log := logger.GetLogEntry(r)
 	log.Debugf("Checking params for token %v", params)
 
diff --git a/api/verify.go b/api/verify.go
index c5cac900..07890cf4 100644
--- a/api/verify.go
+++ b/api/verify.go
@@ -159,8 +159,13 @@ func (a *API) verifyPost(w http.ResponseWriter, r *http.Request) error {
 	ctx := r.Context()
 	config := a.config
 	params := &VerifyParams{}
-	jsonDecoder := json.NewDecoder(r.Body)
-	if err := jsonDecoder.Decode(params); err != nil {
+
+	body, err := getBodyBytes(r)
+	if err != nil {
+		return badRequestError("Could not read body").WithInternalError(err)
+	}
+
+	if err := json.Unmarshal(body, params); err != nil {
 		return badRequestError("Could not read verification params: %v", err)
 	}
 
@@ -178,7 +183,6 @@ func (a *API) verifyPost(w http.ResponseWriter, r *http.Request) error {
 
 	var (
 		user  *models.User
-		err   error
 		token *AccessTokenResponse
 	)
 
diff --git a/conf/logging.go b/conf/logging.go
index 7ecab340..831b3af9 100644
--- a/conf/logging.go
+++ b/conf/logging.go
@@ -34,7 +34,7 @@ func ConfigureLogging(config *LoggingConfig) (*logrus.Entry, error) {
 
 	// use a file if you want
 	if config.File != "" {
-		f, errOpen := os.OpenFile(config.File, os.O_RDWR|os.O_APPEND|os.O_CREATE, 0664)
+		f, errOpen := os.OpenFile(config.File, os.O_RDWR|os.O_APPEND|os.O_CREATE, 0660) //#nosec G302 -- Log files should be rw-rw-r--
 		if errOpen != nil {
 			return nil, errOpen
 		}
diff --git a/migrations/20210927181326_add_refresh_token_parent.up.sql b/migrations/20210927181326_add_refresh_token_parent.up.sql
index 2a9ff0e3..3a55c725 100644
--- a/migrations/20210927181326_add_refresh_token_parent.up.sql
+++ b/migrations/20210927181326_add_refresh_token_parent.up.sql
@@ -1,8 +1,24 @@
 -- adds parent column
 
 ALTER TABLE auth.refresh_tokens
-ADD COLUMN IF NOT EXISTS parent varchar(255) NULL,
-ADD CONSTRAINT refresh_tokens_token_unique UNIQUE ("token"),
-ADD CONSTRAINT refresh_tokens_parent_fkey FOREIGN KEY (parent) REFERENCES auth.refresh_tokens("token");
+ADD COLUMN IF NOT EXISTS parent varchar(255) NULL;
+
+DO $$
+BEGIN
+  IF NOT EXISTS(SELECT *
+    FROM information_schema.constraint_column_usage
+    WHERE table_schema = 'auth' and table_name='refresh_tokens' and constraint_name='refresh_tokens_token_unique')
+  THEN
+      ALTER TABLE "auth"."refresh_tokens" ADD CONSTRAINT refresh_tokens_token_unique UNIQUE ("token");
+  END IF;
+
+  IF NOT EXISTS(SELECT *
+    FROM information_schema.constraint_column_usage
+    WHERE table_schema = 'auth' and table_name='refresh_tokens' and constraint_name='refresh_tokens_parent_fkey')
+  THEN
+      ALTER TABLE "auth"."refresh_tokens" ADD CONSTRAINT refresh_tokens_parent_fkey FOREIGN KEY (parent) REFERENCES auth.refresh_tokens("token");
+  END IF;
+
+  CREATE INDEX IF NOT EXISTS refresh_tokens_parent_idx ON refresh_tokens USING btree (parent);
+END $$;
 
-CREATE INDEX IF NOT EXISTS refresh_tokens_parent_idx ON refresh_tokens USING btree (parent);
diff --git a/migrations/20220811173540_add_sessions_table.up.sql b/migrations/20220811173540_add_sessions_table.up.sql
new file mode 100644
index 00000000..7c4badcb
--- /dev/null
+++ b/migrations/20220811173540_add_sessions_table.up.sql
@@ -0,0 +1,23 @@
+-- Add session_id column to refresh_tokens table
+create table if not exists auth.sessions (
+    id uuid not null,
+    user_id uuid not null,
+    created_at timestamptz null,
+    updated_at timestamptz null,
+    constraint sessions_pkey primary key (id),
+    constraint sessions_user_id_fkey foreign key (user_id) references auth.users(id) on delete cascade
+);
+comment on table auth.sessions is 'Auth: Stores session data associated to a user.';
+
+alter table auth.refresh_tokens
+add column if not exists session_id uuid null;
+
+do $$
+begin
+  if not exists(select *
+    from information_schema.constraint_column_usage
+    where table_schema = 'auth' and table_name='sessions' and constraint_name='refresh_tokens_session_id_fkey')
+  then
+      alter table "auth"."refresh_tokens" add constraint refresh_tokens_session_id_fkey foreign key (session_id) references auth.sessions(id) on delete cascade;
+  end if;
+END $$;
diff --git a/models/errors.go b/models/errors.go
index 6c33c70c..d2b6fe64 100644
--- a/models/errors.go
+++ b/models/errors.go
@@ -5,6 +5,8 @@ func IsNotFoundError(err error) bool {
 	switch err.(type) {
 	case UserNotFoundError:
 		return true
+	case SessionNotFoundError:
+		return true
 	case ConfirmationTokenNotFoundError:
 		return true
 	case RefreshTokenNotFoundError:
@@ -19,6 +21,12 @@ func IsNotFoundError(err error) bool {
 	return false
 }
 
+type SessionNotFoundError struct{}
+
+func (e SessionNotFoundError) Error() string {
+	return "Session not found"
+}
+
 // UserNotFoundError represents when a user is not found.
 type UserNotFoundError struct{}
 
diff --git a/models/refresh_token.go b/models/refresh_token.go
index e20de2eb..5f1bc463 100644
--- a/models/refresh_token.go
+++ b/models/refresh_token.go
@@ -5,6 +5,7 @@ import (
 	"net/http"
 	"time"
 
+	"github.com/gobuffalo/nulls"
 	"github.com/gobuffalo/pop/v5"
 	"github.com/gofrs/uuid"
 	"github.com/netlify/gotrue/crypto"
@@ -20,7 +21,8 @@ type RefreshToken struct {
 
 	UserID uuid.UUID `db:"user_id"`
 
-	Parent storage.NullString `db:"parent"`
+	Parent    storage.NullString `db:"parent"`
+	SessionId nulls.UUID         `db:"session_id"`
 
 	Revoked   bool      `db:"revoked"`
 	CreatedAt time.Time `db:"created_at"`
@@ -60,14 +62,19 @@ func GrantRefreshTokenSwap(r *http.Request, tx *storage.Connection, user *User,
 
 // RevokeTokenFamily revokes all refresh tokens that descended from the provided token.
 func RevokeTokenFamily(tx *storage.Connection, token *RefreshToken) error {
+	var err error
 	tablename := (&pop.Model{Value: RefreshToken{}}).TableName()
-	err := tx.RawQuery(`
-	with recursive token_family as (
-		select id, user_id, token, revoked, parent from `+tablename+` where parent = ?
-		union
-		select r.id, r.user_id, r.token, r.revoked, r.parent from `+tablename+` r inner join token_family t on t.token = r.parent
-	)
-	update `+tablename+` r set revoked = true from token_family where token_family.id = r.id;`, token.Token).Exec()
+	if token.SessionId.Valid && len(token.SessionId.UUID) > 0 {
+		err = tx.RawQuery(`update `+tablename+` set revoked = true where session_id = ?;`, token.SessionId).Exec()
+	} else {
+		err = tx.RawQuery(`
+		with recursive token_family as (
+			select id, user_id, token, revoked, parent from `+tablename+` where parent = ?
+			union
+			select r.id, r.user_id, r.token, r.revoked, r.parent from `+tablename+` r inner join token_family t on t.token = r.parent
+		)
+		update `+tablename+` r set revoked = true from token_family where token_family.id = r.id;`, token.Token).Exec()
+	}
 	if err != nil {
 		return err
 	}
@@ -87,11 +94,6 @@ func GetValidChildToken(tx *storage.Connection, token *RefreshToken) (*RefreshTo
 	return refreshToken, nil
 }
 
-// Logout deletes all refresh tokens for a user.
-func Logout(tx *storage.Connection, id uuid.UUID) error {
-	return tx.RawQuery("DELETE FROM "+(&pop.Model{Value: RefreshToken{}}).TableName()+" WHERE instance_id = ? AND user_id = ?", uuid.Nil, id).Exec()
-}
-
 func createRefreshToken(tx *storage.Connection, user *User, oldToken *RefreshToken) (*RefreshToken, error) {
 	token := &RefreshToken{
 		UserID: user.ID,
@@ -100,6 +102,13 @@ func createRefreshToken(tx *storage.Connection, user *User, oldToken *RefreshTok
 	}
 	if oldToken != nil {
 		token.Parent = storage.NullString(oldToken.Token)
+		token.SessionId = oldToken.SessionId
+	} else {
+		session, err := CreateSession(tx, user)
+		if err != nil {
+			return nil, errors.Wrap(err, "Error generated unique session id")
+		}
+		token.SessionId = nulls.NewUUID(session.ID)
 	}
 
 	if err := tx.Create(token); err != nil {
@@ -111,3 +120,9 @@ func createRefreshToken(tx *storage.Connection, user *User, oldToken *RefreshTok
 	}
 	return token, nil
 }
+
+// Deprecated. For backward compatibility, some access tokens may not have a sessionId. Use models.Logout instead.
+// LogoutAllRefreshTokens deletes all sessions for a user.
+func LogoutAllRefreshTokens(tx *storage.Connection, userId uuid.UUID) error {
+	return tx.RawQuery("DELETE FROM "+(&pop.Model{Value: RefreshToken{}}).TableName()+" WHERE user_id = ?", userId).Exec()
+}
diff --git a/models/sessions.go b/models/sessions.go
new file mode 100644
index 00000000..331d9fe5
--- /dev/null
+++ b/models/sessions.go
@@ -0,0 +1,66 @@
+package models
+
+import (
+	"database/sql"
+	"time"
+
+	"github.com/gobuffalo/pop/v5"
+	"github.com/gofrs/uuid"
+	"github.com/netlify/gotrue/storage"
+	"github.com/pkg/errors"
+)
+
+type Session struct {
+	ID        uuid.UUID `json:"-" db:"id"`
+	UserID    uuid.UUID `json:"user_id" db:"user_id"`
+	CreatedAt time.Time `json:"created_at" db:"created_at"`
+	UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
+}
+
+func (Session) TableName() string {
+	tableName := "sessions"
+	return tableName
+}
+
+func NewSession(user *User) (*Session, error) {
+	id, err := uuid.NewV4()
+	if err != nil {
+		return nil, errors.Wrap(err, "Error generating unique session id")
+	}
+	session := &Session{
+		ID:     id,
+		UserID: user.ID,
+	}
+	return session, nil
+}
+
+func CreateSession(tx *storage.Connection, user *User) (*Session, error) {
+	session, err := NewSession(user)
+	if err != nil {
+		return nil, err
+	}
+	if err := tx.Create(session); err != nil {
+		return nil, errors.Wrap(err, "error creating session")
+	}
+	return session, nil
+}
+
+func FindSessionById(tx *storage.Connection, id uuid.UUID) (*Session, error) {
+	session := &Session{}
+	if err := tx.Eager().Q().Where("id = ?", id).First(session); err != nil {
+		if errors.Cause(err) == sql.ErrNoRows {
+			return nil, SessionNotFoundError{}
+		}
+		return nil, errors.Wrap(err, "error finding user")
+	}
+	return session, nil
+}
+
+// Logout deletes all sessions for a user.
+func Logout(tx *storage.Connection, userId uuid.UUID) error {
+	return tx.RawQuery("DELETE FROM "+(&pop.Model{Value: Session{}}).TableName()+" WHERE user_id = ?", userId).Exec()
+}
+
+func LogoutSession(tx *storage.Connection, sessionId uuid.UUID) error {
+	return tx.RawQuery("DELETE FROM "+(&pop.Model{Value: Session{}}).TableName()+" WHERE id = ?", sessionId).Exec()
+}
diff --git a/models/user.go b/models/user.go
index 4be4b981..429ad21c 100644
--- a/models/user.go
+++ b/models/user.go
@@ -527,9 +527,9 @@ func (u *User) RemoveUnconfirmedIdentities(tx *storage.Connection) error {
 
 	var confirmedProviders []Identity
 
-	for _, identity := range u.Identities {
+	for i, identity := range u.Identities {
 		if identity.Provider == "email" {
-			if terr := tx.Destroy(&identity); terr != nil {
+			if terr := tx.Destroy(&u.Identities[i]); terr != nil {
 				return terr
 			}
 		} else {


Test patch:
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 4261573a..0923cedb 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -33,8 +33,25 @@ jobs:
           go-version: ${{ matrix.go-version }}
       - name: Checkout code
         uses: actions/checkout@v2
-      - name: Formatting checks
-        run: if [ ! -z $(gofmt -l .) ]; then echo 'Make sure to run "gofmt -s -w ." before commit!' && exit 1; fi
+        with:
+          skip-unshallow: "true"
+      - name: Check gofmt
+        run: |
+          set -x
+
+          if [ ! -z $(gofmt -l .) ]
+          then
+            echo 'Make sure to run "gofmt -s -w ." before commit!' && exit 1
+          fi
+      - name: Check go vet
+        run: |
+          set -x
+          go vet ./...
+      - name: Check gosec
+        run: |
+          set -x
+          go install github.com/securego/gosec/v2/cmd/gosec@latest
+          gosec -tests -exclude=G104 ./... # exclude unhandled errors for the time being
       - name: Init Database
         run: psql -f hack/init_postgres.sql postgresql://postgres:root@localhost:5432/postgres
       - name: Run migrations
diff --git a/api/admin_test.go b/api/admin_test.go
index a2489ba3..548bd974 100644
--- a/api/admin_test.go
+++ b/api/admin_test.go
@@ -42,25 +42,12 @@ func TestAdmin(t *testing.T) {
 func (ts *AdminTestSuite) SetupTest() {
 	models.TruncateAll(ts.API.db)
 	ts.Config.External.Email.Enabled = true
-	ts.token = ts.makeSuperAdmin("")
-}
-
-func (ts *AdminTestSuite) makeSuperAdmin(email string) string {
-	u, err := models.NewUser("9123456", email, "test", ts.Config.JWT.Aud, map[string]interface{}{"full_name": "Test User"})
-	require.NoError(ts.T(), err, "Error making new user")
-
-	u.Role = "supabase_admin"
-
-	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")
-
-	p := jwt.Parser{ValidMethods: []string{jwt.SigningMethodHS256.Name}}
-	_, err = p.Parse(token, func(token *jwt.Token) (interface{}, error) {
-		return []byte(ts.Config.JWT.Secret), nil
-	})
-	require.NoError(ts.T(), err, "Error parsing token")
-
-	return token
+	claims := &GoTrueClaims{
+		Role: "supabase_admin",
+	}
+	token, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(ts.Config.JWT.Secret))
+	require.NoError(ts.T(), err, "Error generating admin jwt")
+	ts.token = token
 }
 
 // TestAdminUsersUnauthorized tests API /admin/users route without authentication
diff --git a/api/api_test.go b/api/api_test.go
index 67bc4d96..aae8c47b 100644
--- a/api/api_test.go
+++ b/api/api_test.go
@@ -2,9 +2,7 @@ package api
 
 import (
 	"context"
-	"math/rand"
 	"testing"
-	"time"
 
 	"github.com/gofrs/uuid"
 	"github.com/netlify/gotrue/conf"
@@ -21,7 +19,6 @@ const (
 )
 
 func init() {
-	rand.Seed(time.Now().UnixNano())
 	models.PasswordHashCost = bcrypt.MinCost
 
 }
@@ -53,13 +50,3 @@ func TestEmailEnabledByDefault(t *testing.T) {
 
 	require.True(t, api.config.External.Email.Enabled)
 }
-
-const letterBytes = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
-
-func randStringBytes(n int) string {
-	b := make([]byte, n)
-	for i := range b {
-		b[i] = letterBytes[rand.Int63()%int64(len(letterBytes))]
-	}
-	return string(b)
-}
diff --git a/api/audit_test.go b/api/audit_test.go
index 59513cdc..ad868de3 100644
--- a/api/audit_test.go
+++ b/api/audit_test.go
@@ -48,7 +48,7 @@ func (ts *AuditTestSuite) makeSuperAdmin(email string) string {
 
 	u.Role = "supabase_admin"
 
-	token, err := generateAccessToken(u, time.Second*time.Duration(ts.Config.JWT.Exp), ts.Config.JWT.Secret)
+	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")
 
 	p := jwt.Parser{ValidMethods: []string{jwt.SigningMethodHS256.Name}}
diff --git a/api/invite_test.go b/api/invite_test.go
index de3663f4..52a8e092 100644
--- a/api/invite_test.go
+++ b/api/invite_test.go
@@ -58,7 +58,7 @@ func (ts *InviteTestSuite) makeSuperAdmin(email string) string {
 
 	u.Role = "supabase_admin"
 
-	token, err := generateAccessToken(u, time.Second*time.Duration(ts.Config.JWT.Exp), ts.Config.JWT.Secret)
+	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")
 
 	p := jwt.Parser{ValidMethods: []string{jwt.SigningMethodHS256.Name}}
diff --git a/api/middleware_test.go b/api/middleware_test.go
index eff2bee2..4ef66204 100644
--- a/api/middleware_test.go
+++ b/api/middleware_test.go
@@ -9,6 +9,7 @@ import (
 	"net/http/httptest"
 	"testing"
 
+	jwt "github.com/golang-jwt/jwt"
 	"github.com/netlify/gotrue/conf"
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
@@ -40,44 +41,70 @@ func TestHCaptcha(t *testing.T) {
 }
 
 func (ts *MiddlewareTestSuite) TestVerifyCaptchaValid() {
-	var buffer bytes.Buffer
-	require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]interface{}{
-		"email":    "test@example.com",
-		"password": "secret",
-		"gotrue_meta_security": map[string]interface{}{
-			"captcha_token": HCaptchaResponse,
-		},
-	}))
-
 	ts.Config.Security.Captcha.Enabled = true
 	ts.Config.Security.Captcha.Provider = "hcaptcha"
 	ts.Config.Security.Captcha.Secret = HCaptchaSecret
 
-	req := httptest.NewRequest(http.MethodPost, "http://localhost", &buffer)
-	req.Header.Set("Content-Type", "application/json")
-
-	beforeCtx := context.Background()
-	req = req.WithContext(beforeCtx)
-
-	w := httptest.NewRecorder()
-
-	afterCtx, err := ts.API.verifyCaptcha(w, req)
+	adminClaims := &GoTrueClaims{
+		Role: "supabase_admin",
+	}
+	adminJwt, err := jwt.NewWithClaims(jwt.SigningMethodHS256, adminClaims).SignedString([]byte(ts.Config.JWT.Secret))
 	require.NoError(ts.T(), err)
-
-	body, err := ioutil.ReadAll(req.Body)
-
-	// re-initialize buffer
-	require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]interface{}{
-		"email":    "test@example.com",
-		"password": "secret",
-		"gotrue_meta_security": map[string]interface{}{
-			"captcha_token": HCaptchaResponse,
+	cases := []struct {
+		desc          string
+		adminJwt      string
+		captcha_token string
+	}{
+		{
+			"Valid captcha response",
+			"",
+			HCaptchaResponse,
 		},
-	}))
+		{
+			"Ignore captcha if admin role is present",
+			adminJwt,
+			"",
+		},
+	}
+	for _, c := range cases {
+		var buffer bytes.Buffer
+		require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]interface{}{
+			"email":    "test@example.com",
+			"password": "secret",
+			"gotrue_meta_security": map[string]interface{}{
+				"captcha_token": c.captcha_token,
+			},
+		}))
+		req := httptest.NewRequest(http.MethodPost, "http://localhost", &buffer)
+		req.Header.Set("Content-Type", "application/json")
+		if c.adminJwt != "" {
+			req.Header.Set("Authorization", "Bearer "+c.adminJwt)
+		}
+
+		beforeCtx := context.Background()
+		req = req.WithContext(beforeCtx)
+
+		w := httptest.NewRecorder()
+
+		afterCtx, err := ts.API.verifyCaptcha(w, req)
+		require.NoError(ts.T(), err)
+
+		body, err := ioutil.ReadAll(req.Body)
+		require.NoError(ts.T(), err)
+
+		// re-initialize buffer
+		require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]interface{}{
+			"email":    "test@example.com",
+			"password": "secret",
+			"gotrue_meta_security": map[string]interface{}{
+				"captcha_token": c.captcha_token,
+			},
+		}))
 
-	// check if body is the same
-	require.Equal(ts.T(), body, buffer.Bytes())
-	require.Equal(ts.T(), afterCtx, beforeCtx)
+		// check if body is the same
+		require.Equal(ts.T(), body, buffer.Bytes())
+		require.Equal(ts.T(), afterCtx, beforeCtx)
+	}
 }
 
 func (ts *MiddlewareTestSuite) TestVerifyCaptchaInvalid() {
diff --git a/api/phone_test.go b/api/phone_test.go
index 394fd1e2..5f5f9cc6 100644
--- a/api/phone_test.go
+++ b/api/phone_test.go
@@ -126,7 +126,7 @@ func (ts *PhoneTestSuite) TestMissingSmsProviderConfig() {
 	u.PhoneConfirmedAt = &now
 	require.NoError(ts.T(), ts.API.db.Update(u), "Error updating new test user")
 
-	token, err := generateAccessToken(u, time.Second*time.Duration(ts.Config.JWT.Exp), ts.Config.JWT.Secret)
+	token, err := generateAccessToken(u, "", time.Second*time.Duration(ts.Config.JWT.Exp), ts.Config.JWT.Secret)
 	require.NoError(ts.T(), err)
 
 	cases := []struct {
diff --git a/api/user_test.go b/api/user_test.go
index 67f983c8..83163bd1 100644
--- a/api/user_test.go
+++ b/api/user_test.go
@@ -47,7 +47,7 @@ func (ts *UserTestSuite) SetupTest() {
 func (ts *UserTestSuite) TestUserGet() {
 	u, err := models.FindUserByEmailAndAudience(ts.API.db, "test@example.com", ts.Config.JWT.Aud)
 	require.NoError(ts.T(), err, "Error finding user")
-	token, err := generateAccessToken(u, time.Second*time.Duration(ts.Config.JWT.Exp), ts.Config.JWT.Secret)
+	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")
 
 	req := httptest.NewRequest(http.MethodGet, "http://localhost/user", nil)
@@ -111,7 +111,7 @@ func (ts *UserTestSuite) TestUserUpdateEmail() {
 			require.NoError(ts.T(), u.SetPhone(ts.API.db, c.userData["phone"]), "Error setting user phone")
 			require.NoError(ts.T(), ts.API.db.Create(u), "Error saving test user")
 
-			token, err := generateAccessToken(u, time.Second*time.Duration(ts.Config.JWT.Exp), ts.Config.JWT.Secret)
+			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")
 
 			var buffer bytes.Buffer
@@ -170,7 +170,7 @@ func (ts *UserTestSuite) TestUserUpdatePhoneAutoconfirmEnabled() {
 
 	for _, c := range cases {
 		ts.Run(c.desc, func() {
-			token, err := generateAccessToken(u, time.Second*time.Duration(ts.Config.JWT.Exp), ts.Config.JWT.Secret)
+			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")
 
 			var buffer bytes.Buffer
@@ -244,7 +244,7 @@ func (ts *UserTestSuite) TestUserUpdatePassword() {
 			req := httptest.NewRequest(http.MethodPut, "http://localhost/user", &buffer)
 			req.Header.Set("Content-Type", "application/json")
 
-			token, err := generateAccessToken(u, time.Second*time.Duration(ts.Config.JWT.Exp), ts.Config.JWT.Secret)
+			token, err := generateAccessToken(u, "", time.Second*time.Duration(ts.Config.JWT.Exp), ts.Config.JWT.Secret)
 			require.NoError(ts.T(), err)
 			req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token))
 
@@ -272,7 +272,7 @@ func (ts *UserTestSuite) TestUserUpdatePasswordReauthentication() {
 	u.EmailConfirmedAt = &now
 	require.NoError(ts.T(), ts.API.db.Update(u), "Error updating new test user")
 
-	token, err := generateAccessToken(u, time.Second*time.Duration(ts.Config.JWT.Exp), ts.Config.JWT.Secret)
+	token, err := generateAccessToken(u, "", time.Second*time.Duration(ts.Config.JWT.Exp), ts.Config.JWT.Secret)
 	require.NoError(ts.T(), err)
 
 	// request for reauthentication nonce
diff --git a/api/verify_test.go b/api/verify_test.go
index 0379d253..3b17a75c 100644
--- a/api/verify_test.go
+++ b/api/verify_test.go
@@ -103,7 +103,7 @@ func (ts *VerifyTestSuite) TestVerifySecureEmailChange() {
 	req.Header.Set("Content-Type", "application/json")
 
 	// Generate access token for request
-	token, err := generateAccessToken(u, time.Second*time.Duration(ts.Config.JWT.Exp), ts.Config.JWT.Secret)
+	token, err := generateAccessToken(u, "", time.Second*time.Duration(ts.Config.JWT.Exp), ts.Config.JWT.Secret)
 	require.NoError(ts.T(), err)
 	req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token))