) | 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())) |
+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:
-### Add filters to every query
+- `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.
-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):
+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:
-{/* prettier-ignore */}
-```js
-const { data } = supabase
- .from('table')
- .select()
+```sql
+create policy "User is in team"
+on my_table
+to authenticated
+using ( team_id in (select auth.jwt() -> 'app_metadata' -> 'teams'));
```
-You should always add a filter:
+
-{/* prettier-ignore */}
-```js
-const { data } = supabase
- .from('table')
- .select()
- .eq('user_id', userId)
-```
+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.
-Even though this duplicates the contents of the Policy, Postgres can use the filter to construct a better query plan.
+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.
-#### Benchmarks
+
-| 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` |
+#### 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
@@ -850,79 +530,31 @@ A `security definer` function in an exposed schema is callable over the Data API
-### Minimize joins
+### Bypassing Row Level Security
-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"
- )
-);
-```
+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.
-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:
+The JWT-based `service_role` key is a legacy alternative. Prefer a secret key where possible.
-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.
+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.
-#### 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:
+You can also create new [Postgres Roles](/docs/guides/database/postgres/roles) which can bypass Row Level Security using the "bypass RLS" privilege:
```sql
-create policy "rls_test_select" on rls_test
-to authenticated
-using ( (select auth.uid()) = user_id );
+alter role "role_name" with bypassrls;
```
-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) |
+This can be useful for system-level access. **Never** share login credentials for any Postgres Role with this privilege.
-## 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..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 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 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.
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] ?? '?'}
)}
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;