├── CHANGELOG.md ├── Makefile ├── README.md ├── claims-demo ├── .env.sample ├── .gitignore ├── README.md ├── package-lock.json ├── package.json ├── public │ ├── index.html │ ├── manifest.json │ └── robots.txt └── src │ ├── App.css │ ├── App.js │ ├── Auth.js │ ├── Logout.js │ ├── TestFunctions.js │ ├── Userinfo.js │ ├── index.css │ ├── index.js │ └── supabaseClient.js ├── install.sql ├── supabase_custom_claims--1.0.sql ├── supabase_custom_claims.control └── uninstall.sql /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # CHANGELOG 2 | ## 1.0 3 | - first release 4 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | EXTENSION = supabase_custom_claims 2 | DATA = supabase_custom_claims--1.0.sql 3 | 4 | PG_CONFIG = pg_config 5 | PGXS := $(shell $(PG_CONFIG) --pgxs) 6 | include $(PGXS) -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Supabase Custom Claims 2 | Want to know more about Custom Claims? See the [FAQ](#faq) below. 3 | 4 | This is just one way to implement `custom claims` for a Supabase project. The goal here is simply to add JSON data to the access token that an authenticated user receives when logging into your application. That token (and thus the `custom claims` contained in that token) can be read and used by both your application and by your PostgreSQL database server. These `custom claims` are stored in the `raw_app_meta_data` field of the `users` table in the `auth` schema. (`auth.users.raw_app_meta_data`) 5 | 6 | ## Installing the Functions 7 | The file [install.sql](./install.sql) contains all the PostgreSQL functions you need to implement and manage custom claims in your Supabase project. 8 | 9 | 1. Paste the SQL code from [install.sql](./install.sql) into the [SQL Query Editor](https://app.supabase.io/project/_/sql) of your Supabase project. 10 | 2. Click `RUN` to execute the code. 11 | ## Uninstalling the Functions 12 | 13 | 1. Paste the SQL code from [uninstall.sql](./uninstall.sql) into the [SQL Query Editor](https://app.supabase.io/project/_/sql) of your Supabase project. 14 | 2. Click `RUN` to execute the code. 15 | 16 | ### Security Considerations 17 | If you want to tighten security so that custom claims can only be set or deleted from inside the query editor or inside your PostgreSQL functions or triggers, edit the function `is_claims_admin()` to disallow usage by app users (no usage through the API / Postgrest). Instructions are included in the function. 18 | 19 | By default, usage is allowed through your API, but the ability to set or delete claims is restricted to only users who have the `claims_admin` custom claim set to `true`. This allows you to create an **"admin"** section of your app that allows designated users to modify custom claims for other users of your app. 20 | 21 | ### Bootstrapping 22 | 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 can I edit custom claims from within my app? 23 | 24 | The answer is to **"bootstrap"** a user by running the following command inside your [Supabase Query Editor](https://app.supabase.io/project/_/sql) window: 25 | 26 | `select set_claim('03acaa13-7989-45c1-8dfb-6eeb7cf0b92e', 'claims_admin', 'true');` 27 | 28 | where `03acaa13-7989-45c1-8dfb-6eeb7cf0b92e` is the `id` of your admin user found in `auth.users`. 29 | 30 | ## Usage 31 | ### Inside the Query Editor 32 | You can get, set, and delete claims for any user based on the user's `id` (uuid) with the following functions: 33 | 34 | #### `get_claims(uid uuid)` returns jsonb 35 | ##### example 36 | `select get_claims('03acaa13-7989-45c1-8dfb-6eeb7cf0b92e');` 37 | ##### result 38 | ``` 39 | | get_claims | 40 | | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | 41 | | {"provider": "email", "userrole": "MANAGER", "providers": ["email"], "userlevel": 100, "useractive": true, "userjoined": "2022-05-20T14:07:27.742Z", "claims_admin": true} | 42 | ``` 43 | 44 | #### `get_claim(uid uuid, claim text)` returns jsonb 45 | ##### example 46 | `select get_claim('03acaa13-7989-45c1-8dfb-6eeb7cf0b92e', 'userlevel');` 47 | ##### result 48 | ``` 49 | | get_claim | 50 | | --------- | 51 | | 100 | 52 | ``` 53 | 54 | #### `set_claim(uid uuid, claim text, value jsonb) `returns text 55 | ##### example 56 | Set a **number** value. (Note `value` is passed as a `jsonb` value, so to set a number we need to pass it as a simple string.) 57 | `select set_claim('03acaa13-7989-45c1-8dfb-6eeb7cf0b92e', 'userlevel', '200');` 58 | 59 | Set a **text** value. (Note `value` is passed as a `jsonb` value, so to set a number we need to pass it with double-quotes.) 60 | `select set_claim('03acaa13-7989-45c1-8dfb-6eeb7cf0b92e', 'userrole', '"MANAGER"');` 61 | 62 | **Common Mistake**: If you forget the double-quotes for a string, and try to do this: `select set_claim('03acaa13-7989-45c1-8dfb-6eeb7cf0b92e', 'userrole', 'MANAGER');`, the result will be an error: `invalid input syntax for type json` 63 | 64 | Set a **boolean** value. 65 | `select set_claim('03acaa13-7989-45c1-8dfb-6eeb7cf0b92e', 'useractive', 'true');` 66 | 67 | Set an **array** value. 68 | `select set_claim('03acaa13-7989-45c1-8dfb-6eeb7cf0b92e', 'items', '["bread", "cheese", "butter"]');` 69 | 70 | Set a complex, nested **json** / **object** value. 71 | `select set_claim('03acaa13-7989-45c1-8dfb-6eeb7cf0b92e', 'gamestate', '{"level": 5, "items": ["knife", "gun"], "position":{"x": 15, "y": 22}}');` 72 | 73 | ##### result (for any of the above) 74 | ``` 75 | | set_claim | 76 | | --------- | 77 | | OK | 78 | ``` 79 | 80 | #### `delete_claim(uid uuid, claim text)` returns text 81 | ##### example 82 | `select delete_claim('03acaa13-7989-45c1-8dfb-6eeb7cf0b92e', 'gamestate');` 83 | ##### result 84 | ``` 85 | | delete_claim | 86 | | ------------ | 87 | | OK | 88 | ``` 89 | 90 | ### Inside PostgreSQL Functions and Triggers 91 | When using custom claims from inside a PostgreSQL function or trigger, you can use any of the functions shown in the section above: `Inside the Query Editor`. 92 | 93 | In addition, you can use the following functions that are specific to the currently logged-in user: 94 | 95 | #### `is_claims_admin()` returns bool 96 | ##### example 97 | `select is_claims_admin();` 98 | ##### result 99 | ``` 100 | | is_claims_admin | 101 | | --------------- | 102 | | true | 103 | ``` 104 | 105 | #### `get_my_claims()` returns jsonb 106 | ##### example 107 | `select get_my_claims();` 108 | ##### result 109 | ``` 110 | | get_my_claims | 111 | | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | 112 | | {"provider": "email", "userrole": "MANAGER", "providers": ["email"], "userlevel": 100, "useractive": true, "userjoined": "2022-05-20T14:07:27.742Z", "claims_admin": true} | 113 | ``` 114 | 115 | #### `get_my_claim(claim TEXT)` returns jsonb 116 | ##### example 117 | `select get_my_claim('userlevel');` 118 | ##### result 119 | ``` 120 | | get_my_claim | 121 | | ------------ | 122 | | 100 | 123 | ``` 124 | 125 | ### Inside an RLS (Row Level Security) Policy 126 | To use custom claims in an RLS Policy, you'll normally use the `get_my_claim` to check a specific claim for the currently logged in user. 127 | #### examples 128 | ##### only allow users with userrole "MANAGER" 129 | `get_my_claim('userrole') = '"MANAGER"'` 130 | (which the UI will change into the more formal): 131 | `((get_my_claim('userrole'::text)) = '"MANAGER"'::jsonb)` 132 | 133 | ##### only allow users with userlevel over 100 134 | `coalesce(get_my_claim('userlevel')::numeric,0) > 100` 135 | 136 | ##### only allow users with claim_admin = true 137 | `coalesce(get_my_claim('claims_admin')::bool,false)` 138 | 139 | ### Inside your app (using `.rpc()`) 140 | 141 | #### Getting Claims Data from Local Session Data 142 | You can extract claims information from the `session` object you get when the user is logged in. For example: 143 | 144 | ```js 145 | supabase.auth.onAuthStateChange((_event, session) => { 146 | if (session?.user) { 147 | console.log(session?.user?.app_metadata) // show custom claims 148 | } 149 | }) 150 | ``` 151 | 152 | If any claims have changed since your last log in, you may need to log out and back in to see these changes. 153 | 154 | #### Getting Claims Data from the Server 155 | You can also query the server to see what claims are set for the current user. 156 | 157 | Here are some sample functions that can be used by any authenticated (logged-in) user of your application: 158 | 159 | ```js 160 | public get_my_claims = async () => { 161 | const { data, error } = await supabase 162 | .rpc('get_my_claims', {}); 163 | return { data, error }; 164 | } 165 | public get_my_claim = async (claim: string) => { 166 | const { data, error } = await supabase 167 | .rpc('get_my_claim', {claim}); 168 | return { data, error }; 169 | } 170 | public is_claims_admin = async () => { 171 | const { data, error } = await supabase 172 | .rpc('is_claims_admin', {}); 173 | return { data, error }; 174 | } 175 | ``` 176 | 177 | The following functions can only be used by a **"claims admin"**, that is, a user who has the `claims_admin` custom claim set to `true`: 178 | 179 | (Note: these functions allow you to view, set, and delete claims for any user of your application, so these would be appropriate for an **administrative** branch of your application to be used only by high-level users with the proper security rights (i.e. `claims_admin` level users.)) 180 | 181 | ```js 182 | public get_claims = async (uid: string) => { 183 | const { data, error } = await supabase 184 | .rpc('get_claims', {uid}); 185 | return { data, error }; 186 | } 187 | public get_claim = async (uid: string, claim: string) => { 188 | const { data, error } = await supabase 189 | .rpc('get_claim', {uid, claim}); 190 | return { data, error }; 191 | } 192 | public set_claim = async (uid: string, claim: string, value: object) => { 193 | const { data, error } = await supabase 194 | .rpc('set_claim', {uid, claim, value}); 195 | return { data, error }; 196 | } 197 | public delete_claim = async (uid: string, claim: string) => { 198 | const { data, error } = await supabase 199 | .rpc('delete_claim', {uid, claim}); 200 | return { data, error }; 201 | } 202 | ``` 203 | 204 | ## Running an older project (Postgres 13 or earlier?) 205 | See [this issue](https://github.com/supabase-community/supabase-custom-claims/issues/3) 206 | 207 | ## FAQ 208 | ### What are custom claims? 209 | Custom Claims are special attributes attached to a user that you can use to control access to portions of your application. 210 | 211 | For example: 212 | ``` 213 | plan: "TRIAL" 214 | user_level: 100 215 | group_name: "Super Guild!" 216 | joined_on: "2022-05-20T14:28:18.217Z" 217 | group_manager: false 218 | items: ["toothpick", "string", "ring"] 219 | ``` 220 | 221 | ### What type of data can I store in a custom claim? 222 | Any valid JSON data can be stored in a claim. You can store a string, number, boolean, date (as a string), array, or even a complex, nested, complete JSON object. 223 | 224 | ### Where are these custom claims stored? 225 | Custom claims are stored in the `auth.users` table, in the `raw_app_meta_data` column for a user. 226 | 227 | ### Are there any naming restrictions? 228 | The Supabase Auth System (GoTrue) currently uses the following custom claims: `provider` and `providers`, so DO NOT use these. Any other valid string should be ok as the name for your custom claim(s), though. 229 | 230 | ### Why use custom claims instead of just creating a table? 231 | Performance, mostly. Custom claims are stored in the security token a user receives when logging in, and these claims are made available to the PostgreSQL database as a configuration parameter, i.e. `current_setting('request.jwt.claims', true)`. So the database has access to these values immediately without needing to do any disk i/o. 232 | 233 | This may sound trivial, but this could have a significant effect on scalability if you use claims in an RLS (Row Level Security) Policy, as it could potentially eliminate thousands (or even millions) of database calls. 234 | 235 | ### What are the drawbacks to using custom claims? 236 | One drawback is that claims don't get updated automatically, so if you assign a user a new custom claim, they may need to log out and log back in to have the new claim available to them. The same goes for deleting or changing a claim. So this is not a good tool for storing data that changes frequently. 237 | 238 | You can force a refresh of the current session token by calling `supabase.auth.refreshSession()` on the client, but if a claim is changed by a server process or by a claims adminstrator manually, there's no easy way to notify the user that their claims have changed. You can provide a "refresh" button or a refresh function inside your app to update the claims at any time, though. 239 | 240 | ### How can I write a query to find all the users who have a specific custom claim set? 241 | #### examples 242 | ##### find all users who have `claims_admin` set to `true` 243 | `select * from auth.users where (auth.users.raw_app_meta_data->'claims_admin')::bool = true;` 244 | ##### find all users who have a `userlevel` over 100 245 | `select * from auth.users where (auth.users.raw_app_meta_data->'userlevel')::numeric > 100;` 246 | ##### find all users whose `userrole` is set to `"MANAGER"` 247 | (note for strings you need to add double-quotes becuase data is data is stored as JSONB) 248 | `select * from auth.users where (auth.users.raw_app_meta_data->'userrole')::text = '"MANAGER"';` 249 | 250 | ### What's the difference between `auth.users.raw_app_meta_data` and `auth.users.raw_user_meta_data`? 251 | The `auth.users` table used by Supabase Auth (GoTrue) has both `raw_app_meta_data` and a `raw_user_meta_data` fields. 252 | 253 | `raw_user_meta_data` is designed for profile data and can be created and modified by a user. For example, this data can be set when a user signs up: [sign-up-with-additional-user-meta-data](https://supabase.com/docs/reference/javascript/auth-signup#sign-up-with-additional-user-meta-data) or this data can be modified by a user with [auth-update](https://supabase.com/docs/reference/javascript/auth-updateuser) 254 | 255 | `raw_app_meta_data` is designed for use by the application layer and is used by GoTrue to handle authentication (For exampple, the `provider` and `providers` claims are used by GoTrue to track authentication providers.) `raw_app_meta_data` is not accessible to the user by default. 256 | 257 | ### NOTES: 258 | ##### updating claims from a server process or edge function 259 | https://supabase.com/docs/reference/javascript/auth-api-updateuserbyid#updates-a-users-app_metadata 260 | 261 | ## Warning 262 | Be sure to watch for **reserved** claims in your particular development environment. For example, the claims `exp` and `role` are reserved by the Supabase Realtime system and can cause problems if you try use these names. To avoid these potential problems, it's good practice to use a custom identifier in your custom claims, such as `MY_COMPANY_item1`, `MY_COMPANY_item2`, etc. 263 | -------------------------------------------------------------------------------- /claims-demo/.env.sample: -------------------------------------------------------------------------------- 1 | REACT_APP_SUPABASE_URL=https://xxxxxxxxxxxxxxxxxxxx.supabase.co 2 | REACT_APP_SUPABASE_ANON_KEY=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx 3 | 4 | -------------------------------------------------------------------------------- /claims-demo/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | .env 8 | 9 | # testing 10 | /coverage 11 | 12 | # production 13 | /build 14 | 15 | # misc 16 | .DS_Store 17 | .env.local 18 | .env.development.local 19 | .env.test.local 20 | .env.production.local 21 | 22 | npm-debug.log* 23 | yarn-debug.log* 24 | yarn-error.log* 25 | -------------------------------------------------------------------------------- /claims-demo/README.md: -------------------------------------------------------------------------------- 1 | # claims-demo react app 2 | 3 | This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app). 4 | 5 | ## setup 6 | 7 | - Make sure you have a Supabase project set up and the site URL is set to `https://localhost:3000` 8 | - Copy the file `.env.sample` to `.env` and insert your own values for `REACT_APP_SUPABASE_URL` and `REACT_APP_SUPABASE_ANON_KEY` - found in the Supabase dashboard here: [Supabase API Settings](https://app.supabase.io/project/_/settings/api) 9 | - Run the contents of [../install.sql](../install.sql) inside your [Supabase Dashboard SQL Editor](https://app.supabase.io/project/_/sql) 10 | - [Bootstrap](https://github.com/supabase-community/supabase-custom-claims#bootstrapping) a user to make them a `claims_admin` user if you want to be able to set or delete claims 11 | - Start the app: `npm start` 12 | 13 | ### run: `npm start` 14 | 15 | Runs the app in the development mode.\ 16 | Open [http://localhost:3000](http://localhost:3000) to view it in your browser. 17 | 18 | -------------------------------------------------------------------------------- /claims-demo/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "claims-demo", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@supabase/supabase-js": "^2.4.1", 7 | "@testing-library/jest-dom": "^5.16.4", 8 | "@testing-library/react": "^13.2.0", 9 | "@testing-library/user-event": "^13.5.0", 10 | "react": "^18.1.0", 11 | "react-dom": "^18.1.0", 12 | "react-scripts": "5.0.1", 13 | "web-vitals": "^2.1.4" 14 | }, 15 | "scripts": { 16 | "start": "react-scripts start", 17 | "build": "react-scripts build", 18 | "test": "react-scripts test", 19 | "eject": "react-scripts eject" 20 | }, 21 | "eslintConfig": { 22 | "extends": [ 23 | "react-app", 24 | "react-app/jest" 25 | ] 26 | }, 27 | "browserslist": { 28 | "production": [ 29 | ">0.2%", 30 | "not dead", 31 | "not op_mini all" 32 | ], 33 | "development": [ 34 | "last 1 chrome version", 35 | "last 1 firefox version", 36 | "last 1 safari version" 37 | ] 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /claims-demo/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 17 | 18 | 27 | React App 28 | 29 | 30 | 31 |
32 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /claims-demo/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | ], 6 | "start_url": ".", 7 | "display": "standalone", 8 | "theme_color": "#000000", 9 | "background_color": "#ffffff" 10 | } 11 | -------------------------------------------------------------------------------- /claims-demo/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /claims-demo/src/App.css: -------------------------------------------------------------------------------- 1 | .App { 2 | 3 | } 4 | 5 | .center { 6 | text-align: center; 7 | } 8 | 9 | .App-header { 10 | font-size: calc(10px + 2vmin); 11 | font-weight: bold; 12 | text-align: center; 13 | } 14 | 15 | button { 16 | margin-left: 5px; 17 | margin-right: 5px; 18 | margin-top: 5px; 19 | margin-bottom: 5px; 20 | } 21 | pre { 22 | margin: 20px; 23 | border: 1px solid; 24 | padding: 5px; 25 | } 26 | .notes { 27 | margin: 20px; 28 | border: none; 29 | padding: 5px; 30 | 31 | } 32 | .title { 33 | margin-left: 20px; 34 | font-weight: bold; 35 | } 36 | 37 | -------------------------------------------------------------------------------- /claims-demo/src/App.js: -------------------------------------------------------------------------------- 1 | import './App.css' 2 | import { useState, useEffect } from 'react' 3 | import { supabase } from './supabaseClient' 4 | import Auth from './Auth' 5 | import Logout from './Logout' 6 | import Userinfo from './Userinfo' 7 | import TestFunctions from './TestFunctions' 8 | 9 | function App() { 10 | const [session, setSession] = useState(null) 11 | 12 | useEffect(() => { 13 | const run = async () => { 14 | const { data } = await supabase.auth.getSession() 15 | setSession(data.session) 16 | } 17 | run(); 18 | supabase.auth.onAuthStateChange((_event, session) => { 19 | setSession(session) 20 | }) 21 | }, []) 22 | return ( 23 |
24 |
25 |

Supabase Custom Claims Demo Application

26 |
27 |
28 | {!session ? 29 |
30 | 31 |
32 | : 33 | <> 34 |
35 | 36 | 37 |
38 | 39 | 40 | } 41 |
42 |
43 | ) 44 | } 45 | 46 | export default App 47 | -------------------------------------------------------------------------------- /claims-demo/src/Auth.js: -------------------------------------------------------------------------------- 1 | import { useState } from 'react' 2 | import { supabase } from './supabaseClient' 3 | 4 | export default function Auth() { 5 | const [loading, setLoading] = useState(false) 6 | const [email, setEmail] = useState('') 7 | 8 | const handleLogin = async (e) => { 9 | e.preventDefault() 10 | 11 | try { 12 | setLoading(true) 13 | const { error } = await supabase.auth.signInWithOtp({ email }) 14 | if (error) throw error 15 | alert('Check your email for the login link!') 16 | } catch (error) { 17 | alert(error.error_description || error.message) 18 | } finally { 19 | setLoading(false) 20 | } 21 | } 22 | 23 | return ( 24 |
25 |
26 |

Supabase + React

27 |

Sign in via magic link with your email below

28 | {loading ? ( 29 | 'Sending magic link...' 30 | ) : ( 31 |
32 | 33 | setEmail(e.target.value)} 40 | /> 41 | 44 |
45 | )} 46 |
47 |
48 | ) 49 | } -------------------------------------------------------------------------------- /claims-demo/src/Logout.js: -------------------------------------------------------------------------------- 1 | import { supabase } from './supabaseClient' 2 | 3 | const Logout = () => { 4 | return ( 5 | 6 | 9 | 10 | ) 11 | } 12 | 13 | export default Logout 14 | -------------------------------------------------------------------------------- /claims-demo/src/TestFunctions.js: -------------------------------------------------------------------------------- 1 | import { supabase } from './supabaseClient' 2 | import { useState, useEffect } from 'react'; 3 | 4 | const TestFunctions = ({session}) => { 5 | const [output, setOutput] = useState('') 6 | const [notes, setNotes] = useState('') 7 | const [title, setTitle] = useState('') 8 | const [uid, setUid] = useState('') 9 | const [claim, setClaim] = useState('') 10 | const [value, setValue] = useState('') 11 | useEffect(() => { 12 | setUid(session?.user?.id || '') 13 | }, []) 14 | 15 | const get_my_claims = async () => { 16 | setOutput('Loading...') 17 | setTitle('get_my_claims') 18 | const { data, error } = await supabase.rpc('get_my_claims'); 19 | if (error) console.error('get_my_claims error', error); 20 | else setOutput(JSON.stringify(data, null, 2)); 21 | setNotes('This calls the server function "get_my_claims()" and gets the claims from the current token at the server.') 22 | } 23 | const get_my_claim = async () => { 24 | setOutput('Loading...') 25 | setTitle(`get_my_claim('${claim}')`) 26 | const { data, error } = await supabase.rpc('get_my_claim',{claim}); 27 | if (error) console.error('get_my_claim error', error); 28 | else setOutput(JSON.stringify(data, null, 2)); 29 | setNotes('This calls the server function "get_my_claim(claim text)" and gets the claim from the current token at the server.') 30 | } 31 | const show_session = async () => { 32 | setOutput('Loading...') 33 | setTitle('session object') 34 | setOutput(JSON.stringify(session, null, 2)); 35 | setNotes('This displays the entire session object that was returned from "supabase.auth.onAuthStateChange".') 36 | } 37 | const is_claims_admin = async () => { 38 | setOutput('Loading...') 39 | setTitle('is_claims_admin') 40 | const { data, error } = await supabase.rpc('is_claims_admin'); 41 | if (error) console.error('is_claims_admin error', error); 42 | else setOutput(JSON.stringify(data, null, 2)); 43 | setNotes('This calls the server function "is_claims_admin()" and returns true if the current token on teh server has the "claims_admin" claim.') 44 | } 45 | const session_claims = async () => { 46 | setTitle('session_claims') 47 | if (!session.user) { 48 | setOutput('no session.user') 49 | } else { 50 | setOutput(JSON.stringify(session.user?.app_metadata, null, 2)) 51 | } 52 | setNotes('This returns the value of "app_metadata" (the claims) from the current session object (returned from "supabase.auth.onAuthStateChange").') 53 | } 54 | const set_claim = async () => { 55 | if (!uid || !claim || !value) return; 56 | setOutput('Loading...') 57 | setTitle('set_claim') 58 | const { data, error } = await supabase.rpc('set_claim', {uid, claim, value}); 59 | if (error) console.error('set_claim error', error); 60 | else { // setOutput(JSON.stringify(data, null, 2)); 61 | const { user, error: updateError } = await refresh_claims(); 62 | if (updateError) console.error('update error', updateError); 63 | else setOutput(JSON.stringify(user?.app_metadata, null, 2)); 64 | } 65 | setNotes('This calls the server function "set_claim(uid, claim, value)" to set a custom claim for a given user by id (uuid).') 66 | } 67 | const delete_claim = async () => { 68 | if (!uid || !claim) return; 69 | setOutput('Loading...') 70 | setTitle('delete_claim') 71 | const { data, error } = await supabase.rpc('delete_claim', {uid, claim}); 72 | if (error) console.error('delete_claim error', error); 73 | else { // setOutput(JSON.stringify(data, null, 2)); 74 | const { user, error: updateError } = await refresh_claims(); 75 | if (updateError) console.error('update error', updateError); 76 | else setOutput(JSON.stringify(user?.app_metadata, null, 2)); 77 | } 78 | setNotes('This calls the server function "delete_claim(uid, claim)" to delete a custom claim for a given user by id (uuid).') 79 | } 80 | const refresh_claims = async () => { 81 | const { data: { user }, error } = await supabase.auth.refreshSession() 82 | return { user, error }; 83 | } 84 | return ( 85 | <> 86 |
87 | Local: 88 | 91 | 94 | 97 |
98 |
99 | Server: 100 | 103 | 106 | claim: setClaim(e.target.value)} placeholder="claim name" /> 107 | 110 |
111 |
112 | uid: setUid(e.target.value)} placeholder="user id (auth.users.id)" /> 113 |  claim: setClaim(e.target.value)} placeholder="claim name" /> 114 |  value: setValue(e.target.value)} placeholder="claim value" /> 115 | 118 | 121 |
122 |
123 | { (session?.user?.app_metadata?.claims_admin) && 124 | ADMIN 125 | } 126 | { (!session?.user?.app_metadata?.claims_admin) && 127 | NOT ADMIN 128 | } 129 |
130 |
131 |
{title}
132 |
{output}
133 |
{title}: {notes}
134 |
135 | 136 |
137 | {(session?.user?.app_metadata?.claims_admin) ? 'you are a CLAIMS_ADMIN' : 'you are NOT a CLAIMS_ADMIN'} 138 |
139 | 140 | ) 141 | } 142 | 143 | export default TestFunctions 144 | -------------------------------------------------------------------------------- /claims-demo/src/Userinfo.js: -------------------------------------------------------------------------------- 1 | import { supabase } from './supabaseClient' 2 | 3 | const Userinfo = ({session}) => { 4 | return ( 5 | 6 | { session.user && session.user.email } 7 | { !session.user && 'not logged in' } 8 | 9 | ) 10 | } 11 | 12 | export default Userinfo 13 | -------------------------------------------------------------------------------- /claims-demo/src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 4 | 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', 5 | sans-serif; 6 | -webkit-font-smoothing: antialiased; 7 | -moz-osx-font-smoothing: grayscale; 8 | } 9 | 10 | code { 11 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', 12 | monospace; 13 | } 14 | -------------------------------------------------------------------------------- /claims-demo/src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom/client'; 3 | import './index.css'; 4 | import App from './App'; 5 | 6 | const root = ReactDOM.createRoot(document.getElementById('root')); 7 | root.render( 8 | 9 | 10 | 11 | ); 12 | 13 | -------------------------------------------------------------------------------- /claims-demo/src/supabaseClient.js: -------------------------------------------------------------------------------- 1 | import { createClient } from '@supabase/supabase-js' 2 | 3 | const supabaseUrl = process.env.REACT_APP_SUPABASE_URL 4 | const supabaseAnonKey = process.env.REACT_APP_SUPABASE_ANON_KEY 5 | 6 | export const supabase = createClient(supabaseUrl, supabaseAnonKey) -------------------------------------------------------------------------------- /install.sql: -------------------------------------------------------------------------------- 1 | CREATE OR REPLACE FUNCTION is_claims_admin() RETURNS "bool" 2 | LANGUAGE "plpgsql" 3 | AS $$ 4 | BEGIN 5 | IF session_user = 'authenticator' THEN 6 | -------------------------------------------- 7 | -- To disallow any authenticated app users 8 | -- from editing claims, delete the following 9 | -- block of code and replace it with: 10 | -- RETURN FALSE; 11 | -------------------------------------------- 12 | IF extract(epoch from now()) > coalesce((current_setting('request.jwt.claims', true)::jsonb)->>'exp', '0')::numeric THEN 13 | return false; -- jwt expired 14 | END IF; 15 | If current_setting('request.jwt.claims', true)::jsonb->>'role' = 'service_role' THEN 16 | RETURN true; -- service role users have admin rights 17 | END IF; 18 | IF coalesce((current_setting('request.jwt.claims', true)::jsonb)->'app_metadata'->'claims_admin', 'false')::bool THEN 19 | return true; -- user has claims_admin set to true 20 | ELSE 21 | return false; -- user does NOT have claims_admin set to true 22 | END IF; 23 | -------------------------------------------- 24 | -- End of block 25 | -------------------------------------------- 26 | ELSE -- not a user session, probably being called from a trigger or something 27 | return true; 28 | END IF; 29 | END; 30 | $$; 31 | 32 | CREATE OR REPLACE FUNCTION get_my_claims() RETURNS "jsonb" 33 | LANGUAGE "sql" STABLE 34 | AS $$ 35 | select 36 | coalesce(nullif(current_setting('request.jwt.claims', true), '')::jsonb -> 'app_metadata', '{}'::jsonb)::jsonb 37 | $$; 38 | CREATE OR REPLACE FUNCTION get_my_claim(claim TEXT) RETURNS "jsonb" 39 | LANGUAGE "sql" STABLE 40 | AS $$ 41 | select 42 | coalesce(nullif(current_setting('request.jwt.claims', true), '')::jsonb -> 'app_metadata' -> claim, null) 43 | $$; 44 | 45 | CREATE OR REPLACE FUNCTION get_claims(uid uuid) RETURNS "jsonb" 46 | LANGUAGE "plpgsql" SECURITY DEFINER SET search_path = public 47 | AS $$ 48 | DECLARE retval jsonb; 49 | BEGIN 50 | IF NOT is_claims_admin() THEN 51 | RETURN '{"error":"access denied"}'::jsonb; 52 | ELSE 53 | select raw_app_meta_data from auth.users into retval where id = uid::uuid; 54 | return retval; 55 | END IF; 56 | END; 57 | $$; 58 | 59 | CREATE OR REPLACE FUNCTION get_claim(uid uuid, claim text) RETURNS "jsonb" 60 | LANGUAGE "plpgsql" SECURITY DEFINER SET search_path = public 61 | AS $$ 62 | DECLARE retval jsonb; 63 | BEGIN 64 | IF NOT is_claims_admin() THEN 65 | RETURN '{"error":"access denied"}'::jsonb; 66 | ELSE 67 | select coalesce(raw_app_meta_data->claim, null) from auth.users into retval where id = uid::uuid; 68 | return retval; 69 | END IF; 70 | END; 71 | $$; 72 | 73 | CREATE OR REPLACE FUNCTION set_claim(uid uuid, claim text, value jsonb) RETURNS "text" 74 | LANGUAGE "plpgsql" SECURITY DEFINER SET search_path = public 75 | AS $$ 76 | BEGIN 77 | IF NOT is_claims_admin() THEN 78 | RETURN 'error: access denied'; 79 | ELSE 80 | update auth.users set raw_app_meta_data = 81 | raw_app_meta_data || 82 | json_build_object(claim, value)::jsonb where id = uid; 83 | return 'OK'; 84 | END IF; 85 | END; 86 | $$; 87 | 88 | CREATE OR REPLACE FUNCTION delete_claim(uid uuid, claim text) RETURNS "text" 89 | LANGUAGE "plpgsql" SECURITY DEFINER SET search_path = public 90 | AS $$ 91 | BEGIN 92 | IF NOT is_claims_admin() THEN 93 | RETURN 'error: access denied'; 94 | ELSE 95 | update auth.users set raw_app_meta_data = 96 | raw_app_meta_data - claim where id = uid; 97 | return 'OK'; 98 | END IF; 99 | END; 100 | $$; 101 | NOTIFY pgrst, 'reload schema'; 102 | -------------------------------------------------------------------------------- /supabase_custom_claims--1.0.sql: -------------------------------------------------------------------------------- 1 | --complain if script is sourced in psql, rather than via CREATE EXTENSION 2 | \echo Use "CREATE EXTENSION supabase_custom_claims" to load this file. \quit 3 | 4 | CREATE OR REPLACE FUNCTION is_claims_admin() RETURNS "bool" 5 | LANGUAGE "plpgsql" 6 | AS $$ 7 | BEGIN 8 | IF session_user = 'authenticator' THEN 9 | -------------------------------------------- 10 | -- To disallow any authenticated app users 11 | -- from editing claims, delete the following 12 | -- block of code and replace it with: 13 | -- RETURN FALSE; 14 | -------------------------------------------- 15 | IF extract(epoch from now()) > coalesce((current_setting('request.jwt.claims', true)::jsonb)->>'exp', '0')::numeric THEN 16 | return false; -- jwt expired 17 | END IF; 18 | IF coalesce((current_setting('request.jwt.claims', true)::jsonb)->'app_metadata'->'claims_admin', 'false')::bool THEN 19 | return true; -- user has claims_admin set to true 20 | ELSE 21 | return false; -- user does NOT have claims_admin set to true 22 | END IF; 23 | -------------------------------------------- 24 | -- End of block 25 | -------------------------------------------- 26 | ELSE -- not a user session, probably being called from a trigger or something 27 | return true; 28 | END IF; 29 | END; 30 | $$; 31 | 32 | CREATE OR REPLACE FUNCTION get_my_claims() RETURNS "jsonb" 33 | LANGUAGE "sql" STABLE 34 | AS $$ 35 | select 36 | coalesce(nullif(current_setting('request.jwt.claims', true), '')::jsonb -> 'app_metadata', '{}'::jsonb)::jsonb 37 | $$; 38 | CREATE OR REPLACE FUNCTION get_my_claim(claim TEXT) RETURNS "jsonb" 39 | LANGUAGE "sql" STABLE 40 | AS $$ 41 | select 42 | coalesce(nullif(current_setting('request.jwt.claims', true), '')::jsonb -> 'app_metadata' -> claim, null) 43 | $$; 44 | 45 | CREATE OR REPLACE FUNCTION get_claims(uid uuid) RETURNS "jsonb" 46 | LANGUAGE "plpgsql" SECURITY DEFINER SET search_path = public 47 | AS $$ 48 | DECLARE retval jsonb; 49 | BEGIN 50 | IF NOT is_claims_admin() THEN 51 | RETURN '{"error":"access denied"}'::jsonb; 52 | ELSE 53 | select raw_app_meta_data from auth.users into retval where id = uid::uuid; 54 | return retval; 55 | END IF; 56 | END; 57 | $$; 58 | 59 | CREATE OR REPLACE FUNCTION get_claim(uid uuid, claim text) RETURNS "jsonb" 60 | LANGUAGE "plpgsql" SECURITY DEFINER SET search_path = public 61 | AS $$ 62 | DECLARE retval jsonb; 63 | BEGIN 64 | IF NOT is_claims_admin() THEN 65 | RETURN '{"error":"access denied"}'::jsonb; 66 | ELSE 67 | select coalesce(raw_app_meta_data->claim, null) from auth.users into retval where id = uid::uuid; 68 | return retval; 69 | END IF; 70 | END; 71 | $$; 72 | 73 | CREATE OR REPLACE FUNCTION set_claim(uid uuid, claim text, value jsonb) RETURNS "text" 74 | LANGUAGE "plpgsql" SECURITY DEFINER SET search_path = public 75 | AS $$ 76 | BEGIN 77 | IF NOT is_claims_admin() THEN 78 | RETURN 'error: access denied'; 79 | ELSE 80 | update auth.users set raw_app_meta_data = 81 | raw_app_meta_data || 82 | json_build_object(claim, value)::jsonb where id = uid; 83 | return 'OK'; 84 | END IF; 85 | END; 86 | $$; 87 | 88 | CREATE OR REPLACE FUNCTION delete_claim(uid uuid, claim text) RETURNS "text" 89 | LANGUAGE "plpgsql" SECURITY DEFINER SET search_path = public 90 | AS $$ 91 | BEGIN 92 | IF NOT is_claims_admin() THEN 93 | RETURN 'error: access denied'; 94 | ELSE 95 | update auth.users set raw_app_meta_data = 96 | raw_app_meta_data - claim where id = uid; 97 | return 'OK'; 98 | END IF; 99 | END; 100 | $$; 101 | NOTIFY pgrst, 'reload schema'; 102 | -------------------------------------------------------------------------------- /supabase_custom_claims.control: -------------------------------------------------------------------------------- 1 | # supabase_custom_claims extension 2 | comment = 'PostgreSQL functions that support custom claims in Supabase by writing to the raw_app_meta_data column in the auth.users table' 3 | requires = '' 4 | default_version = '1.0' 5 | module_pathname = '$libdir/supabase_custom_claims' 6 | relocatable = false 7 | schema = public -------------------------------------------------------------------------------- /uninstall.sql: -------------------------------------------------------------------------------- 1 | DROP FUNCTION get_my_claims; 2 | DROP FUNCTION get_my_claim; 3 | DROP FUNCTION get_claims; 4 | DROP FUNCTION set_claim; 5 | DROP FUNCTION delete_claim; 6 | DROP FUNCTION is_claims_admin; 7 | NOTIFY pgrst, 'reload schema'; 8 | --------------------------------------------------------------------------------