From 1b01a9c8aff0124071240fb76b8b7dac889c274a Mon Sep 17 00:00:00 2001 From: kanad Date: Wed, 19 Aug 2026 10:32:51 -0700 Subject: [PATCH 1/4] feat: table for collecting interfaces feedback (#48420) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## I have read the [CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md) file. YES ## What kind of change does this PR introduce? Database migration — adds a table for collecting free-form product feedback submitted from Supabase interfaces (starting with the CLI and the MCP server), including support for deleting a submission via a server-issued token. ## What is the current behavior? There is no destination for feedback submitted from the CLI or MCP server. The existing `feedback` and `feedback_comments` tables are scoped to the docs feedback widget, so interface feedback would otherwise end up as ad-hoc GitHub issues — with no way to revoke something submitted by accident (e.g. a secret key pasted into the message). ## What is the new behavior? Adds `public.interfaces_feedback`: | Column | Type | Notes | | --- | --- | --- | | `id` | `bigint` identity | primary key (not exposed through the API) | | `created_at` | `timestamptz` | `not null default now()` | | `feedback` | `text` | `not null`, ≤ 1000 chars — the free-form feedback | | `delete_token` | `uuid` | server-generated, `unique not null`; authorizes deleting the row | | `user_agent` | `text` | ≤ 255 chars; interface + version, also identifies the source interface | | `user_id` | `text` | optional, ≤ 255 chars; unverified, interface-defined identifier | | `project_ref` | `text` | optional, ≤ 255 chars | | `metadata` | `jsonb` | ≤ 8 KB catch-all | **Submission** happens exclusively through a `SECURITY DEFINER` function, `submit_interfaces_feedback(...)`, which inserts the row and returns the server-generated `delete_token` exactly once. There is no insert grant or policy on the table itself, so clients cannot insert directly or supply their own token — the function is the only door. Execute is revoked from `PUBLIC` and granted to `anon` only (both statements matter: local and hosted databases have different default function ACLs). **Deletion** is a hard `DELETE` authorized by presenting the token in an `x-feedback-token` request header. RLS policies compare the row's `delete_token` against that header (`current_setting('request.headers', ...)`) — the URL filter is never the security boundary; a request without the matching header affects zero rows, even with no filter or someone else's token in the filter. Tokens never expire (the delete right shouldn't lapse). The header is cast to `uuid` and compared against the untransformed column, so lookups use the unique index on `delete_token` even for header-only reads; a malformed token header is rejected with a `400` (`22P02`), consistent with what a malformed URL filter value already returns. **Context gate (defense-in-depth)**: rows submitted with a `project_ref` and/or `user_id` additionally require the matching `x-feedback-project-ref` / `x-feedback-user-id` headers — on both reads and deletes — so a leaked bare token can neither read the submission text back nor remove the row. A `NULL` column imposes no requirement: context-free rows keep token-only behavior, and extra headers sent against them are ignored (this keeps clients that always send their current context from being locked out of rows submitted without it). These are client-supplied, unverified values, so the gate is a knowledge factor rather than an identity check; clients should persist `{delete_token, project_ref, user_id}` together at submit time and re-present them byte-exact (`project_ref`/`user_id` are compared as plain text). **Reads** are limited to `grant select (feedback, delete_token)` behind the same token-scoped policy: a token-holder can preview their own submission text before deleting and confirm the delete matched (`Prefer: count=exact` → `Content-Range: */1` vs `*/0`). No other columns are readable by any API role; `delete_token` needs select because PostgREST requires a WHERE clause on deletes and filter columns require select privilege. Verified locally via `supabase db reset` + the local REST API: token issuance, token-scoped preview and delete, zero-row results for missing/wrong/malformed tokens (including a victim's token in the filter without the header), the full context-gate matrix (project+user, project-only, and context-free rows, incl. lenient extra-header behavior), denied direct inserts and column reads, length caps enforced through the function, and no execute for `authenticated`. ## Additional context Linear tickets: [CLI-1946](https://linear.app/supabase/issue/CLI-1946), [CLI-1999](https://linear.app/supabase/issue/CLI-1999) The client-side flows (`supabase feedback add` / `feedback delete` in the CLI, and the MCP tool) land separately in their respective repos and will call the RPC / DELETE endpoint described above. Supersedes #48378 — recreated on a fresh git branch so that the Supabase preview branch used for testing this table isn't shared with unrelated work. ## Summary by CodeRabbit * **New Features** * Added support for collecting and storing feedback submitted through interfaces. * Feedback can include submission source, timestamps, user details, project references, and additional metadata. * Added secure feedback submission with controlled access to protect submitted information. * Added support for authorized feedback removal using a secure deletion token. * Added safeguards to validate feedback content and restrict access to permitted information. --------- Co-authored-by: Claude Fable 5 --- ...60728035858_create_interfaces_feedback.sql | 100 ++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 supabase/migrations/20260728035858_create_interfaces_feedback.sql diff --git a/supabase/migrations/20260728035858_create_interfaces_feedback.sql b/supabase/migrations/20260728035858_create_interfaces_feedback.sql new file mode 100644 index 0000000000000..5b93b0e14baf9 --- /dev/null +++ b/supabase/migrations/20260728035858_create_interfaces_feedback.sql @@ -0,0 +1,100 @@ +create table interfaces_feedback ( + id bigint primary key generated always as identity, + created_at timestamptz not null default now(), + feedback text not null check (char_length(feedback) <= 1000), + delete_token uuid unique not null default gen_random_uuid(), + user_agent text check (char_length(user_agent) <= 255), + user_id text check (char_length(user_id) <= 255), + project_ref text check (char_length(project_ref) <= 255), + metadata jsonb check (pg_column_size(metadata) <= 8192) +); + +comment on table interfaces_feedback is +'General customer feedback submitted from Supabase interfaces such as the CLI and MCP server. Rows are inserted via submit_interfaces_feedback().'; +comment on column interfaces_feedback.feedback is +'The free-form feedback text as submitted by the user.'; +comment on column interfaces_feedback.delete_token is +'Server-generated capability token returned once by submit_interfaces_feedback(); presenting it via the x-feedback-token request header authorizes reading and deleting this row. Rows submitted with a project_ref and/or user_id additionally require the matching x-feedback-project-ref / x-feedback-user-id headers.'; +comment on column interfaces_feedback.user_agent is +'User agent of the submitting interface, e.g. SupabaseCLI/2.3.4. Also identifies which interface the feedback came from.'; +comment on column interfaces_feedback.user_id is +'Optional identifier of the submitting user, as reported by the interface. Unverified; format is interface-defined.'; +comment on column interfaces_feedback.project_ref is +'Optional reference of the Supabase project the feedback relates to.'; + +alter table interfaces_feedback enable row level security; + +-- The x-feedback-* request headers are the capability check: policies can +-- only compare row data against session context (never a query's WHERE +-- clause), so the values must arrive as headers. The token is always +-- required; project_ref and user_id are additionally required when (and only +-- when) the row was submitted with them — a null column imposes no +-- requirement, and values must be re-presented byte-exact. The token column +-- stays untransformed so lookups use its unique index; a malformed token +-- header is rejected with a 400 (22P02), same as a malformed URL filter. +create policy "Token holders can read their own feedback" +on interfaces_feedback +as permissive for select +to anon +using ( + delete_token = (current_setting('request.headers', true)::json ->> 'x-feedback-token')::uuid + and (project_ref is null or project_ref = current_setting('request.headers', true)::json ->> 'x-feedback-project-ref') + and (user_id is null or user_id = current_setting('request.headers', true)::json ->> 'x-feedback-user-id') +); + +create policy "Token holders can delete their own feedback" +on interfaces_feedback +as permissive for delete +to anon +using ( + delete_token = (current_setting('request.headers', true)::json ->> 'x-feedback-token')::uuid + and (project_ref is null or project_ref = current_setting('request.headers', true)::json ->> 'x-feedback-project-ref') + and (user_id is null or user_id = current_setting('request.headers', true)::json ->> 'x-feedback-user-id') +); + +-- Submissions go exclusively through this function so the delete token is +-- always server-generated and returned exactly once to the submitter. There +-- is deliberately no insert grant or policy on the table itself. +create function public.submit_interfaces_feedback( + feedback text, + user_agent text default null, + user_id text default null, + project_ref text default null, + metadata jsonb default null +) +returns uuid +security definer +set search_path = '' +language plpgsql +as $$ +#variable_conflict use_variable +declare + token uuid; +begin + insert into public.interfaces_feedback (feedback, user_agent, user_id, project_ref, metadata) + values (feedback, user_agent, user_id, project_ref, metadata) + returning delete_token into token; + return token; +end; +$$; + +comment on function public.submit_interfaces_feedback is +'Submits interface feedback and returns the delete token (issued exactly once).'; + +-- Both lines are load-bearing, in different environments: locally, the +-- default ACL gives new functions no EXECUTE at all (the grant is required); +-- on prod, the built-in default gives EXECUTE to the PUBLIC pseudo-role (the +-- revoke is required, and it must target public — revoking from anon or +-- authenticated by name is a no-op). +revoke execute on function public.submit_interfaces_feedback(text, text, text, text, jsonb) from public; +grant execute on function public.submit_interfaces_feedback(text, text, text, text, jsonb) to anon; + +-- Two-gate model: these grants allow anon to ATTEMPT select/delete +-- statements; the header-checked policies above decide which rows each +-- statement can see. Column-scoped select keeps everything except the +-- feedback text and the caller's own token unreadable. delete_token needs +-- select because PostgREST rejects filterless deletes and WHERE columns +-- require select privilege — clients send the token as both the filter and +-- the header, and the policy stays the security boundary. +grant select (feedback, delete_token) on table interfaces_feedback to anon; +grant delete on table interfaces_feedback to anon; From edf50668aab7ca90495ea9afc2a266f4b6a0a3db Mon Sep 17 00:00:00 2001 From: Miranda Limonczenko Date: Wed, 19 Aug 2026 10:41:08 -0700 Subject: [PATCH 2/4] docs(database): split RLS tuning into its own guide (#49016) Stacked on #49015, which is stacked on #49011. Review those first. ## Problem The Row Level Security guide spent 225 lines and 5 benchmark tables on performance, 29% of the page. The `RLS Performance and Best Practices` troubleshooting entry already covers the same six tips with the same numbers, from the same source. Neither page tells you how to check whether RLS is your bottleneck in the first place. Four of the six tips are not tuning advice. Indexes, `select`-wrapping, role scoping, and `security definer` safety change whether a policy is correct and safe, not just fast. ## Solution - Add `guides/database/postgres/row-level-security-performance`. It carries the client-filter rule, the join-rewrite rule, all 5 benchmark tables merged into one, and a new `Diagnose whether RLS is the bottleneck` section: toggle RLS off to confirm it's the cost, then read the plan under an impersonated role. That diagnostic exists in the troubleshooting entry and has never been in the guide. - Keep every rule that affects correctness on the RLS guide, grouped under `Write policies that scale`. These are also the four the `build-docs-002-rls-guide` eval grades, and an agent reads the guide top-down. - Repoint the Grafana IO troubleshooting entry at the new page. - Rewrite `More resources` as `Related content`. Every link now says what it is and when to use it. Adds `Advanced pgTAP testing`, the deepest RLS testing content in the docs, which nothing here linked. Drops discussion 14576: locked, mislabeled here as "RLS Guide and Best Practices" when it is "RLS **Performance** and Best Practices", and superseded by the troubleshooting entry and this new page. **Ownership rule** so the two pages don't drift: the RLS guide owns the rule and the correct form. The performance page owns the measurement and the optimizer explanation. If a sentence on the performance page tells you what to write, it belongs on the guide. Scoped out of this PR: `More resources` was assigned to the restructure PR in the plan, but the 14576 link is what this PR supersedes, so leaving it would ship a stale pointer. ## Manual testing 1. Open the [RLS performance guide](https://docs-git-docs-rls-performance-split-supabase.vercel.app/docs/guides/database/postgres/row-level-security-performance) on the preview. It appears in the left nav under Database, Access and security, directly below Row Level Security. 2. Select the three rule links in its intro. Each lands on the matching section of the RLS guide. 3. Open the [Row Level Security guide](https://docs-git-docs-rls-performance-split-supabase.vercel.app/docs/guides/database/postgres/row-level-security) and go to `Write policies that scale`. It holds indexes, `select`-wrapping, and role scoping, with one link out to the performance page. 4. Open the [Grafana IO troubleshooting entry](https://docs-git-docs-rls-performance-split-supabase.vercel.app/docs/guides/troubleshooting/interpreting-supabase-grafana-io-charts-MUynDR) and select the RLS performance guide link. It lands on the new page. ## Summary by CodeRabbit * **Documentation** * Added a dedicated guide for diagnosing and improving PostgreSQL Row Level Security performance. * Expanded guidance on indexing, query filters, role targeting, function usage, and avoiding costly policy joins. * Updated the Row Level Security guide with streamlined, scalable policy recommendations and links to related resources. * Added the new performance guide to the Database documentation navigation. * Updated troubleshooting guidance to reference the dedicated performance guide. --------- Co-authored-by: Claude Opus 5 --- .../NavigationMenu.constants.ts | 4 + .../row-level-security-performance.mdx | 155 ++++++++++ .../database/postgres/row-level-security.mdx | 272 ++---------------- ...ting-supabase-grafana-io-charts-MUynDR.mdx | 2 +- 4 files changed, 181 insertions(+), 252 deletions(-) create mode 100644 apps/docs/content/guides/database/postgres/row-level-security-performance.mdx diff --git a/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts b/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts index 3c79bb06fd879..5498aca968b67 100644 --- a/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts +++ b/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts @@ -1117,6 +1117,10 @@ export const database: NavMenuConstant = { name: 'Row Level Security', url: '/guides/database/postgres/row-level-security' as `/${string}`, }, + { + name: 'Row Level Security Performance', + url: '/guides/database/postgres/row-level-security-performance' as `/${string}`, + }, { name: 'Column Level Security', url: '/guides/database/postgres/column-level-security' as `/${string}`, diff --git a/apps/docs/content/guides/database/postgres/row-level-security-performance.mdx b/apps/docs/content/guides/database/postgres/row-level-security-performance.mdx new file mode 100644 index 0000000000000..af0a3d11c7ed8 --- /dev/null +++ b/apps/docs/content/guides/database/postgres/row-level-security-performance.mdx @@ -0,0 +1,155 @@ +--- +id: 'row-level-security-performance' +title: 'Row Level Security performance' +description: 'Measure and tune Postgres Row Level Security policies.' +subtitle: 'Measure and tune Postgres Row Level Security policies.' +--- + +Measure the cost of Row Level Security (RLS) and tune policies that are already correct. To learn how to write correct policies, see [Row Level Security](/docs/guides/database/postgres/row-level-security). + +Postgres evaluates a policy expression against each candidate row, so the cost scales with the rows a query scans. This matters most for queries that scan every row in a table, like many `select` operations, including those using limit, offset, and ordering. + +Three policy rules affect performance enough that they belong with the policy itself rather than here. Apply them first: + +- [Index the columns your policies filter on](/docs/guides/database/postgres/row-level-security#add-indexes) +- [Call functions with `select`](/docs/guides/database/postgres/row-level-security#call-functions-with-select) +- [Specify roles in your policies](/docs/guides/database/postgres/row-level-security#specify-roles-in-your-policies) + +## Diagnose whether RLS is the bottleneck + +Confirm that policies are the cost before you rewrite one. Run the query with RLS enabled, then again with it disabled, and compare. If the times are similar, the query itself is the problem. + + + +Disabling RLS exposes every row in the table to any role with a matching grant. Only do this in a non-production environment. + + + +To reproduce an API request, set the JWT claims and switch to the role the request runs as: + +```sql +set session role authenticated; +set request.jwt.claims to '{"role":"authenticated", "sub":"5950b438-b07c-4012-8190-6ce79e4bd8e5"}'; + +explain analyze select count(*) from rlstest; + +set session role postgres; +``` + +The output shows the policy expression as a filter, and the execution time is the number to compare: + +``` +Seq Scan on rlstest (cost=0.00..4334.00 rows=1 width=35) (actual time=170.999..170.999 rows=0 loops=1) + Filter: ((COALESCE(NULLIF(current_setting('request.jwt.claim.sub'::text, true), ''::text), ((NULLIF(current_setting('request.jwt.claims'::text, true), ''::text))::jsonb ->> 'sub'::text)))::uuid = user_id) + Rows Removed by Filter: 100000 +Planning Time: 0.216 ms +Execution Time: 171.033 ms +``` + +`Rows Removed by Filter` is the signal to watch. A policy that removes most of the table on every read is a policy whose filter column needs an index. + +### Measure through the Data API + +PostgREST can return the query plan to a Supabase client. Enable it first: + +```sql +alter role authenticator set pgrst.db_plan_enabled to true; +notify pgrst, 'reload config'; +``` + + + +`pgrst.db_plan_enabled` exposes query plans over your Data API. Don't enable it in production. + + + +Then add the `.explain()` modifier to a query: + +```js +const { data, error } = await supabase + .from('projects') + .select('*') + .eq('id', 1) + .explain({ analyze: true }) + +console.log(data) +``` + +``` +Aggregate (cost=8.18..8.20 rows=1 width=112) (actual time=0.017..0.018 rows=1 loops=1) + -> Index Scan using projects_pkey on projects (cost=0.15..8.17 rows=1 width=40) (actual time=0.012..0.012 rows=0 loops=1) + Index Cond: (id = 1) + Filter: false + Rows Removed by Filter: 1 +Planning Time: 0.092 ms +Execution Time: 0.046 ms +``` + +## Filter in the client query too + +Policies are implicit `where` clauses, so it's common to run `select` statements without any filters. That's a bad pattern for performance. Instead of this: + +{/* prettier-ignore */} +```js +const { data } = supabase + .from('table') + .select() +``` + +Always add a filter: + +{/* prettier-ignore */} +```js +const { data } = supabase + .from('table') + .select() + .eq('user_id', userId) +``` + +Even though this duplicates the contents of the policy, Postgres can use the filter to construct a better query plan. + +## Avoid joins in policy expressions + +You can often rewrite a policy to avoid a join between the source and the target table. Fetch the relevant data from the target table into an array or set instead, then use an `in` or `any` operation in your filter. + +This policy joins the source `test_table` to the target `team_user`: + +```sql +create policy "rls_test_select" on test_table +to authenticated +using ( + (select auth.uid()) in ( + select user_id + from team_user + where team_user.team_id = team_id -- joins to the source "test_table.team_id" + ) +); +``` + +Rewriting it selects the filter criteria into a set instead: + +```sql +create policy "rls_test_select" on test_table +to authenticated +using ( + team_id in ( + select team_id + from team_user + where user_id = (select auth.uid()) -- no join + ) +); +``` + +You can also use a [security definer function](/docs/guides/database/postgres/row-level-security#use-security-definer-functions) to bypass RLS on the join table. + + + +If the list exceeds 1000 items, a different approach may be needed, or you may need to analyze the approach to ensure that the performance is acceptable. + + + +## More resources + +- [Row Level Security](/docs/guides/database/postgres/row-level-security) +- [Managing indexes in Postgres](/docs/guides/database/postgres/indexes) +- [Query optimization](/docs/guides/database/query-optimization) diff --git a/apps/docs/content/guides/database/postgres/row-level-security.mdx b/apps/docs/content/guides/database/postgres/row-level-security.mdx index 7943aebcc910f..ea7fbf7611469 100644 --- a/apps/docs/content/guides/database/postgres/row-level-security.mdx +++ b/apps/docs/content/guides/database/postgres/row-level-security.mdx @@ -557,147 +557,9 @@ rollback; For CLI setup and more pgTAP helpers, see [Testing your database](/docs/guides/database/testing). -## Test your policies - -We recommend writing tests for every policy, in the same change that sets the grants and creates the policies. Tests are a fundamental part of a secure setup, and they give you a repeatable way to prove a policy behaves the way you intended. - -A wrong policy fails quietly. Too permissive, and a query returns rows it shouldn't. Too strict, and it returns nothing and raises no error. Neither case surfaces as an error, so tests are how you find out. - -Supabase runs database tests with [pgTAP](/docs/guides/database/extensions/pgtap) through the CLI. Test files are `.sql` files under `supabase/tests/`. - -### Anatomy of a policy test - -Each case sets an identity, runs one statement as that identity, and asserts the outcome. Three things decide whether the assertion means anything. - -**Identity.** Switch role and identity between cases with `set local role` and `set local request.jwt.claim.sub`, so each assertion runs as the user it describes. Without the switch, every case runs as the same role and proves nothing about access. - -**Denials.** A denied request doesn't always raise an error, so match the assertion to the way the denial happens: - -- A missing grant raises `42501`. Assert it with `throws_ok`. -- A `with check` violation raises `42501`. Assert it with `throws_ok`. -- A `using` clause that filters the target row out raises nothing. The update or delete matches zero rows instead. Assert that no row changed. - -**Allowed writes.** The absence of an error doesn't prove that anything changed. Add `returning` to the statement so one assertion covers both directions. An allowed write returns the changed row, and a write the policy filters out returns nothing. - -### Write and run the tests - -1. Create the tests directory and a test file: - - ```bash - mkdir -p supabase/tests - touch supabase/tests/profiles_rls.test.sql - ``` - -2. Write the tests. Cover `select`, `insert`, `update`, and `delete` twice each, once for a request the policy allows and once for a request it denies. Cover `anon` as well as `authenticated`. - -3. Run the suite: - - ```bash - supabase test db - ``` - -This example tests a `profiles` table where `authenticated` holds every privilege, `anon` holds none, and each user reads and writes only their own row: - -```sql supabase/tests/profiles_rls.test.sql -begin; -select plan(11); - --- Seed two users. The rows come later, through the policies under test. -insert into auth.users (id, email) -values - ('11111111-1111-1111-1111-111111111111', 'owner@example.com'), - ('22222222-2222-2222-2222-222222222222', 'other@example.com'); - --- Signed-out visitors hold no grant, so the request stops before any policy runs. -set local role anon; -select throws_ok( - $$select * from profiles$$, - '42501', - null, - 'anon cannot read profiles' -); -select throws_ok( - $$insert into profiles (id, user_id) - values (gen_random_uuid(), '11111111-1111-1111-1111-111111111111')$$, - '42501', - null, - 'anon cannot insert a profile' -); - --- The owner reads and writes their own row. -set local role authenticated; -set local request.jwt.claim.sub = '11111111-1111-1111-1111-111111111111'; -select results_eq( - $$insert into profiles (id, user_id, avatar_url) - values ( - gen_random_uuid(), - '11111111-1111-1111-1111-111111111111', - 'owner.png' - ) - returning avatar_url$$, - array['owner.png'], - 'the owner creates their own profile' -); -select results_eq( - $$select avatar_url from profiles$$, - array['owner.png'], - 'the owner reads their own profile' -); -select results_eq( - $$update profiles set avatar_url = 'updated.png' returning avatar_url$$, - array['updated.png'], - 'the owner updates their own profile' -); - --- The with check clause rejects the row, which raises. -select throws_ok( - $$insert into profiles (id, user_id) - values (gen_random_uuid(), '22222222-2222-2222-2222-222222222222')$$, - '42501', - null, - 'the owner cannot create a profile for someone else' -); +## Write policies that scale --- A signed-in stranger holds the grant, so the policy is what stops them. The --- using clause filters the row out, so these match nothing and raise nothing. -set local request.jwt.claim.sub = '22222222-2222-2222-2222-222222222222'; -select is_empty( - $$select * from profiles$$, - 'another user reads no profiles' -); -select is_empty( - $$update profiles set avatar_url = 'stolen.png' returning avatar_url$$, - 'another user updates no profiles' -); -select is_empty( - $$delete from profiles returning id$$, - 'another user deletes no profiles' -); - --- The row is still there, still holding the owner's value. -set local request.jwt.claim.sub = '11111111-1111-1111-1111-111111111111'; -select results_eq( - $$select avatar_url from profiles$$, - array['updated.png'], - 'the other user changed nothing' -); -select results_eq( - $$delete from profiles returning avatar_url$$, - array['updated.png'], - 'the owner deletes their own profile' -); - -select * from finish(); -rollback; -``` - -For CLI setup and more pgTAP helpers, see [Testing your database](/docs/guides/database/testing). - -## RLS performance recommendations - -Every authorization system has an impact on performance. Postgres evaluates a policy expression against each candidate row, so the cost scales with the rows a query scans. This matters most for queries that scan every row in a table, like many `select` operations, including those using limit, offset, and ordering. - -Based on a series of [tests](https://github.com/GaryAustin1/RLS-Performance), these are the recommendations for RLS: +Postgres evaluates a policy expression against each candidate row, so the cost scales with the rows a query scans. Three rules keep that cost from growing with your table. Apply all three to every policy you write. ### Add indexes @@ -732,12 +594,6 @@ on team_members using btree (user_id); ``` -#### Benchmarks - -| Test | Before (ms) | After (ms) | % Improvement | Change | -| --------------------------------------------------------------------------------------------- | ----------- | ---------- | ------------- | -------------------------------------------------------------------------------------------------------- | -| [test1-indexed](https://github.com/GaryAustin1/RLS-Performance/tree/main/tests/test1-indexed) | 171 | < 0.1 | 99.94% |
Before:
No index

After:
`user_id` indexed
| - ### Call functions with `select` You can use `select` statement to improve policies that use functions. For example, instead of this: @@ -764,44 +620,26 @@ You can only use this technique if the results of the query or function do not c -#### Benchmarks - -| Test | Before (ms) | After (ms) | % Improvement | Change | -| --------------------------------------------------------------------------------------------------------------------------------- | ----------- | ---------- | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| [test2a-wrappedSQL-uid]() | 179 | 9 | 94.97% |
Before:
`auth.uid() = user_id`

After:
`(select auth.uid()) = user_id`
| -| [test2b-wrappedSQL-isadmin]() | 11,000 | 7 | 99.94% |
Before:
`is_admin()` _table join_

After:
`(select is_admin())` _table join_
| -| [test2c-wrappedSQL-two-functions](https://github.com/GaryAustin1/RLS-Performance/tree/main/tests/test2c-wrappedSQL-two-functions) | 11,000 | 10 | 99.91% |
Before:
`is_admin() OR auth.uid() = user_id`

After:
`(select is_admin()) OR (select auth.uid() = user_id)`
| -| [test2d-wrappedSQL-sd-fun](https://github.com/GaryAustin1/RLS-Performance/tree/main/tests/test2d-wrappedSQL-sd-fun) | 178,000 | 12 | 99.993% |
Before:
`has_role() = role`

After:
(select has_role()) = role
| -| [test2e-wrappedSQL-sd-fun-array](https://github.com/GaryAustin1/RLS-Performance/tree/main/tests/test2e-wrappedSQL-sd-fun-array) | 173000 | 16 | 99.991% |
Before:
`team_id=any(user_teams())`

After:
team_id=any(array(select user_teams()))
| - -### Add filters to every query +### Specify roles in your policies -Policies are "implicit where clauses," so it's common to run `select` statements without any filters. This is a bad pattern for performance. Instead of doing this (JS client example): +Always name the role a policy applies to, using the `to` clause. Instead of this: -{/* prettier-ignore */} -```js -const { data } = supabase - .from('table') - .select() +```sql +create policy "rls_test_select" on rls_test +using ( auth.uid() = user_id ); ``` -You should always add a filter: +Use: -{/* prettier-ignore */} -```js -const { data } = supabase - .from('table') - .select() - .eq('user_id', userId) +```sql +create policy "rls_test_select" on rls_test +to authenticated +using ( (select auth.uid()) = user_id ); ``` -Even though this duplicates the contents of the Policy, Postgres can use the filter to construct a better query plan. - -#### Benchmarks +This prevents the policy `( (select auth.uid()) = user_id )` from running for any `anon` users, since the execution stops at the `to authenticated` step. -| Test | Before (ms) | After (ms) | % Improvement | Change | -| ------------------------------------------------------------------------------------------------- | ----------- | ---------- | ------------- | -------------------------------------------------------------------------------------------------------------------------------------- | -| [test3-addfilter](https://github.com/GaryAustin1/RLS-Performance/tree/main/tests/test3-addfilter) | 171 | 9 | 94.74% |
Before:
`auth.uid() = user_id`

After:
add `.eq` or `where` on `user_id`
| +These three rules keep policies correct as a table grows. To diagnose whether a policy is still the bottleneck, and to tune beyond these rules, see [Row Level Security performance](/docs/guides/database/postgres/row-level-security-performance). ### Use security definer functions @@ -850,79 +688,11 @@ A `security definer` function in an exposed schema is callable over the Data API -### Minimize joins - -You can often rewrite your Policies to avoid joins between the source and the target table. Instead, try to organize your policy to fetch all the relevant data from the target table into an array or set, then you can use an `IN` or `ANY` operation in your filter. - -For example, this is an example of a slow policy which joins the source `test_table` to the target `team_user`: - -```sql -create policy "rls_test_select" on test_table -to authenticated -using ( - (select auth.uid()) in ( - select user_id - from team_user - where team_user.team_id = team_id -- joins to the source "test_table.team_id" - ) -); -``` - -We can rewrite this to avoid this join, and instead select the filter criteria into a set: - -```sql -create policy "rls_test_select" on test_table -to authenticated -using ( - team_id in ( - select team_id - from team_user - where user_id = (select auth.uid()) -- no join - ) -); -``` - -In this case you can also consider [using a `security definer` function](#use-security-definer-functions) to bypass RLS on the join table: - - - -If the list exceeds 1000 items, a different approach may be needed or you may need to analyze the approach to ensure that the performance is acceptable. - - - -#### Benchmarks - -| Test | Before (ms) | After (ms) | % Improvement | Change | -| --------------------------------------------------------------------------------------------------- | ----------- | ---------- | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | -| [test5-fixed-join](https://github.com/GaryAustin1/RLS-Performance/tree/main/tests/test5-fixed-join) | 9,000 | 20 | 99.78% |
Before:
`auth.uid()` in table join on col

After:
col in table join on `auth.uid()`
| - -### Specify roles in your policies - -Always use the Role of inside your policies, specified by the `TO` operator. For example, instead of this query: - -```sql -create policy "rls_test_select" on rls_test -using ( auth.uid() = user_id ); -``` - -Use: - -```sql -create policy "rls_test_select" on rls_test -to authenticated -using ( (select auth.uid()) = user_id ); -``` - -This prevents the policy `( (select auth.uid()) = user_id )` from running for any `anon` users, since the execution stops at the `to authenticated` step. - -#### Benchmarks - -| Test | Before (ms) | After (ms) | % Improvement | Change | -| --------------------------------------------------------------------------------------------- | ----------- | ---------- | ------------- | -------------------------------------------------------------------------------------------------------------------------------- | -| [test6-To-role](https://github.com/GaryAustin1/RLS-Performance/tree/main/tests/test6-To-role) | 170 | < 0.1 | 99.78% |
Before:
No `TO` policy

After:
`TO authenticated` (anon accessing)
| - -## More resources +## Related content -- [Testing your database](/docs/guides/database/testing) -- [RLS Guide and Best Practices](https://github.com/orgs/supabase/discussions/14576) -- Community repo on testing RLS using [pgTAP and dbdev](https://github.com/usebasejump/supabase-test-helpers/tree/main) +- [Row Level Security performance](/docs/guides/database/postgres/row-level-security-performance): diagnose whether policies are your bottleneck, and tune ones that are already correct. +- [Advanced pgTAP testing](/docs/guides/local-development/testing/pgtap-extended): schema-wide RLS test helpers and a worked multi-tenant example. +- [Testing your database](/docs/guides/database/testing): the CLI test workflow that `supabase test db` runs. +- [Securing your API](/docs/guides/api/securing-your-api): grants, dedicated schemas, and pre-request checks around the Data API. +- [Column Level Security](/docs/guides/database/postgres/column-level-security): restrict access to individual columns. +- [`supabase-test-helpers`](https://github.com/usebasejump/supabase-test-helpers/tree/main): a community extension that adds user creation and role impersonation helpers to pgTAP. diff --git a/apps/docs/content/troubleshooting/interpreting-supabase-grafana-io-charts-MUynDR.mdx b/apps/docs/content/troubleshooting/interpreting-supabase-grafana-io-charts-MUynDR.mdx index 394a440fddb76..9efe9c4793daa 100644 --- a/apps/docs/content/troubleshooting/interpreting-supabase-grafana-io-charts-MUynDR.mdx +++ b/apps/docs/content/troubleshooting/interpreting-supabase-grafana-io-charts-MUynDR.mdx @@ -42,7 +42,7 @@ Excessive IO usage is highly problematic as it clarifies that your database is e - **Excessive and needless sequential scans:** poorly indexed tables force requests to scan disk ([guide to resolve](https://github.com/orgs/supabase/discussions/22449)) - **Too little cache**: There is not enough memory, so instead of reading data from the memory cache, it is accessed from disk ([guide to inspect](https://github.com/orgs/supabase/discussions/22449)) -- **Poorly optimized RLS policies**: RLS that rely heavily on joins are more likely to hit disk. If possible, they should optimized ([RLS best practice guide](/docs/guides/database/postgres/row-level-security#rls-performance-recommendations)) +- **Poorly optimized RLS policies**: RLS that rely heavily on joins are more likely to hit disk. If possible, they should optimized ([RLS performance guide](/docs/guides/database/postgres/row-level-security-performance)) - **Excessive bloat**: This is the least likely to cause major issues, but bloat can take up space, preventing data on disk from being placed in the same locality. This can force the database to scan more pages than necessary. ([explainer guide](/blog/postgres-bloat)) - **Uploading high amounts of data:** temporarily increase compute add-on size for the duration of the uploads - **Insufficient memory**: Sometimes an inadequate amount of memory forces queries to hit disk instead of the memory cache. Address memory issues ([guide](https://github.com/orgs/supabase/discussions/27021)) can reduce disk strain. From 2bc6144aecfab2de97c5210d14aee85a34565535 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <209825114+claude[bot]@users.noreply.github.com> Date: Wed, 19 Aug 2026 14:23:54 -0600 Subject: [PATCH 3/4] fix(studio): guard unguarded requester.name reads on the OAuth authorize and apps pages (#49267) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _Requested by **Ali Waseem** · [Slack thread](https://supabase.slack.com/archives/C063LNYJJKS/p1787146439389169)_ ## I have read the [CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md) file. YES ## What kind of change does this PR introduce? Bug fix. ## What is the current behavior? Opening `/authorize` for an OAuth app whose `name` the platform API omitted crashed the entire page with `TypeError: Cannot read properties of undefined (reading 'toLowerCase')` ([SUPABASE-APP-K7E](https://supabase.sentry.io/issues/7679644991/)). The user got a full-page error instead of a consent screen, and could neither authorize nor decline. The same class of crash hit the project-level OAuth apps list ([SUPABASE-APP-JB1](https://supabase.sentry.io/issues/7502074939/)). Typing in the search box called `.toLowerCase()` on `client_name` for every app, so one app registered without a name broke search for the whole list. The project-claim page crashed the same way, reading the first character of the name for the fallback avatar. ## What is the new behavior? The trusted-partner helpers treat a missing name as "no trusted partner matched" and return `null`. The apps filter treats a missing name or client ID as "does not match the search string". The claim page falls back to a placeholder initial instead of indexing into `undefined`. The authorize page now renders normally, minus the optional partner-impersonation caution, which cannot be evaluated without a name. Three changes: - `apps/studio/components/interfaces/Organization/OAuthApps/OAuthApps.utils.ts` — `findTrustedPartnerByName` accepts `string | null | undefined` and returns `null` early on a falsy name; `getOAuthImpersonationWarning`'s `name` param widened to match (its existing `if (!namedPartner) return null` already handles the rest). - `apps/studio/components/interfaces/Auth/OAuthApps/oauthApps.utils.ts` — `filterOAuthApps` optional-chains `client_name` and `client_id` before `.toLowerCase()`, defaulting each match to `false`. - `apps/studio/components/interfaces/Organization/ProjectClaim/confirm.tsx` — `{requester.name?.[0] ?? '?'}` for the fallback avatar initial. Each is a separate commit so any one can be dropped independently. ## Additional context ### Root cause, not fixed here `apps/studio/data/api-authorization/api-authorization-query.ts:37` returns `data as ApiAuthorizationResponse`, an unchecked cast with no runtime validation, even though the openapi-fetch client already types the endpoint from the generated schema. Both the generated `GetOAuthAuthorizationResponse` and the hand-written local type declare `name: string` as required, so this was invisible to TypeScript. The durable fix is to derive the type from the schema and drop the cast, which is the house pattern elsewhere in `apps/studio/data`, and to correct the OpenAPI spec at source if the API can legitimately omit `name`. Left out deliberately to keep this cherry-pickable. ### Not in scope `requester.scopes` is optional in the schema but required in the local type, and is read unguarded in several places. Defaulting it to `[]` would tell a user an app requested no permissions on a live consent screen, so it needs a product decision rather than a drive-by guard. ### Testing No local checks were run. This clone has no `node_modules` and `pnpm install` is blocked in the environment, so `npm run build`, typecheck, lint, Prettier and tests were all left to CI. Please treat CI as the verification for this PR. There is also a coverage gap worth noting: `apps/studio/tests/components/ApiAuthorization.test.tsx:48-62` hardcodes `name: 'Test App'` in `createMockAuthResponse`, and no test omits the field, which is why none of these crashes were caught. --- _Generated by [Claude Code](https://claude.ai/code/session_01P489vrPdHcJfMfzCGM9rZ5)_ --------- Co-authored-by: Claude Co-authored-by: Ali Waseem --- .../interfaces/Auth/OAuthApps/oauthApps.utils.ts | 4 ++-- .../interfaces/Organization/OAuthApps/OAuthApps.utils.ts | 8 ++++++-- .../interfaces/Organization/ProjectClaim/confirm.tsx | 2 +- 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/apps/studio/components/interfaces/Auth/OAuthApps/oauthApps.utils.ts b/apps/studio/components/interfaces/Auth/OAuthApps/oauthApps.utils.ts index f1f6ad52c6e46..94d732d0601e7 100644 --- a/apps/studio/components/interfaces/Auth/OAuthApps/oauthApps.utils.ts +++ b/apps/studio/components/interfaces/Auth/OAuthApps/oauthApps.utils.ts @@ -27,8 +27,8 @@ export function filterOAuthApps({ // Filter by search string if (searchString) { const searchLower = searchString.toLowerCase() - const matchesName = app.client_name.toLowerCase().includes(searchLower) - const matchesClientId = app.client_id.toLowerCase().includes(searchLower) + const matchesName = app.client_name?.toLowerCase().includes(searchLower) ?? false + const matchesClientId = app.client_id?.toLowerCase().includes(searchLower) ?? false if (!matchesName && !matchesClientId) { return false } diff --git a/apps/studio/components/interfaces/Organization/OAuthApps/OAuthApps.utils.ts b/apps/studio/components/interfaces/Organization/OAuthApps/OAuthApps.utils.ts index e8f942cf7a0ca..13be7546cb2bf 100644 --- a/apps/studio/components/interfaces/Organization/OAuthApps/OAuthApps.utils.ts +++ b/apps/studio/components/interfaces/Organization/OAuthApps/OAuthApps.utils.ts @@ -86,7 +86,11 @@ export function findTrustedPartnerByRedirectUri( ) } -export function findTrustedPartnerByName(name: string): TrustedOAuthPartner | null { +export function findTrustedPartnerByName( + name: string | null | undefined +): TrustedOAuthPartner | null { + if (!name) return null + const searchable = name.toLowerCase() return ( TRUSTED_OAUTH_PARTNERS.find((partner) => @@ -154,7 +158,7 @@ export function getOAuthImpersonationWarning({ name, redirectUri, }: { - name: string + name: string | null | undefined redirectUri: string | null | undefined }): OAuthImpersonationWarning | null { const namedPartner = findTrustedPartnerByName(name) diff --git a/apps/studio/components/interfaces/Organization/ProjectClaim/confirm.tsx b/apps/studio/components/interfaces/Organization/ProjectClaim/confirm.tsx index c19aa0349c853..fe125a2f99898 100644 --- a/apps/studio/components/interfaces/Organization/ProjectClaim/confirm.tsx +++ b/apps/studio/components/interfaces/Organization/ProjectClaim/confirm.tsx @@ -85,7 +85,7 @@ export const ProjectClaimConfirm = ({ }} > {!requester.icon && ( -

{requester.name[0]}

+

{requester.name?.[0] ?? '?'}

)} From 6368f00ca0ee80574774b3345033b82ffac13a78 Mon Sep 17 00:00:00 2001 From: Miranda Limonczenko Date: Wed, 19 Aug 2026 14:51:06 -0700 Subject: [PATCH 4/4] docs(database): restructure the RLS guide by information type (#49017) ## Problem The guide alternated between context, procedure, and reference on almost every heading. A reader who wanted to write a policy passed through four context or reference sections to reach one. A reader who wanted the model had to skip three procedures. ## Solution - Group into three sections by information type: `Understand Row Level Security`, `Secure a table with RLS`, and `RLS reference`, with a navigation intro. - Merge the four policy sections. They repeated the same setup block, burying the clause that differed. One setup block now precedes four short policy examples. - Move the auto-enable recipe into `event-triggers.mdx`, whose stub section's entire body was a link back here. - Relocate the stranded `auth.uid()` caution into the `auth.uid()` reference. - Lift the revoke-and-grant procedure out of the danger admonition and merge it with the two other places that taught `enable row level security`. - Point the Grafana IO chart entry at the performance guide. Its `#rls-performance-recommendations` anchor went away when tuning split out in #49016. 765 lines to 582. 30 headings to 25. Headings are demoted rather than renamed wherever anything links to them. Every inbound anchor in the repo still resolves; the only one removed, `#auto-enable-rls-for-new-tables`, was referenced solely by the `event-triggers.mdx` stub this PR replaces. ## Note on the history Rebuilt from `master` after #49011, #49015, and #49016 merged. The branch previously carried those 10 commits plus rebase churn against them. Rebasing naively would have reverted review feedback from #49016 (`70fa812`), which removed the benchmarks table and the "This guide" opener from the performance guide. Those are deliberately not restored here. The only changes to that file are two missing `await`s and a join predicate that was a tautology while unqualified. The three PRs stacked on this one (#49268, #49269, #49270) have been rebased onto the new base. ## Manual testing 1. Open the [Row Level Security guide](https://docs-git-docs-rls-restructure-supabase.vercel.app/docs/guides/database/postgres/row-level-security) on the preview. Three top-level sections appear in the table of contents. 2. Select each link in the intro. All three jump to their section. 3. Open [Event triggers](https://docs-git-docs-rls-restructure-supabase.vercel.app/docs/guides/database/postgres/event-triggers). The auto-enable section holds the full recipe instead of a link. 4. Open the [performance guide](https://docs-git-docs-rls-restructure-supabase.vercel.app/docs/guides/database/postgres/row-level-security-performance). No benchmarks table, and the three bullets at the top link into the RLS guide. ## Summary by CodeRabbit * **Documentation** * Reworked the Row Level Security guide with clearer guidance on grants, policies, permissions, performance, testing, views, and secure functions. * Added a complete example for automatically enabling RLS on newly created public tables. * Improved SQL examples and clarified table references in RLS performance guidance. * Corrected grammar in the Grafana chart troubleshooting documentation. --------- Co-authored-by: Claude Opus 5 --- .../database/postgres/event-triggers.mdx | 43 +- .../row-level-security-performance.mdx | 6 +- .../database/postgres/row-level-security.mdx | 582 +++++++----------- ...ting-supabase-grafana-io-charts-MUynDR.mdx | 2 +- 4 files changed, 268 insertions(+), 365 deletions(-) diff --git a/apps/docs/content/guides/database/postgres/event-triggers.mdx b/apps/docs/content/guides/database/postgres/event-triggers.mdx index 682b7d042a573..914fb27bbf368 100644 --- a/apps/docs/content/guides/database/postgres/event-triggers.mdx +++ b/apps/docs/content/guides/database/postgres/event-triggers.mdx @@ -55,7 +55,48 @@ EXECUTE FUNCTION dont_drop_function(); ### Example trigger function - auto enable Row Level Security -See how to [auto enable RLS for new tables](/docs/guides/database/postgres/row-level-security#auto-enable-rls-for-new-tables). +If you want [Row Level Security](/docs/guides/database/postgres/row-level-security) enabled automatically for new tables, create an event trigger that runs after table creation and calls `ALTER TABLE ... ENABLE ROW LEVEL SECURITY` on each newly created table. + +```sql +CREATE OR REPLACE FUNCTION rls_auto_enable() +RETURNS EVENT_TRIGGER +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = pg_catalog +AS $$ +DECLARE + cmd record; +BEGIN + FOR cmd IN + SELECT * + FROM pg_event_trigger_ddl_commands() + WHERE command_tag IN ('CREATE TABLE', 'CREATE TABLE AS', 'SELECT INTO') + AND object_type IN ('table','partitioned table') + LOOP + IF cmd.schema_name IS NOT NULL AND cmd.schema_name IN ('public') AND cmd.schema_name NOT IN ('pg_catalog','information_schema') AND cmd.schema_name NOT LIKE 'pg_toast%' AND cmd.schema_name NOT LIKE 'pg_temp%' THEN + BEGIN + EXECUTE format('alter table if exists %s enable row level security', cmd.object_identity); + RAISE LOG 'rls_auto_enable: enabled RLS on %', cmd.object_identity; + EXCEPTION + WHEN OTHERS THEN + RAISE LOG 'rls_auto_enable: failed to enable RLS on %', cmd.object_identity; + RAISE; + END; + ELSE + RAISE LOG 'rls_auto_enable: skip % (either system schema or not in enforced list: %.)', cmd.object_identity, cmd.schema_name; + END IF; + END LOOP; +END; +$$; + +DROP EVENT TRIGGER IF EXISTS ensure_rls; +CREATE EVENT TRIGGER ensure_rls +ON ddl_command_end +WHEN TAG IN ('CREATE TABLE', 'CREATE TABLE AS', 'SELECT INTO') +EXECUTE FUNCTION rls_auto_enable(); +``` + +Note that this applies to tables created after the trigger is installed. Existing tables still need RLS enabled manually. ### Event trigger Functions and firing events diff --git a/apps/docs/content/guides/database/postgres/row-level-security-performance.mdx b/apps/docs/content/guides/database/postgres/row-level-security-performance.mdx index af0a3d11c7ed8..37c124cc8c2ee 100644 --- a/apps/docs/content/guides/database/postgres/row-level-security-performance.mdx +++ b/apps/docs/content/guides/database/postgres/row-level-security-performance.mdx @@ -91,7 +91,7 @@ Policies are implicit `where` clauses, so it's common to run `select` statements {/* prettier-ignore */} ```js -const { data } = supabase +const { data } = await supabase .from('table') .select() ``` @@ -100,7 +100,7 @@ Always add a filter: {/* prettier-ignore */} ```js -const { data } = supabase +const { data } = await supabase .from('table') .select() .eq('user_id', userId) @@ -121,7 +121,7 @@ using ( (select auth.uid()) in ( select user_id from team_user - where team_user.team_id = team_id -- joins to the source "test_table.team_id" + where team_user.team_id = test_table.team_id -- joins to the source table ) ); ``` diff --git a/apps/docs/content/guides/database/postgres/row-level-security.mdx b/apps/docs/content/guides/database/postgres/row-level-security.mdx index ea7fbf7611469..d953048136f4a 100644 --- a/apps/docs/content/guides/database/postgres/row-level-security.mdx +++ b/apps/docs/content/guides/database/postgres/row-level-security.mdx @@ -7,47 +7,36 @@ subtitle: 'Secure your data using Postgres Row Level Security.' Postgres [Row Level Security (RLS)](https://www.postgresql.org/docs/current/ddl-rowsecurity.html) gives you granular authorization rules that run inside the database. -## Row Level Security in Supabase - -A table in an exposed schema without RLS is readable and writable by anyone with your publishable key. RLS must always be enabled on any table stored in an exposed schema. By default, this is the `public` schema. - -RLS is enabled by default on tables created with the Table Editor in the dashboard. If you create a table in raw SQL or with the SQL editor, enable RLS yourself and leave each role only the privileges it needs: - -```sql --- Take back the privileges granted automatically to client roles. -revoke all on table . from anon, authenticated; - --- Grant back only what each role needs. -grant select on table . to anon, authenticated; -grant insert, update, delete on table . to authenticated; +A table in an exposed schema without RLS is readable and writable by any role with a grant on it. Enable RLS on every table in an exposed schema. On projects that still grant `anon` and `authenticated` by default, revoke those grants. Adding policies doesn't remove them. -alter table . -enable row level security; -``` + -Policies alone don't do this. See [Grants and policies](#grants-and-policies). +Use the guide in three parts: - +- [Understand Row Level Security](#understand-row-level-security) explains how grants and policies combine to control access. +- [Secure a table with RLS](#secure-a-table-with-rls) is the procedure to follow for every table in an exposed schema. +- [RLS reference](#rls-reference) documents the helper functions and patterns you use inside a policy expression. -You write RLS rules in SQL, so a rule can express whatever access logic your app needs. Combine RLS with [Supabase Auth](/docs/guides/auth) for end-to-end user security from the browser to the database. +Read the first section when you're deciding how to model access. Go directly to the second section when you're ready to secure a table. -RLS is a Postgres primitive and can provide "[defense in depth]()" to protect your data from malicious actors even when accessed through third-party tooling. +## Understand Row Level Security -## Policies +### What a policy does [Policies](https://www.postgresql.org/docs/current/sql-createpolicy.html) are Postgres's rule engine. Each policy is attached to a table, and the policy is executed every time a table is accessed. -Think of a policy as adding a `WHERE` clause to every query. For example, a policy like this: +Think of a policy as adding a `WHERE` clause to every query. A policy like this: ```sql create policy "Individuals can view their own todos." on todos for select +to authenticated using ( (select auth.uid()) = user_id ); ``` -That policy translates to this whenever a user tries to select from the todos table: +That policy translates to this whenever a user selects from the todos table: ```sql select * @@ -56,17 +45,9 @@ where auth.uid() = todos.user_id; -- Policy is implicitly added. ``` -## Enabling Row Level Security - -You can enable RLS for any table using the `enable row level security` clause: - -```sql -alter table "table_name" enable row level security; -``` - -Once you have enabled RLS, no data will be accessible via the [API](/docs/guides/api) when using a publishable key, until you create policies. +You write RLS rules in SQL, so a rule can express whatever access logic your app needs. Because RLS is a Postgres primitive, it also protects your data when it is reached through third-party tooling, which is what makes it "[defense in depth]()". Combine RLS with [Supabase Auth](/docs/guides/auth) for end-to-end user security from the browser to the database. -## Grants and policies +### Grants and policies Postgres runs two checks before a client touches a table. Grants decide whether a role can run an operation on the table at all. Policies decide which rows that operation applies to. Set both for every table you expose. @@ -80,111 +61,11 @@ On existing projects, a new table in `public` starts with every privilege alread Adding policies doesn't take those grants back. A table protected only by policies still hands `anon` an insert path if you never revoke the grant. -A missing grant raises a `42501` error before any policy runs. When a request fails that your policy should allow, check the grants before you change the policy. - -### Set the grants for a table - -Run these statements in the [SQL Editor](/dashboard/project/_/sql/new) for a one-off change, or in a [migration](/docs/guides/deployment/database-migrations) to keep the change reproducible across environments. Grants and RLS belong in the same migration. - -Set the grants to match what each role does in your app: - -1. Revoke the automatic grants from both client roles. - - ```sql - revoke all on table public.reports from anon, authenticated; - ``` - -2. Grant back only the privileges the role needs. - - ```sql - -- Signed-in users manage reports. Signed-out visitors get nothing. - grant select, insert, update, delete on table public.reports to authenticated; - ``` - -3. Enable RLS on the table and write policies that decide which rows each role reaches. - -For data that clients read but never write, such as a feed a backend job populates, grant no writes in step 2: - -```sql -revoke all on table public.weather_readings from anon, authenticated; -grant select on table public.weather_readings to anon, authenticated; -``` - -To stop new tables from receiving the automatic grants in the first place, see [Revoke default privileges](/docs/guides/api/securing-your-api#revoke-default-privileges). - -Write the tests for this table in the same change. See [Test your policies](#test-your-policies). - -## Auto-enable RLS for new tables - -If you want RLS enabled automatically for new tables, you can create an event trigger that runs after table creation. This uses a Postgres [event trigger](/docs/guides/database/postgres/event-triggers) to call `ALTER TABLE ... ENABLE ROW LEVEL SECURITY` on each newly created table. - -```sql -CREATE OR REPLACE FUNCTION rls_auto_enable() -RETURNS EVENT_TRIGGER -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = pg_catalog -AS $$ -DECLARE - cmd record; -BEGIN - FOR cmd IN - SELECT * - FROM pg_event_trigger_ddl_commands() - WHERE command_tag IN ('CREATE TABLE', 'CREATE TABLE AS', 'SELECT INTO') - AND object_type IN ('table','partitioned table') - LOOP - IF cmd.schema_name IS NOT NULL AND cmd.schema_name IN ('public') AND cmd.schema_name NOT IN ('pg_catalog','information_schema') AND cmd.schema_name NOT LIKE 'pg_toast%' AND cmd.schema_name NOT LIKE 'pg_temp%' THEN - BEGIN - EXECUTE format('alter table if exists %s enable row level security', cmd.object_identity); - RAISE LOG 'rls_auto_enable: enabled RLS on %', cmd.object_identity; - EXCEPTION - WHEN OTHERS THEN - RAISE LOG 'rls_auto_enable: failed to enable RLS on %', cmd.object_identity; - END; - ELSE - RAISE LOG 'rls_auto_enable: skip % (either system schema or not in enforced list: %.)', cmd.object_identity, cmd.schema_name; - END IF; - END LOOP; -END; -$$; - -DROP EVENT TRIGGER IF EXISTS ensure_rls; -CREATE EVENT TRIGGER ensure_rls -ON ddl_command_end -WHEN TAG IN ('CREATE TABLE', 'CREATE TABLE AS', 'SELECT INTO') -EXECUTE FUNCTION rls_auto_enable(); -``` - -Note that this applies to tables created after the trigger is installed. Existing tables still need RLS enabled manually. - - - -When a request is made without an authenticated user (e.g., no access token is provided or the session has expired), `auth.uid()` returns `null`. - -This means that a policy like: - -```sql -USING (auth.uid() = user_id) -``` - -will silently fail for unauthenticated users, because: - -```sql -null = user_id -``` - -is always false in SQL. - -To avoid confusion and make your intention clear, we recommend explicitly checking for authentication: - -```sql -USING (auth.uid() IS NOT NULL AND auth.uid() = user_id) -``` +Not every project grants these automatically. See [Default privileges](/docs/guides/api/securing-your-api#default-privileges). Grant each role only the operations it needs. - +A missing grant raises a `42501` error before any policy runs. When a request fails that your policy should allow, check the grants before you change the policy. To set them, see [Lock down the table](#lock-down-the-table). -## Authenticated and unauthenticated roles +### Authenticated and unauthenticated roles Supabase maps every request to one of the roles: @@ -213,99 +94,110 @@ Using the `anon` Postgres role is different from an [anonymous user](/docs/guide -## Creating policies +A policy that reads `to anon using ( true )` grants every unauthenticated visitor read access to every row the role can already reach through grants. Use it only for data that is meant to be public. -Policies are SQL logic that you attach to a Postgres table. You can attach as many policies as you want to each table. +### Views and RLS -Supabase provides some [helpers](#helper-functions) that simplify RLS if you're using Supabase Auth. The examples below use these helpers. +Views bypass RLS by default because they are usually created with the `postgres` user. This is a feature of Postgres, which automatically creates views with `security definer`. A view over a protected table hands out every row its policies were meant to withhold, so a view needs the same attention as a table. To create one safely, see [Expose a view safely](#expose-a-view-safely). -### SELECT policies +## Secure a table with RLS -You can specify select policies with the `using` clause. +Follow these steps for every table in an exposed schema. -Say you have a table called `profiles` in the public schema and you want to enable read access to everyone. +### Lock down the table -```sql --- 1. Create table -create table profiles ( - id uuid primary key, - user_id uuid references auth.users, - avatar_url text -); +Run these statements in the [SQL Editor](/dashboard/project/_/sql/new) for a one-off change, or in a [migration](/docs/guides/deployment/database-migrations) to keep the change reproducible across environments. Grants and RLS belong in the same migration. --- 2. Enable RLS -alter table profiles enable row level security; +Enable RLS, then set the grants to match what each role does in your app: --- 3. Create Policy -create policy "Public profiles are visible to everyone." -on profiles for select -to anon -- the Postgres Role (recommended) -using ( true ); -- the actual Policy -``` +1. Enable RLS on the table. + + ```sql + alter table public.reports enable row level security; + ``` + + Once RLS is enabled, no data is accessible through the [API](/docs/guides/api) when using a publishable key, until you create policies. + +2. Revoke any existing grants from both client roles. -Alternatively, if you only wanted users to be able to see their own profiles: + ```sql + revoke all on table public.reports from anon, authenticated; + ``` + +3. Grant back only the privileges the role needs. + + ```sql + -- Signed-in users manage reports. Signed-out visitors get nothing. + grant select, insert, update, delete on table public.reports to authenticated; + ``` + +Data that clients read but never write, such as a feed a backend job populates, gets no write grant at all: ```sql -create policy "User can see their own profile only." -on profiles for select -to authenticated -using ( (select auth.uid()) = user_id ); +revoke all on table public.weather_readings from anon, authenticated; +grant select on table public.weather_readings to anon, authenticated; ``` -### INSERT policies +If new tables still receive automatic grants, see [Revoke default privileges](/docs/guides/api/securing-your-api#revoke-default-privileges). To enable RLS automatically on every new table, see [Event triggers](/docs/guides/database/postgres/event-triggers). + +Write the tests for this table in the same change. See [Test your policies](#test-your-policies). -You can specify insert policies with the `with check` clause. The `with check` expression ensures that any new row data adheres to the policy constraints. +### Write a policy for each operation -Say you have a table called `profiles` in the public schema and you only want users to create a profile for themselves. In that case, check that their user ID matches the value they are trying to insert: +Write a separate policy for `select`, `insert`, `update`, and `delete`. Postgres does not accept multiple operations in one `for` clause, and a `for all` policy hides which operation each rule was meant to cover. + +These examples use a `profiles` table where each user manages only their own row: ```sql --- 1. Create table create table profiles ( id uuid primary key, user_id uuid references auth.users, avatar_url text ); --- 2. Enable RLS alter table profiles enable row level security; --- 3. Create Policy -create policy "Users can create a profile." -on profiles for insert -to authenticated -- the Postgres Role (recommended) -with check ( (select auth.uid()) = user_id ); -- the actual Policy +revoke all on table profiles from anon, authenticated; +grant select, insert, update, delete on table profiles to authenticated; ``` -### UPDATE policies +Supabase provides [helper functions](#helper-functions) that simplify RLS if you are using Supabase Auth. `auth.uid()` returns the ID of the user making the request. -You can specify update policies by combining both the `using` and `with check` expressions. +#### SELECT policies -The `using` clause represents the condition that must be true for the update to be allowed, and `with check` clause ensures that the updates made adhere to the policy constraints. +You can specify select policies with the `using` clause. + +```sql +create policy "Users can view their own profile." +on profiles for select +to authenticated +using ( (select auth.uid()) = user_id ); +``` -Say you have a table called `profiles` in the public schema and you only want users to update their own profile. +#### INSERT policies -You can create a policy where the `using` clause checks if the user owns the profile being updated. And the `with check` clause ensures that, in the resultant row, users do not change the `user_id` to a value that is not equal to their User ID, maintaining that the modified profile still meets the ownership condition. +You can specify insert policies with the `with check` clause. The `with check` expression ensures that any new row adheres to the policy constraints, so a user cannot create a row that belongs to someone else. ```sql --- 1. Create table -create table profiles ( - id uuid primary key, - user_id uuid references auth.users, - avatar_url text -); +create policy "Users can create their own profile." +on profiles for insert +to authenticated +with check ( (select auth.uid()) = user_id ); +``` --- 2. Enable RLS -alter table profiles enable row level security; +#### UPDATE policies + +You can specify update policies by combining the `using` and `with check` expressions. The `using` clause decides which existing rows can be updated. The `with check` clause decides what the resulting row is allowed to look like, which stops a user from reassigning `user_id` to someone else. --- 3. Create Policy +```sql create policy "Users can update their own profile." on profiles for update -to authenticated -- the Postgres Role (recommended) -using ( (select auth.uid()) = user_id ) -- checks if the existing row complies with the policy expression -with check ( (select auth.uid()) = user_id ); -- checks if the new row complies with the policy expression +to authenticated +using ( (select auth.uid()) = user_id ) -- checks the existing row +with check ( (select auth.uid()) = user_id ); -- checks the resulting row ``` -If no `with check` expression is defined, then the `using` expression will be used both to determine which rows are visible (normal USING case) and which new rows will be allowed to be added (WITH CHECK case). +If no `with check` expression is defined, the `using` expression decides both which rows are visible and which new rows are allowed. @@ -313,115 +205,110 @@ To perform an `UPDATE` operation, a corresponding [`SELECT` policy](#select-poli -### DELETE policies +#### DELETE policies You can specify delete policies with the `using` clause. -Say you have a table called `profiles` in the public schema and you only want users to be able to delete their own profile: - ```sql --- 1. Create table -create table profiles ( - id uuid primary key, - user_id uuid references auth.users, - avatar_url text -); - --- 2. Enable RLS -alter table profiles enable row level security; - --- 3. Create Policy -create policy "Users can delete a profile." +create policy "Users can delete their own profile." on profiles for delete -to authenticated -- the Postgres Role (recommended) -using ( (select auth.uid()) = user_id ); -- the actual Policy +to authenticated +using ( (select auth.uid()) = user_id ); ``` -### Views +### Specify roles in your policies -Views bypass RLS by default because they are usually created with the `postgres` user. This is a feature of Postgres, which automatically creates views with `security definer`. +Always name the role a policy applies to, using the `to` clause. Instead of this: + +```sql +create policy "rls_test_select" on rls_test +using ( auth.uid() = user_id ); +``` -In Postgres 15 and above, you can make a view obey the RLS policies of the underlying tables when invoked by `anon` and `authenticated` roles by setting `security_invoker = true`. +Use: ```sql -create view -with(security_invoker = true) -as select +create policy "rls_test_select" on rls_test +to authenticated +using ( (select auth.uid()) = user_id ); ``` -In older versions of Postgres, protect your views by revoking access from the `anon` and `authenticated` roles, or by putting them in an unexposed schema. +This prevents the policy `( (select auth.uid()) = user_id )` from running for any `anon` users, since the execution stops at the `to authenticated` step. -## Helper functions +These three rules keep policies correct as a table grows. For the measured impact and for tuning beyond them, see [Row Level Security performance](/docs/guides/database/postgres/row-level-security-performance). -Supabase provides some helper functions that make it easier to write Policies. +### Add indexes -### `auth.uid()` +Add an [index](/docs/guides/database/postgres/indexes) on every column your policies filter on. Postgres evaluates the policy against each candidate row, so an unindexed filter column turns a read into a sequential scan. For a policy like this: -Returns the ID of the user making the request. +```sql +create policy "rls_test_select" on test_table +to authenticated +using ( (select auth.uid()) = user_id ); +``` -### `auth.jwt()` +You can add an index like: - +```sql +create index userid +on test_table +using btree (user_id); +``` -Not all information present in the JWT should be used in RLS policies. For instance, creating an RLS policy that relies on the `user_metadata` claim can create security issues in your application as this information can be modified by authenticated end users. +A column counts as indexed only when it comes first in a `btree` index. Postgres can't use a multi-column index to filter on a column that isn't the leading one, so a composite primary key indexes its first column and no others. A membership table keyed on `(team_id, user_id)` has no index on `user_id`: - +```sql +create table team_members ( + team_id uuid references teams (id), + user_id uuid references auth.users (id), + primary key (team_id, user_id) +); -Returns the JWT of the user making the request. Anything that you store in the user's `raw_app_meta_data` column or the `raw_user_meta_data` column will be accessible using this function. It's important to know the distinction between these two: +-- The primary key covers team_id. A policy filtering on user_id needs its own index. +create index team_members_user_id_idx +on team_members +using btree (user_id); +``` -- `raw_user_meta_data` - can be updated by the authenticated user using the `supabase.auth.update()` function. It is not a good place to store authorization data. -- `raw_app_meta_data` - cannot be updated by the user, so it's a good place to store authorization data. +### Call functions with `select` -The `auth.jwt()` function is extremely versatile. For example, if you store some team data inside `app_metadata`, you can use it to determine whether a particular user belongs to a team. For example, if this was an array of IDs: +You can use `select` statement to improve policies that use functions. For example, instead of this: ```sql -create policy "User is in team" -on my_table +create policy "rls_test_select" on test_table to authenticated -using ( team_id in (select auth.jwt() -> 'app_metadata' -> 'teams')); +using ( auth.uid() = user_id ); ``` - - -Keep in mind that a JWT is not always "fresh". In the example above, even if you remove a user from a team and update the `app_metadata` field, that will not be reflected using `auth.jwt()` until the user's JWT is refreshed. - -Also, if you are using Cookies for Auth, then you must be mindful of the JWT size. Some browsers are limited to 4096 bytes for each cookie, and so the total size of your JWT should be small enough to fit inside this limitation. - - - -### MFA - -The `auth.jwt()` function can be used to check for [Multi-Factor Authentication](/docs/guides/auth/auth-mfa#enforce-rules-for-mfa-logins). For example, you could restrict a user from updating their profile unless they have at least 2 levels of authentication (Assurance Level 2): +You can do: ```sql -create policy "Restrict updates." -on profiles -as restrictive -for update -to authenticated using ( - (select auth.jwt()->>'aal') = 'aal2' -); +create policy "rls_test_select" on test_table +to authenticated +using ( (select auth.uid()) = user_id ); ``` -## Bypassing Row Level Security - -Supabase provides special "Service" keys, which can be used to bypass RLS. These should never be used in the browser or exposed to customers, but they are useful for administrative tasks. +This method works well for JWT functions like `auth.uid()` and `auth.jwt()` as well as `security definer` Functions. Wrapping the function causes an `initPlan` to be run by the Postgres optimizer, which allows it to "cache" the results per-statement, rather than calling the function on each row. - + -A Service Key bypasses RLS only when the request carries no user access token. If the request carries one, it runs under the RLS policies of that signed-in user, even when the client library was initialized with a Service Key. +You can only use this technique if the results of the query or function do not change based on the row data. -You can also create new [Postgres Roles](/docs/guides/database/postgres/roles) which can bypass Row Level Security using the "bypass RLS" privilege: +### Expose a view safely + +In Postgres 15 and above, make a view obey the RLS policies of its underlying tables when invoked by `anon` and `authenticated` by setting `security_invoker = true`. ```sql -alter role "role_name" with bypassrls; +create view +with(security_invoker = true) +as select ``` -This can be useful for system-level access. **Never** share login credentials for any Postgres Role with this privilege. +In older versions of Postgres, protect your views by revoking access from the `anon` and `authenticated` roles, or by putting them in an unexposed schema. -## Test your policies +### Test your policies We recommend writing tests for every policy, in the same change that sets the grants and creates the policies. Tests are a fundamental part of a secure setup, and they give you a repeatable way to prove a policy behaves the way you intended. @@ -429,7 +316,7 @@ A wrong policy fails quietly. Too permissive, and a query returns rows it should Supabase runs database tests with [pgTAP](/docs/guides/database/extensions/pgtap) through the CLI. Test files are `.sql` files under `supabase/tests/`. -### Anatomy of a policy test +#### Anatomy of a policy test Each case sets an identity, runs one statement as that identity, and asserts the outcome. Three things decide whether the assertion means anything. @@ -443,16 +330,17 @@ Each case sets an identity, runs one statement as that identity, and asserts the **Allowed writes.** The absence of an error doesn't prove that anything changed. Add `returning` to the statement so one assertion covers both directions. An allowed write returns the changed row, and a write the policy filters out returns nothing. -### Write and run the tests +#### Write and run the tests -1. Create the tests directory and a test file: +1. Create a test file: ```bash - mkdir -p supabase/tests - touch supabase/tests/profiles_rls.test.sql + supabase test new profiles_rls ``` -2. Write the tests. Cover `select`, `insert`, `update`, and `delete` twice each, once for a request the policy allows and once for a request it denies. Cover `anon` as well as `authenticated`. +2. Write the tests. Cover `select`, `insert`, `update`, and `delete` twice each, once for a request the policy allows and once for a request it denies, for `anon` as well as `authenticated`. + + [`supabase-test-helpers`](https://github.com/usebasejump/supabase-test-helpers/tree/main) removes most of the setup below. It adds `tests.create_supabase_user()`, `tests.authenticate_as()`, and `tests.rls_enabled()`, so you don't hand-roll user seeding or role switching. See [Advanced pgTAP testing](/docs/guides/local-development/testing/pgtap-extended) for schema-wide assertions and a worked multi-tenant example. 3. Run the suite: @@ -460,19 +348,18 @@ Each case sets an identity, runs one statement as that identity, and asserts the supabase test db ``` -This example tests a `profiles` table where `authenticated` holds every privilege, `anon` holds none, and each user reads and writes only their own row: +This example shows each of those techniques against a `profiles` table where `authenticated` holds every privilege, `anon` holds none, and each user reads and writes only their own row. Extend it to the remaining operations: ```sql supabase/tests/profiles_rls.test.sql begin; -select plan(11); +select plan(4); --- Seed two users. The rows come later, through the policies under test. insert into auth.users (id, email) values ('11111111-1111-1111-1111-111111111111', 'owner@example.com'), ('22222222-2222-2222-2222-222222222222', 'other@example.com'); --- Signed-out visitors hold no grant, so the request stops before any policy runs. +-- anon holds no grant, so the request stops before any policy runs. set local role anon; select throws_ok( $$select * from profiles$$, @@ -480,15 +367,8 @@ select throws_ok( null, 'anon cannot read profiles' ); -select throws_ok( - $$insert into profiles (id, user_id) - values (gen_random_uuid(), '11111111-1111-1111-1111-111111111111')$$, - '42501', - null, - 'anon cannot insert a profile' -); --- The owner reads and writes their own row. +-- The owner writes their own row. returning proves the row changed. set local role authenticated; set local request.jwt.claim.sub = '11111111-1111-1111-1111-111111111111'; select results_eq( @@ -502,25 +382,6 @@ select results_eq( array['owner.png'], 'the owner creates their own profile' ); -select results_eq( - $$select avatar_url from profiles$$, - array['owner.png'], - 'the owner reads their own profile' -); -select results_eq( - $$update profiles set avatar_url = 'updated.png' returning avatar_url$$, - array['updated.png'], - 'the owner updates their own profile' -); - --- The with check clause rejects the row, which raises. -select throws_ok( - $$insert into profiles (id, user_id) - values (gen_random_uuid(), '22222222-2222-2222-2222-222222222222')$$, - '42501', - null, - 'the owner cannot create a profile for someone else' -); -- A signed-in stranger holds the grant, so the policy is what stops them. The -- using clause filters the row out, so these match nothing and raise nothing. @@ -533,23 +394,6 @@ select is_empty( $$update profiles set avatar_url = 'stolen.png' returning avatar_url$$, 'another user updates no profiles' ); -select is_empty( - $$delete from profiles returning id$$, - 'another user deletes no profiles' -); - --- The row is still there, still holding the owner's value. -set local request.jwt.claim.sub = '11111111-1111-1111-1111-111111111111'; -select results_eq( - $$select avatar_url from profiles$$, - array['updated.png'], - 'the other user changed nothing' -); -select results_eq( - $$delete from profiles returning avatar_url$$, - array['updated.png'], - 'the owner deletes their own profile' -); select * from finish(); rollback; @@ -557,89 +401,87 @@ rollback; For CLI setup and more pgTAP helpers, see [Testing your database](/docs/guides/database/testing). -## Write policies that scale +## RLS reference -Postgres evaluates a policy expression against each candidate row, so the cost scales with the rows a query scans. Three rules keep that cost from growing with your table. Apply all three to every policy you write. +These are the functions and patterns available inside a policy expression. -### Add indexes +### Helper functions -Add an [index](/docs/guides/database/postgres/indexes) on every column your policies filter on. Postgres evaluates the policy against each candidate row, so an unindexed filter column turns a read into a sequential scan. For a policy like this: +Supabase provides some helper functions that make it easier to write policies. -```sql -create policy "rls_test_select" on test_table -to authenticated -using ( (select auth.uid()) = user_id ); -``` +#### `auth.uid()` -You can add an index like: +Returns the ID of the user making the request. -```sql -create index userid -on test_table -using btree (user_id); -``` + -A column counts as indexed only when it comes first in a `btree` index. Postgres can't use a multi-column index to filter on a column that isn't the leading one, so a composite primary key indexes its first column and no others. A membership table keyed on `(team_id, user_id)` has no index on `user_id`: +When a request is made without an authenticated user (e.g., no access token is provided or the session has expired), `auth.uid()` returns `null`. -```sql -create table team_members ( - team_id uuid references teams (id), - user_id uuid references auth.users (id), - primary key (team_id, user_id) -); +This means that a policy like: --- The primary key covers team_id. A policy filtering on user_id needs its own index. -create index team_members_user_id_idx -on team_members -using btree (user_id); +```sql +USING (auth.uid() = user_id) ``` -### Call functions with `select` - -You can use `select` statement to improve policies that use functions. For example, instead of this: +will silently fail for unauthenticated users, because: ```sql -create policy "rls_test_select" on test_table -to authenticated -using ( auth.uid() = user_id ); +null = user_id ``` -You can do: +is always false in SQL. + +To avoid confusion and make your intention clear, we recommend explicitly checking for authentication: ```sql -create policy "rls_test_select" on test_table -to authenticated -using ( (select auth.uid()) = user_id ); +USING (auth.uid() IS NOT NULL AND auth.uid() = user_id) ``` -This method works well for JWT functions like `auth.uid()` and `auth.jwt()` as well as `security definer` Functions. Wrapping the function causes an `initPlan` to be run by the Postgres optimizer, which allows it to "cache" the results per-statement, rather than calling the function on each row. + + +#### `auth.jwt()` -You can only use this technique if the results of the query or function do not change based on the row data. +Not all information present in the JWT should be used in RLS policies. For instance, creating an RLS policy that relies on the `user_metadata` claim can create security issues in your application as this information can be modified by authenticated end users. -### Specify roles in your policies - -Always name the role a policy applies to, using the `to` clause. Instead of this: +Returns the JWT of the user making the request. Anything that you store in the user's `raw_app_meta_data` column or the `raw_user_meta_data` column will be accessible using this function. It's important to know the distinction between these two: -```sql -create policy "rls_test_select" on rls_test -using ( auth.uid() = user_id ); -``` +- `raw_user_meta_data` - can be updated by the authenticated user using the `supabase.auth.update()` function. It is not a good place to store authorization data. +- `raw_app_meta_data` - cannot be updated by the user, so it's a good place to store authorization data. -Use: +The `auth.jwt()` function is extremely versatile. For example, if you store some team data inside `app_metadata`, you can use it to determine whether a particular user belongs to a team. For example, if this was an array of IDs: ```sql -create policy "rls_test_select" on rls_test +create policy "User is in team" +on my_table to authenticated -using ( (select auth.uid()) = user_id ); +using ( team_id in (select auth.jwt() -> 'app_metadata' -> 'teams')); ``` -This prevents the policy `( (select auth.uid()) = user_id )` from running for any `anon` users, since the execution stops at the `to authenticated` step. + -These three rules keep policies correct as a table grows. To diagnose whether a policy is still the bottleneck, and to tune beyond these rules, see [Row Level Security performance](/docs/guides/database/postgres/row-level-security-performance). +Keep in mind that a JWT is not always up-to-date. In the team policy example, even if you remove a user from a team and update the `app_metadata` field, that will not be reflected using `auth.jwt()` until the user's JWT is refreshed. + +Also, if you are using Cookies for Auth, then you must be mindful of the JWT size. Some browsers are limited to 4096 bytes for each cookie, and so the total size of your JWT should be small enough to fit inside this limitation. + + + +#### MFA + +The `auth.jwt()` function can be used to check for [Multi-Factor Authentication](/docs/guides/auth/auth-mfa#enforce-rules-for-mfa-logins). For example, you could restrict a user from updating their profile unless they have at least 2 levels of authentication (Assurance Level 2): + +```sql +create policy "Restrict updates." +on profiles +as restrictive +for update +to authenticated using ( + (select auth.jwt()->>'aal') = 'aal2' +); +``` ### Use security definer functions @@ -688,6 +530,26 @@ A `security definer` function in an exposed schema is callable over the Data API +### Bypassing Row Level Security + +Use a [secret key](/docs/guides/getting-started/api-keys) for administrative tasks that need to bypass RLS. A secret key authorizes access through the `service_role` Postgres role, which has the `bypassrls` attribute. Never use a secret key in the browser or expose it to customers. + +The JWT-based `service_role` key is a legacy alternative. Prefer a secret key where possible. + + + +A secret key bypasses RLS only when the request carries no user access token. If the request carries one, it runs under the RLS policies of that signed-in user, even when the client library was initialized with a secret key. + + + +You can also create new [Postgres Roles](/docs/guides/database/postgres/roles) which can bypass Row Level Security using the "bypass RLS" privilege: + +```sql +alter role "role_name" with bypassrls; +``` + +This can be useful for system-level access. **Never** share login credentials for any Postgres Role with this privilege. + ## Related content - [Row Level Security performance](/docs/guides/database/postgres/row-level-security-performance): diagnose whether policies are your bottleneck, and tune ones that are already correct. diff --git a/apps/docs/content/troubleshooting/interpreting-supabase-grafana-io-charts-MUynDR.mdx b/apps/docs/content/troubleshooting/interpreting-supabase-grafana-io-charts-MUynDR.mdx index 9efe9c4793daa..3b93646ebe2f2 100644 --- a/apps/docs/content/troubleshooting/interpreting-supabase-grafana-io-charts-MUynDR.mdx +++ b/apps/docs/content/troubleshooting/interpreting-supabase-grafana-io-charts-MUynDR.mdx @@ -42,7 +42,7 @@ Excessive IO usage is highly problematic as it clarifies that your database is e - **Excessive and needless sequential scans:** poorly indexed tables force requests to scan disk ([guide to resolve](https://github.com/orgs/supabase/discussions/22449)) - **Too little cache**: There is not enough memory, so instead of reading data from the memory cache, it is accessed from disk ([guide to inspect](https://github.com/orgs/supabase/discussions/22449)) -- **Poorly optimized RLS policies**: RLS that rely heavily on joins are more likely to hit disk. If possible, they should optimized ([RLS performance guide](/docs/guides/database/postgres/row-level-security-performance)) +- **Poorly optimized RLS policies**: RLS that rely heavily on joins are more likely to hit disk. If possible, they should be optimized ([RLS performance guide](/docs/guides/database/postgres/row-level-security-performance)) - **Excessive bloat**: This is the least likely to cause major issues, but bloat can take up space, preventing data on disk from being placed in the same locality. This can force the database to scan more pages than necessary. ([explainer guide](/blog/postgres-bloat)) - **Uploading high amounts of data:** temporarily increase compute add-on size for the duration of the uploads - **Insufficient memory**: Sometimes an inadequate amount of memory forces queries to hit disk instead of the memory cache. Address memory issues ([guide](https://github.com/orgs/supabase/discussions/27021)) can reduce disk strain.