Supabase access tokens are just regular JWTs, so you can quite easily generate your own access tokens and completely bypass the built-in authentication system.
As for why would you do such a thing, some of the needs I have had myself have included:
- Impersonating a user for admin purposes
- Masquerade as a user for be able to query Postgrest with RLS active
Let’s get started!
Token structure
Supabase stores session in supabase-auth-token
cookie. It is an array consisting of 1. access token in JWT form and 2. refresh token.
By copying the token to jwt.io, we can see that it is a regular JWT token with HS256 algorithm and the following structure:
{ "aud": "authenticated", "exp": 1687358892, "sub": "59ea6364-0a07-4295-aa05-b6b11ca83e9d", "email": "joonas@catjam.fi", "phone": "", "app_metadata": { "provider": "email", "providers": [ "email" ] }, "user_metadata": {}, "role": "authenticated", "aal": "aal1", "amr": [ { "method": "password", "timestamp": 1687355292 } ], "session_id": "6d3f28c7-0c16-425a-aebc-f2384e788027"}
Here’s a quick mapping of some of the keys’ meaning:
Key | Meaning |
---|---|
sub | user id |
aal | Authenticator Assurance Level. aal1 means single factor auth |
amr | Authentication Methods Reference, i.e. what auth methods were used |
Next up, we’ll need to figure out the secret used to create JWT signature.
Getting the JWT Secret
In local dev, secret for creating jwt signatures is super-secret-jwt-token-with-at-least-32-characters-long
(https://github.com/supabase/supabase-js/issues/25#issuecomment-1019935888)
In production, JWT secret can be retrieved from the dashboard settings.
Now we can get to minting our own JWT token.
Minting our own JWT token (naive version)
Now that we know the structure and the secret, it is in theory possible to generate our own tokens.
Let’s just naively try it by copying the existing token and turning it into a token (with fast-jwt):
import { createSigner } from "fast-jwt";const signer = createSigner({ key: "super-secret-jwt-token-with-at-least-32-characters-long", algorithm: "HS256",});const token = signer({ "aud": "authenticated", "exp": 1687358892, "sub": "59ea6364-0a07-4295-aa05-b6b11ca83e9d", "email": "joonas@catjam.fi", "phone": "", "app_metadata": { "provider": "email", "providers": [ "email" ] }, "user_metadata": {}, "role": "authenticated", "aal": "aal1", "amr": [ { "method": "password", "timestamp": 1687355292 } ], "session_id": "6d3f28c7-0c16-425a-aebc-f2384e788027"});console.log(token);
This produces a token that can then be tested directly with supabase. Supabase’s GoTrue (= the auth server used by supabase) client provides a convenient getUser
function that directly takes a JWT token and returns user data for it.
import { createClient } from "@supabase/supabase-js";createClient( process.env.SUPABASE_URL!, process.env.SUPABASE_ANON_KEY!) .auth.getUser(token) .then(console.log);
It works and returns the user data correctly!
We do have two issues with this however:
- What should we use for session_id, expiration and other keys inside the JWT when mass-minting custom tokens?
- How do we actually use this for something other than
getUser
call, which just happens to have a jwt token parameter?
Minting our own JWT token (improved version)
I figured providing a valid session_id
for the token might prove problematic, if we have to somehow get it from Supabase auth server and it has to be valid. However, I tried out just removing the session_id
altogether from the token, and it still worked! Hence, we can just omit the session id for this purpose.
For expiration, just coming up with an arbitrary timestamp (in seconds) in the future did the trick.
This is the minimal token payload that I ended up with and successfully got data out of Supabase servers:
const ONE_HOUR = 60 * 60;const exp = Math.round(Date.now() / 1000) + ONE_HOUR;const payload = { exp, sub: "59ea6364-0a07-4295-aa05-b6b11ca83e9d", email: "joonas@catjam.fi", role: "authenticated",};const token = signer(payload);
A generic minting function
Here’s a copypasteable custom Supabase token creation function:
import { createSigner } from "fast-jwt";const signer = createSigner({ key: "super-secret-jwt-token-with-at-least-32-characters-long", algorithm: "HS256",});export function createSupabaseToken(userEmail: string, userId: string) { const ONE_HOUR = 60 * 60; const exp = Math.round(Date.now() / 1000) + ONE_HOUR; const payload = { exp, sub: userId, email: userEmail, role: "authenticated", }; return signer(payload);}
Using our JWT token
The second problem was actually managing to use the access token for database query purposes. I tried just passing the minted token in place of anon key, but to no avail. However, passing token directly as the bearer token in client initialization headers does the trick.
const token = createSupabaseToken("user@example.com", "59ea6364-0a07-4295-aa05-b6b11ca83e9d");const client = createClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, accessToken: async () => { return token } // note: requires supabase-js >=2.45);// can be used normally with RLS!console.log(await client.auth.getUser());client.from("sites").select("*").then(console.log);
These steps allow quite nifty generation of your own JWT tokens. My use case was creating a Supabase client to allow GraphQL to use RLS policy-scoped Postgrest queries. This method of dynamically generating the access token makes it easy to granularly control which user we are acting as on a per-request basis, i.e. perfect for a GraphQL client!