JWT Juggling with Supabase: Taming Auth Like a Pro

Introduction

Here at Voltade, we’ve experimented with countless database services, but Supabase stood out with its impressive suite of tools—offering real-time updates, row-level security, powerful APIs, and a seamless Postgres experience. It’s a one-stop shop for building modern apps without sacrificing performance or flexibility. But unlike most, we chose to self-host it, which means we have to manage and connect all these different services ourselves. And let’s be real—it’s not all smooth sailing, especially when it comes to setting up authentication! Wrangling Kong, Auth, and Rest can be a bit of a headache. So, we’re breaking it down to show you how it all works (without losing your sanity). Let’s dive in!

What is Supabase?

Supabase is a Backend-as-a-Service that enhances the developer experience by providing multiple services in addition to a PostgreSQL database. When you create a Supabase project, it offers seven different services accessible at the following endpoints:

  • /auth (GoTrue)
  • /rest (PostgREST)
  • /realtime (Realtime)
  • /storage (Storage)
  • /pg (pg-meta)
  • /functions (Functions)
  • /graphql (pg_graphql)

These services are all configured using the API Gateway Kong.

What is Kong?

Kong is an API gateway that routes traffic to the correct service based on the HTTP(S) endpoint in the request. The base URL is your SUPABASE_URL, and you can reach the other services by navigating to http(s)://${SUPABASE_URL}/v1/${SERVICE_NAME}. You need to include your SUPABASE_ANON_KEY or SUPABASE_SERVICE_KEY to access these endpoints, which acts as the first layer of authentication.

Your SUPABASE_ANON_KEY and SUPABASE_SERVICE_KEY (collectively referred to as api_keys) are signed by a SUPABASE_JWT_SECRET, which should always remain confidential. Kong uses this SUPABASE_JWT_SECRET to verify the signatures of all incoming requests.

What is Auth?

Auth is a user management and authentication server written in Go that powers Supabase's features such as:

  • Issuing JWTs
  • Enforcing Row Level Security (RLS) with PostgREST
  • User management
  • Sign-in with email, password, magic link, or phone number
  • Sign-in with external providers (Google, Apple, Facebook, Discord, etc.)

Originally based on the GoTrue codebase by Netlify, it has significantly diverged in features and capabilities. Auth provides endpoints for common authentication use cases like user sign-up, sign-in, password recovery, and authentication with third-party apps such as Google.

When a user signs in, the Auth service encodes additional user metadata into a JWT (JSON Web Token):

{
  "aud": "authenticated",
  "sub": "user-uuid",
  "email": "user@example.com",
  "role": "authenticated",
  "exp": 1727639544,
  "user_metadata": {
    "email": "user@example.com"
  },
  "app_metadata": {
    "provider": "email",
    "providers": ["email"]
  }
}

This JSON represents a JWT payload containing user metadata and authentication details used for authorization and enforcing Row Level Security policies.

When using Row Level Security (RLS), this JWT is crucial for authorization. The server inspects the request's headers to locate the Authorization value, which should contain Bearer {jwt-token}. Decoding the JWT reveals the JSON payload shown above. Permissions are then checked against RLS policies in the database. For example, the role (authenticated in this case) is used to determine access rights. New tables can also specify specific permission settings.

We can even change the role from authenticated to service_role to bypass all Row Level Security policies and perform administrative tasks such as managing user accounts or altering database structures.

await db.transaction(async (tx) => {
  await tx.execute(
    sql`select set_config('request.jwt.claims', ${JSON.stringify(jwtPayload)}, TRUE)`,
  );
  if (options.dangerouslyUseServiceRole === true) {
    await tx.execute(sql`set local role service_role`);
  } else {
    await tx.execute(sql`set local role authenticated`);
  }
  c.set('tx', tx);
  await next();
}, options.txConfig);

This TypeScript code sets up a database transaction that injects the JWT claims into the transaction context and adjusts the user's role based on the dangerouslyUseServiceRole option.

Role-Based Access Control

We can encode the user’s role into the JWT access token so that Supabase checks the user’s role and permissions against the table they are accessing with each request.

Referring to the example JWT above, this JWT payload includes user authentication details and is attached to each request in the Authorization header.

At Voltade, we can customize the login process so that only users with a specific email domain can access our application.

create or replace function public.custom_access_token_hook(event jsonb)
  returns jsonb
  language plpgsql
as $$
declare
  email_claim text;
begin
  -- Extract email claim
  email_claim = event -> 'claims' ->> 'email';

  if email_claim ilike '%@voltade.com' then
    return event;
  end if;
  -- Return an error if the email domain does not match
  return jsonb_build_object('error', jsonb_build_object('http_code', 403, 'message', 'Not allowed'));
end;
$$;

grant usage on schema public to supabase_auth_admin;
grant execute on function public.custom_access_token_hook to supabase_auth_admin;
revoke execute on function public.custom_access_token_hook from authenticated, anon, public;

This SQL function custom_access_token_hook checks if the user's email ends with @voltade.com. If not, it returns a 403 error, preventing unauthorized access.

We can also encode the user’s role into the JWT:

create or replace function public.custom_access_token_hook(event jsonb)
returns jsonb
language plpgsql stable
as $$
  declare
    claims jsonb;
    user_role public.app_role;
  begin
    -- Fetch the user role from the user_roles table
    select role into user_role from public.user_roles where user_id = (event->>'user_id')::uuid;

    claims := event->'claims';

    if user_role is not null then
      -- Set the user_role claim
      claims := jsonb_set(claims, '{user_role}', to_jsonb(user_role));
    else
      claims := jsonb_set(claims, '{user_role}', 'null');
    end if;

    -- Update the 'claims' object in the event
    event := jsonb_set(event, '{claims}', claims);

    return event;
  end;
$$;

grant usage on schema public to supabase_auth_admin;
grant execute on function public.custom_access_token_hook to supabase_auth_admin;
revoke execute on function public.custom_access_token_hook from authenticated, anon, public;

grant all on table public.user_roles to supabase_auth_admin;
revoke all on table public.user_roles from authenticated, anon, public;

create policy "Allow auth admin to read user roles" ON public.user_roles
as permissive for select
to supabase_auth_admin
using (true);

This function adds the user's role from the user_roles table into the JWT claims, enabling role-based authorization in your application.

We can then check the user's permissions by creating an authorization function:

create or replace function public.authorize(
  requested_permission app_permission
)
returns boolean as $$
declare
  has_permission int;
  user_role public.app_role;
begin
  -- Fetch the user role from JWT claims
  select (auth.jwt() ->> 'user_role')::public.app_role into user_role;

  -- Check if the role has the requested permission
  select count(*)
  into has_permission
  from public.role_permissions
  where permission = requested_permission
    and role = user_role;

  return has_permission > 0;
end;
$$ language plpgsql stable security definer set search_path = '';

This authorize function checks if the user's role has the requested permission by querying the role_permissions table, returning true or false accordingly.

Then, create Row Level Security policies:

create policy "Allow authorized delete access" on public.channels
for delete using ( (SELECT authorize('channels.delete')) );

create policy "Allow authorized delete access" on public.messages
for delete using ( (SELECT authorize('messages.delete')) );

These policies use the authorize function to control delete access on the channels and messages tables, ensuring only users with the appropriate permissions can perform delete operations.

If we want to make a database call from the server, we can modify the JWT that we send from the server itself:

await db.transaction(async (tx) => {
  await tx.execute(
    sql`select set_config('request.jwt.claims', ${JSON.stringify(jwtPayload)}, TRUE)`,
  );
  if (options.dangerouslyUseServiceRole === true) {
    await tx.execute(sql`set local role service_role`);
  } else {
    await tx.execute(sql`set local role authenticated`);
  }
  c.set('tx', tx);
  await next();
}, options.txConfig);

This TypeScript code modifies the JWT payload sent from the server by setting the request.jwt.claims configuration and adjusting the database role, allowing the server to perform operations with the appropriate permissions.

Final Thoughts

Understanding how Kong, Auth, and Rest services work together is essential for implementing robust authentication and authorization in Supabase. Kong serves as the gateway that secures access to the various services using API keys. The Auth service handles user authentication and issues JWTs containing user metadata and roles. The Rest service (PostgREST) leverages these JWTs to enforce Row Level Security policies in the database.

By properly configuring these services and utilizing features like custom JWT claims and role-based access control, you can build secure and scalable applications with fine-grained access control tailored to your application's needs.