Add Custom JWT Claims To Supabase Auth
I am currently working on a website where you can use AI to practice your language skills. To save user's progress I use Supabase as my Postgres database. While Supabase has some limitations (which get more visible as your app scales) it is still perfect for fully functional minimal app. If you have few tables and very little logic, there is really nothing better.
My Next.JS app uses API routes (App Router in NextJS 14) to provide data to the mostly server components. User must be authenticated to access these routes. Verifying user's identity can be easily done with supabase.auth.getUser()
.
Always prefer to use
getUser()
call overgetSession()
. The reason is thatgetUser()
fetches the most recent data from Supabase DB andgetSession()
only returns the data from the JWT token.
To fetch the required data I wanted to know user's primary language they speak and also the target language they want to practice. As you cannot add additional fields to Supabase auth.users
table (nor you should want to) I decided to create public.profiles
table. You probably have profiles
table already.
Always create
profiles
table and use Postgres triggers to create a row there whenever a new user is created (as noted in docs). Then you can useprofiles.user_id
as a foreign key in your other tables. Referencingauth.users.id
will make your life harder with RLS.
My profiles
table schema looked like this:
In order to correctly process the request in my API route I had to fetch the user's profile table from my database. In the world of serverless every request counts. We have to await each sequential roundtrip to the database which might not be close. So I was thinking how to avoid this and improve the performance. Couldn't we somehow get this data from getUser()
call?
I remembered that JWT are not just access tokens. They can contain custom data called claims. You, as the developer, can include additional claims which are going to be passed around in JWT and stored in cookies. Always available.
Few caveats here:
- Claims are not encrypted. Do not store sensitive data in them.
- Claims can be edited by the user and you should always verify them on the server.
- Claims are not automatically updated. You have to refresh the token to get the new claims.
Primary language is definitely not private info. We don't have to worry here. To prevent clients from altering the claims we need to first validate them on the server. Fortunately, the getUser()
call fetches the most recent data from the Supabase Auth database (as mentioned in the docs).
Therefore, if we could somehow insert the selected language claims into user's meta, we could easily access them without additional API calls.
Custom user metadata are stored inside raw_app_meta_data
field. However, the updateUserById()
call is a part of the Admin API and cannot be called by the user. Of course, you can always create an Admin client and call it from your Next.JS project. But for my solution I wanted to keep the logic in my DB.
My idea was to use Database Triggers to update the claims whenever user changes their language settings.
To update user's raw_app_meta_data
whenever they change their language settings we are gonna use triggers. Useful side effect here is that when you call Supabase to update profiles
table, the request will wait until the trigger finished. Therefore you can safely refresh user session after the update.
This trigger is simple except the line 8. I wanted to use jsonb_set()
call to update the JSONB column. Unfortunately, allows changing only one value at a time. But jsonb_set()
accepts a JSONB object and returns a JSONB object. So to get by this limitation we can call jsonb_set()
twice and nest the calls.
You can see if it works by updating the profiles
table from your Supabase Dashboard. If you want to see raw user auth data, then you have to switch from public
to auth
schema in the top left corner. To read the data in your API Route just read the data from getUser()
call.
With setup I can easily access necessary user data without additional roundtrips to the database. While my example works for API routes, you can just as easily use it on your client.
If you followed the Supabase SSR setup then you already have the getUser()
call in your middleware which refreshes the use session before every request. Therefore, you should expect only the most recent data from the database.
You can update user's metadata by updating the profiles
table. When you update this table, our trigger will handle the JWT claims update automatically. Remember that if the trigger function fails, so will fail this update. This is expected behavior for me. (This update call will addionally finish only after the trigger finishes.)