# supabase-custom-claims: putting user metadata in the access token

> This Supabase community project adds JSON custom claims to the access token by writing PostgreSQL functions that manage the raw_app_meta_data field of the auth schema users table, so the same claims can be read by your application and by row level security policies. It is one implementation of a recurring problem, and the readme is clear about that.

**supabase-community/supabase-custom-claims** — How to implement custom claims with Supabase

- Repository: https://github.com/supabase-community/supabase-custom-claims
- Stars: 676 · Forks: 49
- Language: JavaScript
- License: not declared
- Published: 2026-10-21 · Updated: 2026-10-21 · Language: en
- Canonical page: https://hysenlabs.com/projects/supabase-community-supabase-custom-claims

## Claims are app metadata, and the token is what carries them

The stated goal is simple: add JSON data to the access token that an authenticated user receives when logging into the application. That token, and the custom claims inside it, can be read and used by both the application and the PostgreSQL database server.

Where the data actually lives is the important technical detail. These custom claims are stored in the raw_app_meta_data field of the users table in the auth schema, at auth.users.raw_app_meta_data. That is the app metadata column rather than the user metadata column, which is the correct place for it, because app metadata is the part of the record the server considers authoritative and which is not writable by the user from the client.

The project frames itself honestly at the start. It is just one way to implement custom claims for a Supabase project, and the readme points to a FAQ for more background. That framing matters when you are choosing a mechanism, because Supabase projects accumulate their own conventions and the goal here is a workable pattern rather than a canonical answer.

The primary language of the repository is JavaScript, but the substance of the project is SQL. The top level contains install.sql with all the PostgreSQL functions, uninstall.sql, a claims-demo directory, a Makefile, a CHANGELOG, and the extension packaging files supabase_custom_claims--1.0.sql and supabase_custom_claims.control.

## Installation is copy and run, and removal is the same

There is no package manager step and no migration framework involved. The file install.sql contains all the PostgreSQL functions you need to implement and manage custom claims, and installing it is two steps: paste the SQL code from install.sql into the SQL Query Editor of your Supabase project, then click RUN to execute the code.

Uninstalling is the mirror image, using uninstall.sql in the same query editor and clicking RUN.

That simplicity is the appeal for a Supabase user, because the SQL editor is already where you run schema changes and where your database changes are visible to everyone on the team. The downside is that the install is not tracked as a migration, so if you have several environments, or a policy of applying schema changes through a pipeline, you now have a step that lives outside that pipeline.

The Makefile shows that the project can also be packaged as a PostgreSQL extension, and it is short enough to read in one go:

```bash
EXTENSION = supabase_custom_claims
DATA = supabase_custom_claims--1.0.sql
PG_CONFIG = pg_config
PGXS := $(shell $(PG_CONFIG) --pgxs)
include $(PGXS)
```

It sets EXTENSION to supabase_custom_claims, points DATA at supabase_custom_claims--1.0.sql, and includes the PGXS build system through pg_config, which is the standard way a C or SQL extension is built. So the same functions are usable through a normal extension mechanism where that is preferable to pasting SQL.

Either way, the functions are plain SQL objects in your database, which is what makes them usable from row level security policies, from triggers, and from the query editor with the same syntax.

## Reading and writing claims for any user by id

From the query editor, you can get, set and delete claims for any user based on the user's id, which is a uuid. Four functions cover the surface.

get_claims(uid uuid) returns jsonb and gives the whole claims object for a user. The readme shows the shape of the result, and it is worth noticing what is in there beyond the custom keys: provider, providers, userrole, userlevel, useractive, userjoined and claims_admin. The provider and providers entries are the authentication provider fields that Supabase keeps in the same column, which means a get_claims call returns those alongside your own values.

get_claim(uid uuid, claim text) returns jsonb for a single claim, returning 100 for a userlevel claim in the example.

set_claim(uid uuid, claim text, value jsonb) returns text and is where the sharp edges are, because the value argument is a jsonb value and you are passing SQL literals as strings. A number has to be passed as a plain string, so to set a number you pass it as a simple string. A text value has to be passed with double quotes inside the quotes. A boolean is passed as true. An array is passed as a JSON array literal, and a complex nested object is passed as a JSON object literal.

The readme calls out the common mistake directly. If you forget the double quotes for a string and write the value as a bare identifier, the result is an error: invalid input syntax for type json. The asymmetry between how a number and a string are written is a genuine trap, and the fact that the error is a type error rather than a permission error means it will not be confused with a security problem when it happens.

delete_claim(uid uuid, claim text) returns text, and both set_claim and delete_claim return OK in the readme examples.

## Row level security is the reason to care about any of this

The application of claims that actually changes how you build an application is row level security, and the readme gives three concrete policy examples.

To allow only users whose userrole is MANAGER, the policy condition is get_my_claim('userrole') = '"MANAGER"', which the UI rewrites into the more formal cast form with the text and jsonb types made explicit. The doubled quoting is the same jsonb literal issue as in set_claim, and it will trip up anyone who has not written jsonb comparisons before.

To allow only users whose userlevel is over 100, the condition is coalesce(get_my_claim('userlevel')::numeric,0) > 100. The coalesce matters: a user with no userlevel claim has no value to compare, and without the default of 0 the comparison would be null rather than false, and a null in a policy is not what you want to reason about.

To allow only users with claims_admin set to true, the condition is coalesce(get_my_claim('claims_admin')::bool,false).

All three use get_my_claim, the function that resolves against the currently logged in user rather than taking a uid. The readme also documents the full set for the current session: is_claims_admin() returns a boolean, get_my_claims() returns the whole jsonb object, and get_my_claim(claim text) returns one value.

The practical effect is that a policy can filter on a claim without a join to a profile table, and the same claim value is available to the client from the session, so the front end and the database agree on the user's role without a second request.

## Who is allowed to set claims, and how you bootstrap the first admin

The permission model has two halves, and the second one is where the bootstrapping problem comes from.

By default, usage is allowed through the API, but the ability to set or delete claims is restricted to users who have the claims_admin custom claim set to true. The intent is to let you build an admin section of the app where designated users can modify custom claims for other users.

If you want to tighten security further, so that claims can only be set or deleted from inside the query editor or inside your PostgreSQL functions or triggers, you edit the is_claims_admin() function to disallow usage by app users, which means no usage through the API or PostgREST. The instructions for that are included in the function itself.

That leaves an obvious chicken and egg problem, which the readme addresses directly. If the only way to set or delete claims requires the claims_admin claim to be set to true, and no users have that claim, how do you edit custom claims from within the app? The answer is to bootstrap a user by running a command in the Supabase query editor:

`select set_claim('03acaa13-7989-45c1-8dfb-6eeb7cf0b92e', 'claims_admin', 'true');`

The id in that call is the id of your admin user, found in auth.users.

The broader question the readme does not discuss is when the claim takes effect. Since the claim is stored in app metadata and the token is issued at sign in, a user who is granted a claim while already logged in will not see it in their current token until they refresh or sign in again. That is a property of the token model rather than of this implementation, but it is the detail most likely to confuse an application team on day one, and the readme does not call it out.

## Reading claims on the client, and the state of the repository

On the client side the readme shows that claims are extracted from the session object you get when the user is logged in, using the onAuthStateChange callback, and the example is a JavaScript block in the Supabase client style.

The repository is organised as a Supabase community project, with a claims-demo directory alongside the SQL files, and the extension packaging in the root. The licence is recorded as unknown, which is a genuine point to resolve before you ship the SQL in a commercial product, since a community project with no clear licence term is not something you can assume you may redistribute.

There are no GitHub releases, so there is no version to point at, although the extension file is named for version 1.0. The last push was on 2026-05-12, and the project has a CHANGELOG for tracking changes.

The alternative approaches to this problem are worth naming without detail. Supabase has a user_metadata column on the same users table, which is writable by the user from the client, and the difference between that and raw_app_meta_data is exactly the trust boundary that makes the claims_admin model work. A project that puts role information in user metadata and then uses it in a row level security policy has a policy that the user can change. Other approaches move the whole decision into a separate roles table with a join, which costs a query but keeps the token small and the data normalised. This project takes the path of least resistance: the data is already in the token, so the policy reads it without a join.

## Conclusion

Adopt supabase-custom-claims if your Supabase project needs role or tier information in the access token so that row level security policies can filter on it, and you are comfortable pasting SQL into the query editor and versioning it yourself. Do not adopt it if you need claims refreshed mid session, since claims live in the token issued at sign in, or if you need a packaged extension with a release train, since the repository has no releases and the licence is unrecorded. Before you commit, install install.sql in a scratch project, bootstrap your first admin user with set_claim, and read the is_claims_admin function to decide whether to lock claims administration away from the API.

## FAQ

### What are custom claims in supabase-custom-claims?

They are JSON data added to the access token an authenticated user receives at login, so the values can be read by both the application and the PostgreSQL database. They are stored in the raw_app_meta_data field of the users table in the auth schema.

### How do I install supabase-custom-claims?

Paste the SQL from install.sql into the SQL Query Editor of your Supabase project and click RUN. Uninstalling uses uninstall.sql the same way. The project also ships a Makefile that packages the functions as a PostgreSQL extension named supabase_custom_claims.

### How do I set a custom claim to a string value?

The value argument is a jsonb value, so a text value needs double quotes inside the string literal. Writing set_claim with a bare identifier for the value produces an invalid input syntax for type json error, while a number is passed as a simple string and a boolean as true.

### How do I use custom claims in a row level security policy?

Use get_my_claim, which resolves against the currently logged in user. The readme gives conditions for a role equality, a numeric threshold using coalesce(get_my_claim('userlevel')::numeric,0) > 100, and a boolean flag using coalesce(get_my_claim('claims_admin')::bool,false).

### How do I bootstrap the first admin user for managing claims?

Run set_claim for the admin user's id with the claim name claims_admin and the value true in the Supabase query editor. The id is the one found in auth.users, and this is needed because setting or deleting claims otherwise requires the claims_admin claim to already be set.

## Sources

- [Issues](https://github.com/supabase-community/supabase-custom-claims/issues)
- [README](https://github.com/supabase-community/supabase-custom-claims/blob/main/README.md)
- [supabase-community/supabase-custom-claims on GitHub](https://github.com/supabase-community/supabase-custom-claims)

---

Hysen Labs editorial analysis, written from the project's own repository and release notes. Cite the canonical page: https://hysenlabs.com/projects/supabase-community-supabase-custom-claims
