Vojtech Pavlovsky

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 over getSession(). The reason is that getUser() fetches the most recent data from Supabase DB and getSession() only returns the data from the JWT token.

1export async function POST(req: Request) {
2 const supabase = getSupabase();
3 const user = await supabase.auth.getUser();
5 if (user.error && !user.data.user) {
6 return new Response("Unauthorized", { status: 401 });
7 }
9 // Now I can safely load user data...

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 use profiles.user_id as a foreign key in your other tables. Referencing auth.users.id will make your life harder with RLS.

My profiles table schema looked like this:

[FK] uuid

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.

1create function public.update_lang_claims()
2returns trigger
3language plpgsql
4security definer set search_path = public
5as $$
7 update auth.users
8 set raw_app_meta_data = jsonb_set(jsonb_set(raw_app_meta_data, '{target_lang}', to_json(new.target_lang::text)::jsonb)::jsonb, '{primary_lang}', to_json(new.primary_lang::text)::jsonb)::jsonb
9 where id = new.id;
10 return new;
14-- trigger the function every time a profile is updated
15create trigger on_profile_updated
16 after update on public.profiles
17 for each row execute procedure public.update_lang_claims();
19-- Use this to delete this funcion and a trigger if you need to.
21-- drop trigger "on_profile_updated" on public.profiles;
22-- drop function public.update_lang_claims();

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.

1-- Update only one value
2jsonb_set(raw_app_meta_data, '{target_lang}', to_json(new.target_lang::text)::jsonb)::jsonb
4-- Update first value and pass the output as a parameter
5-- to the second call. Not very readable, but works OK.
7 jsonb_set(raw_app_meta_data, '{target_lang}', to_json(new.target_lang::text)::jsonb)::jsonb,
8'{primary_lang}', to_json(new.primary_lang::text)::jsonb)::jsonb

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.

1export async function POST(req: Request) {
2 const user = await supabase.auth.getUser();
4 if (user.error && !user.data.user) {
5 return new Response("Unauthorized", { status: 401 });
6 }
8 // Now we can access the primary lang. Unfortunately,
9 // `app_metadata` is not typed and is inferred as `any`.
10 // And to be even more defensive in your programming,
11 // assume that `primary_lang` might be undefined/null.
12 const userMeta = user.data.user!.app_metadata;
13 console.log("user", "primary lang", userMeta.primary_lang)

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.)

1async function updateUserProfile() {
2 const { error } = await supabase
3 .from('profiles')
4 .update({ primary_lang: 'en' })
5 .eq('user_id', 1)