Authentication is critical for almost every application these days. If you hack on multiple projects, you end up copying code from project to project mostly or if you use a framework like Laravel, Rails, you spend time configuring a bunch of times.

Over the weekend, I spent some time chatting with a junior developer who was using Supabase for their new project but he had an issue. He wanted to use his own backend for everything else while only using Supbase for authentication ( social oauth, username + password including the forgot password flow). Smart lad! Authentication work is absolutely low quality yet important to get it right.

So here is the flow he went with:

  1. User visits signup page
  2. Input details and click signup
  3. Supabase takes over from there ( 100ms - 1.5s really)
  4. In the callback function from the Supabase SDK, send the returned JWT to the custom backend to be used for other functionalities.
  5. On the backend, send an HTTP request to Supabase to validate the token and retrieve the user
    1. Proceed to fulfill the api request from item 4

While this works, there is an issue here in step 5, there is now an external dependency to identify a user. If for some reason Supabase throttles you or gets slower, the experience for your users get worse. Simple API requests end up taking longer than expected.

You can check the Go SDK here

user, err := supabase.Auth.User(context.Background(), token)

Or in Javascript

const bearerToken = req.headers.authorization.split("Bearer ").pop();
// get the user from supabase. This is an extra http call.
const user = supabase.auth.api.getUser(access_token);

All JWTs are made the same. Maybe :)

Turns out Supabase uses JWT for a lot of things - including the token they use to identify logged in users. With that in mind, you can just validate the JWT locally, check it isn't expired and extract the user information you need to fulfill the request.

They ( rightfully ) use a key per project and they let you view that in your dashboard. So copy it as you will need it to verify the JWT in the next step.

Supabase dashboard

package xyz

import (

type UserMetadata struct {
	StripeCustomerID string `json:"stripe_customer_id"`

type User struct {
	ID        uuid.UUID `bun:"type:uuid,default:uuid_generate_v4()"`
	Reference string    `json:"reference"`
	Email     string     `json:"email"`

	FullName string        `json:"full_name"`
	Metadata *UserMetadata `json:"metadata"`

	CreatedAt time.Time `bun:",nullzero,notnull,default:current_timestamp"`
	UpdatedAt time.Time `bun:",nullzero,notnull,default:current_timestamp"`
	DeletedAt time.Time `bun:",soft_delete,nullzero"`

func ParseUserFromJWT() (*User, error) {

	token, err := jwt.Parse([]byte(os.Getenv("JWT_SECRET_KEY")), jwt.WithVerify(jwa.HS256, []byte(config.Global().Supabase.JWTKey)))
	if err != nil {
		return nil, err

	if token.Expiration().Before(time.Now()) {
		return nil, errors.New("token is expired")

	err = errors.New("not found")

	id, exists := token.Get("sub")
	if !exists {
		return nil, err

	email, exists := token.Get("email")
	if !exists {
		return nil, err

	userMetada, exists := token.Get("user_metadata")
	if !exists {
		return nil, err

	data,ok := userMetada.(map[string]interface{})
	if !ok {
	    return nil,errors.New("invalid jwt")

	return &User{
		Reference: id.(string),
		FullName:  data["full_name"].(string),
		Email:     Email(email.(string)),
		Metadata:  &UserMetadata{},
	}, nil

Or in JS/TS:

import jwt, { type JwtPayload } from "jsonwebtoken";

try {
  const token = authorization.split("Bearer ").pop();
  const user = jwt.verify(token, process.env.JWT_SECRET_KEY) as JwtPayload; = user.sub;
} catch {

With this, you get rid of the extra HTTP call per api request to supbase. And can do the verification locally and be certain you have the right data.