Tutorial

Supabase in 2026: The Production Playbook for Grants, RLS, Keys, Connections, Realtime and Cost

A practical production guide to Supabase grants, row-level security, API keys, Postgres connections, Realtime, Edge Functions and pricing.

·
23 min read
·
AI-assisted

AI tooling disclosure

Generative AI materially contributed to this article.

Human review confirmed by the author.

Supabase in 2026: The Production Playbook for Grants, RLS, Keys, Connections, Realtime and Cost

Supabase combines Postgres with services for authentication, generated APIs, realtime messaging, object storage and Edge Functions. This guide skips the product tour and focuses on what can break deployments, expose data or raise costs.

The most urgent item has a deadline: on October 30, 2026, Supabase will stop automatically granting Data API access to newly created tables in existing projects. Explicit permissions must become part of your migration workflow. This guide uses Supabase documentation, changelog entries and pricing pages checked on October 18, 2026. Recheck linked sources before applying time-sensitive settings because platform documentation and limits can change.

Start with the problem you need to solve: Section 1 covers the grants deadline; Sections 3–5 cover RLS and policy performance; Section 6 covers keys and JWTs; Sections 7–8 cover connections and Realtime; Sections 9–10 cover Edge Functions and costs.

1. The October 30 change: explicit grants for the Data API

Supabase announced in its April 28 changelog entry that new tables in the public schema will no longer be exposed to the Data API automatically. The behavior became the default for new projects on May 30, 2026, and is scheduled to apply to existing projects on October 30, 2026. Existing tables keep their current grants. New tables need explicit grants before PostgREST, GraphQL or supabase-js can access them.

This affects apps that use Supabase client libraries or call /rest/v1/ or /graphql/v1. If your app connects to Postgres only through a connection string, ORM or server-side driver, this particular Data API change does not directly affect that connection. If a Data API request lacks the required grant, PostgREST can return SQLSTATE 42501 with a hint about the missing GRANT.

Date

Milestone

What to do

2026-04-28

Opt-in behavior announced

Test the change on a branch or staging project

2026-05-30

New-project default begins rolling out

Update provisioning and migration templates

2026-10-23

Final email notice scheduled for owners and admins

Complete the access audit

2026-10-30

Applies to existing projects

Ensure every exposed object has intentional grants

Source: Supabase changelog: tables are no longer exposed automatically.

You can adopt the new behavior before October 30. Supabase's API security guide documents the statements below. Test them on a branch or staging project first. The final statement is not schema-scoped: a schema-specific revoke cannot remove a privilege inherited from PostgreSQL's global default for functions.

-- Tables created by postgres in public do not get automatic Data API table grants.
alter default privileges for role postgres in schema public
  revoke select, insert, update, delete on tables from anon, authenticated, service_role;

-- New sequences do not get automatic grants to the Data API roles.
alter default privileges for role postgres in schema public
  revoke usage, select on sequences from anon, authenticated, service_role;

-- New functions created by postgres in public do not get automatic EXECUTE grants
-- to these API roles.
alter default privileges for role postgres in schema public
  revoke execute on functions from anon, authenticated, service_role;

-- This global default revoke prevents new postgres-owned functions from inheriting
-- EXECUTE from PUBLIC. It is deliberately not limited to the public schema.
alter default privileges for role postgres
  revoke execute on functions from public;

These statements affect only future objects created by the postgres role. They do not remove grants from existing objects, so audit those separately and grant only the access each one needs. PostgreSQL default privileges depend on the role and schema. Check your project's current defaults before applying the changes, especially if objects are created by other roles or you have customized grants.

Before the deadline, audit ordinary and partitioned tables in public. The first query checks each privilege separately instead of passing a comma-separated list to has_table_privilege.

select
  c.relname as table_name,
  c.relrowsecurity as rls_enabled,
  (
    has_table_privilege('anon', c.oid, 'select') or
    has_table_privilege('anon', c.oid, 'insert') or
    has_table_privilege('anon', c.oid, 'update') or
    has_table_privilege('anon', c.oid, 'delete')
  ) as anon_has_any,
  (
    has_table_privilege('authenticated', c.oid, 'select') or
    has_table_privilege('authenticated', c.oid, 'insert') or
    has_table_privilege('authenticated', c.oid, 'update') or
    has_table_privilege('authenticated', c.oid, 'delete')
  ) as authenticated_has_any
from pg_class c
join pg_namespace n on n.oid = c.relnamespace
where n.nspname = 'public'
  and c.relkind in ('r', 'p')
order by c.relrowsecurity, c.relname;

-- List RLS-enabled tables in public, including those with zero policies.
select
  c.relname as table_name,
  count(p.policyname) as policy_count
from pg_class c
join pg_namespace n on n.oid = c.relnamespace
left join pg_policies p
  on p.schemaname = n.nspname
 and p.tablename = c.relname
where n.nspname = 'public'
  and c.relkind in ('r', 'p')
  and c.relrowsecurity
group by c.relname
order by policy_count, c.relname;

The LEFT JOIN matters. A query that starts from pg_policies cannot show tables with zero policies because those tables have no policy rows. This query starts from the table catalog, so it includes them.

This is a table-focused audit, not a complete exposure audit. Review views, functions, sequences, other API-exposed schemas, and grants inherited through roles separately. A table's grants and its RLS policies answer different questions: grants decide whether a role can access the table at all; RLS decides which rows it can access. Without RLS, a table in an exposed schema is accessible for every operation granted to the caller. With RLS enabled but no matching policy, ordinary Data API requests are denied by default.

See Supabase's RLS guide and troubleshooting note about inherited privileges.

2. What you are actually renting: the architecture

Every Supabase project has a Postgres database with services around it. The architecture guide shows Envoy routing requests to GoTrue, PostgREST, Realtime, Storage, postgres-meta, Edge Functions and pg_graphql. These services use the same database. Supabase does not replace Postgres with a proprietary data layer, so many security and performance choices are standard PostgreSQL choices.

Component

Role

Implementation language noted in the architecture guide

Postgres

Database and query engine

C

GoTrue (Auth)

Users, sessions and JWTs

Go

PostgREST

Generates a REST API from the database schema

Haskell

Realtime

WebSocket service for Broadcast, Presence and database changes

Elixir

Storage API

Object storage API with metadata in Postgres

Node.js / TypeScript

Edge Functions

Deno-based runtime for TypeScript and JavaScript

TypeScript / Rust

Supavisor

Multi-tenant Postgres connection pooler

Elixir

Envoy

API gateway

C++

Source: Supabase architecture guide.

Supabase says portability is a core principle. Its architecture guide describes compatibility between cloud and self-hosted deployments, with tools such as pg_dump and CSV for moving data. Self-hosted deployments are changing too. A July 17 changelog entry says Envoy is replacing Kong as the default API gateway, with rollout beginning the week of August 9, 2026.

In its Series F announcement on June 4, 2026, Supabase reported a $500 million round led by GIC at a $10 billion pre-money valuation. The grants change addresses a risk that comes with faster development: scripts and AI tools can create tables faster than people review their permissions.

A Data API request passes through four distinct checks:

  1. API key: identifies the project and key type. A publishable key does not identify a specific user.

  2. JWT: when present, identifies the user and provides claims, including the Postgres role for the request. Without a signed-in user, the request normally uses the anon role.

  3. SQL privileges: determine whether that role can access a table, view, sequence or function.

  4. RLS policies: determine which rows the role can read or change.

Supabase's API keys guide explains that PostgreSQL checks privileges before RLS. A missing grant therefore causes a different result from a policy that filters out all rows.

sequenceDiagram
  participant App as Browser app
  participant GW as API gateway
  participant PR as PostgREST
  participant PG as Postgres
  App->>GW: Request with apikey header and, when signed in, user JWT
  GW->>GW: Validate the API key
  GW->>PR: Forward the request
  PR->>PG: Execute as anon or the role named in the user JWT
  PG->>PG: Check object privileges; fail if missing
  PG->>PG: Apply RLS policies to candidate rows
  PG-->>PR: Authorized rows
  PR-->>App: JSON response

3. Build one table the secure way

Use one migration for each table. In it, create the table and indexes, enable RLS, grant only the required privileges, and add policies for the operations the app needs. Supabase recommends separate policies for SELECT, INSERT, UPDATE and DELETE, with the intended roles named in each TO clause.

-- supabase/migrations/20261018120000_create_notes.sql
create table public.notes (
  id         uuid primary key default gen_random_uuid(),
  user_id    uuid not null references auth.users (id) on delete cascade,
  body       text not null,
  created_at timestamptz not null default now()
);

-- RLS filters by user_id, so index it.
create index notes_user_id_idx on public.notes using btree (user_id);

alter table public.notes enable row level security;

-- Start from no direct table privileges for these roles, then add only what's needed.
revoke all on table public.notes from anon, authenticated, service_role;
grant select, insert, update, delete on table public.notes to authenticated;
grant select, insert, update, delete on table public.notes to service_role;

create policy "notes_select_own" on public.notes
  for select to authenticated
  using ((select auth.uid()) = user_id);

create policy "notes_insert_own" on public.notes
  for insert to authenticated
  with check ((select auth.uid()) = user_id);

create policy "notes_update_own" on public.notes
  for update to authenticated
  using ((select auth.uid()) = user_id)
  with check ((select auth.uid()) = user_id);

create policy "notes_delete_own" on public.notes
  for delete to authenticated
  using ((select auth.uid()) = user_id);

Keep three details in mind. The user_id index helps queries and policies that filter by owner. The update policy uses both USING and WITH CHECK: USING limits which existing rows can be changed, while WITH CHECK prevents the caller from assigning a row to someone else. Every policy targets authenticated, so the anon role cannot use these policies to access rows.

The service_role is privileged and bypasses RLS. Give it access only where trusted server-side code needs it, and never expose its key in browser or mobile code. RLS bypass does not replace SQL privileges: the role must still have the required object access.

Test the policies with pgTAP

Supabase's RLS guide documents database testing in supabase/tests, run with supabase test db. Create a test file with supabase test new notes_rls.test, then test both allowed and denied actions. The example below assumes the notes table and policies above are present in the test database.

-- supabase/tests/notes_rls.test.sql
begin;
select plan(9);

insert into auth.users (id, email) values
  ('11111111-1111-1111-1111-111111111111', '[email protected]'),
  ('22222222-2222-2222-2222-222222222222', '[email protected]');

-- No table grant for anon: this fails before an RLS policy can permit access.
set local role anon;
select throws_ok(
  $$select * from public.notes$$,
  '42501', null,
  'anon cannot read notes'
);

-- The owner creates a row belonging to themselves.
set local role authenticated;
set local request.jwt.claim.sub = '11111111-1111-1111-1111-111111111111';
select results_eq(
  $$insert into public.notes (user_id, body)
    values ('11111111-1111-1111-1111-111111111111', 'hello')
    returning body$$,
  array['hello'],
  'owner inserts a note'
);

-- The owner can update their own note.
select results_eq(
  $$update public.notes set body = 'updated'
    where user_id = '11111111-1111-1111-1111-111111111111'
      and body = 'hello'
    returning body$$,
  array['updated'],
  'owner updates their note'
);

-- The owner cannot reassign that row to someone else.
select throws_ok(
  $$update public.notes
    set user_id = '22222222-2222-2222-2222-222222222222'
    where user_id = '11111111-1111-1111-1111-111111111111'$$,
  '42501', null,
  'owner cannot transfer ownership by updating user_id'
);

-- A different signed-in user cannot insert a note for the owner.
set local request.jwt.claim.sub = '22222222-2222-2222-2222-222222222222';
select throws_ok(
  $$insert into public.notes (user_id, body)
    values ('11111111-1111-1111-1111-111111111111', 'forged')$$,
  '42501', null,
  'another user cannot insert a note for the owner'
);

select is_empty(
  $$select * from public.notes$$,
  'another user reads no notes'
);
select is_empty(
  $$update public.notes set body = 'hacked' returning id$$,
  'another user updates no notes'
);
select is_empty(
  $$delete from public.notes returning id$$,
  'another user deletes no notes'
);

-- Confirm the owner's row remains intact.
set local request.jwt.claim.sub = '11111111-1111-1111-1111-111111111111';
select results_eq(
  $$select body from public.notes
    where user_id = '11111111-1111-1111-1111-111111111111'$$,
  array['updated'],
  'owner note remains intact'
);

select * from finish();
rollback;

The nine assertions separate permission errors from operations that affect zero rows. A missing grant or a WITH CHECK violation can raise 42501, so the test uses throws_ok for those cases. A USING policy usually hides rows without raising an error; an update or delete then affects zero rows, so the test checks RETURNING and confirms the owner's row still exists.

Run this test in your database-test environment. Adjust the auth.users fixture if your local schema or auth triggers require extra fields. A passing database test does not replace an end-to-end test of the actual Data API request with a user JWT.

4. RLS patterns that bite in production

Unauthenticated requests make auth.uid() null

Supabase says auth.uid() returns null when no user is signed in. Because null does not equal a user ID, an ownership condition will not match. A policy using TO authenticated already excludes anon. If a policy must serve both anonymous and signed-in users, add an explicit non-null check where needed.

using (
  (select auth.uid()) is not null
  and (select auth.uid()) = user_id
)

Do not authorize from user_metadata

Do not use user-editable metadata such as raw_user_meta_data to decide access, because users may be able to change it from the client. For suitable authorization data, use trusted claims such as raw_app_meta_data. If access must be revoked immediately, check a database membership table instead. JWT claims can be stale, so a membership change stored only in a claim may not take effect until the token is refreshed or expires.

Views can bypass underlying RLS

By default, PostgreSQL checks access to a view's underlying tables using the view owner's privileges. If that owner is privileged, the view may reveal rows the caller could not read directly. On PostgreSQL 15 and later, set security_invoker = true when the view should follow the caller's permissions and RLS policies. On older versions, revoke API-role access to the view or keep it in an unexposed schema. Review the view's own grants separately.

create view public.my_notes
with (security_invoker = true)
as
select id, body, created_at
from public.notes;

grant select on table public.my_notes to authenticated;

Source: Supabase views guide.

Security-definer helpers and policy recursion

If policies on two tables query each other, PostgreSQL can raise 42P17 for infinite recursion. One solution is a carefully designed SECURITY DEFINER helper in a schema that the Data API does not expose. Set search_path to an empty string and qualify every object name. Restrict who can execute the helper: it runs with its owner's privileges, so careless access can create a privilege-escalation risk.

create schema if not exists private;

create function private.team_ids_for_current_user()
returns setof uuid
language sql
security definer
set search_path = ''
stable
as $$
  select team_id
  from public.team_members
  where user_id = (select auth.uid())
$$;

revoke execute on function private.team_ids_for_current_user() from public;
grant usage on schema private to authenticated;
grant execute on function private.team_ids_for_current_user() to authenticated;

create policy "members read team notes" on public.team_notes
  for select to authenticated
  using (
    team_id in (select private.team_ids_for_current_user())
  );

This example assumes public.team_members uses the intended membership model and that the function owner can read it. A SECURITY DEFINER function does not make a policy safe by itself. Check the caller's identity and access inside the function, restrict EXECUTE, and test with real roles. See Supabase's database functions guide.

Two smaller traps

An UPDATE usually also needs a matching SELECT policy to behave as expected. A secret key also does not guarantee RLS bypass when the request includes a user's access token. With a user JWT attached, the request may run under that user's role and policies, even if the client was created with a secret key. This can explain why some server-side admin calls return only a subset of rows.

5. Make RLS fast

RLS policies run as part of each query. Their cost depends on how many candidate rows the query scans and how much work each policy does. Start with Supabase's RLS performance guide and RLS guide.

First, index columns used in row filters. A composite primary key on (team_id, user_id) does not provide an index that starts with user_id. Add one if your queries filter by that column.

create index team_members_user_id_idx
  on public.team_members using btree (user_id);

Second, wrap helper calls that do not depend on the current row in a scalar subquery. For example, (select auth.uid()) can let PostgreSQL evaluate the function once as an initPlan and reuse the result within the statement. Third, limit each policy to the intended role with TO authenticated or another role. Fourth, repeat the relevant filter in the application query when appropriate. This can help the planner narrow the rows it checks, while RLS remains the security boundary.

// Repeat the ownership condition intentionally; RLS remains the security boundary.
const { data, error } = await supabase
  .from('notes')
  .select()
  .eq('user_id', userId)

if (error) throw error

Avoid expensive joins inside policy expressions when a set-based membership check can do the job. Selecting the allowed team IDs may be easier for the planner than joining membership rows to every candidate row. Test the pattern for your schema with EXPLAIN ANALYZE; a set containing thousands of IDs is not automatically faster.

-- One possible set-based pattern for a membership policy.
using (
  team_id in (
    select team_id
    from public.team_user
    where user_id = (select auth.uid())
  )
)

Measure instead of guessing. In a test environment, use realistic data and compare the same query under the role your app uses. Check Rows Removed by Filter when a policy rejects many scanned rows.

begin;
set local role authenticated;
set local request.jwt.claims =
  '{"role":"authenticated","sub":"5950b438-b07c-4012-8190-6ce79e4bd8e5"}';

explain analyze
select count(*)
from public.notes;

rollback;

Use a real test user ID and run this only in a safe environment. Testing as an administrator does not show how the query behaves under your application's role and JWT claims.

6. Keys and tokens: the 2026 model

Supabase API keys identify the project or calling application; authentication identifies the individual user. These are separate jobs. The API keys guide describes four key types:

Key

Format

Intended use

Typical Postgres role

Publishable

sb_publishable_...

Browser, mobile app and other public client code

anon, or authenticated when a user JWT is present

Secret

sb_secret_...

Trusted servers, Edge Functions and controlled jobs

service_role, which bypasses RLS

anon (legacy)

Long-lived JWT

Legacy equivalent of the publishable key

anon

service_role (legacy)

Long-lived JWT

Legacy equivalent of the secret key

service_role

Supabase plans to deprecate the legacy anon and service_role keys by the end of 2026. Both key systems work side by side during the transition. Create the new keys, update your clients, and check Settings → API Keys for last-used activity. Deactivate old keys only after confirming that nothing depends on them, and test the change before applying it in production.

Secret keys have extra safeguards compared with the older JWT-based service-role key. Supabase can return HTTP 401 when the User-Agent header identifies a browser request using a secret key. Treat this as an extra safeguard, not a security boundary. Never place a secret key in frontend code, a mobile app, a public repository or client-side configuration. Where practical, use a separate secret key for each backend component so you can rotate one without affecting the others. Never log keys; store a cryptographic hash if you need to identify a key without retaining the key itself.

Publishable and secret keys are not JWTs, so send them in the apikey header, not as a Bearer user token. The verify_jwt setting does not turn an API key into a user identity. Your function must still authorize its caller. Supabase's @supabase/server guide shows how to protect functions that require a secret key.

import { withSupabase } from 'npm:@supabase/server@1'

export default {
  fetch: withSupabase({ auth: 'secret' }, async (_req, ctx) => {
    // This client is privileged and bypasses RLS.
    // Only use it after the SDK has authorized the secret-key caller.
    const { count, error } = await ctx.supabaseAdmin
      .from('notes')
      .select('id', { count: 'exact', head: true })

    if (error) return Response.json({ error: 'Query failed' }, { status: 500 })
    return Response.json({ notes: count })
  }),
}

For a service-to-service function using withSupabase({ auth: 'secret' }), Supabase's current guide says to set verify_jwt = false because the wrapper validates the apikey credential. Keep verify_jwt = true for the usual user-JWT flow. Never disable platform verification without adding the intended protection. For external webhooks, verify the provider's signature yourself.

# supabase/config.toml — only for a function whose withSupabase auth mode
# validates the secret key, such as nightly-report.
[functions.nightly-report]
verify_jwt = false

Supabase supports a legacy shared JWT secret and a newer signing-keys system. The signing keys guide recommends asymmetric keys for production and covers ES256 and RS256. A backend can verify these tokens with public keys, without holding the signing secret. Rotation does not automatically sign out every user, but custom backends must handle cached keys correctly.

Supabase's JWT guide explains that JWKS responses are cached, so key changes may take time to reach every verifier. Follow the documented waiting period before deleting a key that might still be cached. Supabase's services and your own backend may not see a change at the same time.

For a standalone backend, use the jose library to verify a Supabase JWT against the project's JWKS endpoint if the project uses asymmetric signing keys. Check the issuer against a real token; a custom domain can change the issuer URL.

import { createRemoteJWKSet, jwtVerify } from 'jose'

const issuer = `${process.env.SUPABASE_URL}/auth/v1`
const jwks = createRemoteJWKSet(new URL(`${issuer}/.well-known/jwks.json`))

export async function verifyAccessToken(token: string) {
  const { payload } = await jwtVerify(token, jwks, { issuer })
  return {
    userId: payload.sub,
    role: payload.role,
    claims: payload,
  }
}

JWKS works with asymmetric signing keys. If your project still uses an HS256 shared secret, follow Supabase's current token-validation guidance instead; the JWKS endpoint cannot verify a token signed with that shared secret.

7. Connections: pick the method by where the code runs

Supabase's connection guide matches connection methods to different runtimes. The shared Supavisor pooler supports IPv4 on every plan. Direct connections generally require IPv6 unless you have the paid IPv4 add-on. Check these network requirements first when troubleshooting connection failures.

Where the code runs

Typical choice

Port

Notes

Browser or mobile frontend

Data API

None

Use RLS; never embed database credentials

Serverless or edge function using a Postgres driver

Shared pooler, transaction mode

6543

Suited to many short-lived connections; transaction-mode limitations apply

Persistent backend with IPv6 or paid IPv4 add-on

Direct connection

5432

Appropriate for long-lived sessions and native database tools

Persistent backend on IPv4-only network

Shared pooler, session mode

5432

Keeps a session pinned while connected

BI tool or database GUI

Shared pooler, session mode

5432

Useful when the client expects session features or prepared statements

High-throughput workload on a paid plan

Dedicated pooler, where appropriate

6543

Lower network latency than the shared pooler; still account for database limits

Migrations, pg_dump and replication tasks

Direct connection, when reachable

5432

Prefer a connection mode that supports the required native operation

Transaction pooling returns a connection to the pool after each transaction, so session state does not persist. Session-level SET and RESET, advisory locks, LISTEN and NOTIFY, temporary tables that outlive a transaction, and WITH HOLD cursors may not work as expected. Prepared statements and query pipelining also need driver-specific treatment.

Postgres.js has an important caveat. Supabase's Postgres.js guide warns that its default query pipelining can hang queries or return mismatched rows with the shared transaction pooler. The guide says there is no working direct setting to disable pipelining; max_pipeline: 0 can break sql.begin() transactions. prepare: false disables prepared statements, but it does not fix pipelining. Do not treat it as a complete Postgres.js transaction-pooling fix.

If serverless code can use the Data API, supabase-js is often simpler: it uses HTTPS, and Supabase manages the API's database connections. If you need direct SQL, choose a driver and configuration documented for your runtime and pooler. Check the current connection guide before using shared transaction mode with any driver.

For a persistent Node.js backend using a direct connection or session-mode pooler, create one client at module scope to reuse connections across requests. Set the pool size based on the project's connection budget rather than copying a sample value:

import postgres from 'postgres'

// Example for a long-lived backend using a direct connection or session pooler.
// Do not reuse this as a generic transaction-pooler recipe.
export const sql = postgres(process.env.DATABASE_URL!, {
  max: 5,
  ssl: 'require',
})

Keep the combined pool size across all app instances within the database's connection limit, with room for Supabase services and administration. Even five connections per instance can be too many if your platform scales to dozens of instances.

Driver or ORM

Common transaction-pooler consideration

Postgres.js

Review the warning above; prepare: false does not resolve query pipelining

Drizzle

Behavior depends on the underlying driver; apply that driver's pooling constraints

Prisma

Follow current Prisma/Supabase guidance for the deployed version and connection string

asyncpg

The Supabase connection guide lists statement_cache_size=0 where needed

JDBC

The connection guide lists prepareThreshold=0 for relevant driver configurations

Sources: Supabase connection guide and Postgres.js guide.

8. Realtime: broadcast changes deliberately

Supabase Realtime offers Broadcast, Presence and Postgres Changes. For database events, the subscribing guide recommends Broadcast for better scalability and security. Postgres Changes is simpler to start with but can become costly when many clients subscribe to the same events.

With Postgres Changes, Supabase checks each event against every subscriber. One event sent to 100 subscribers can require 100 authorization checks. Processing also follows a single-threaded path to preserve order, so more database compute does not automatically solve the bottleneck. If many clients subscribe to the same changes, consider Broadcast early instead of relying on a compute upgrade.

Two details are easy to miss. RLS policies are not applied to DELETE events in the same way as other row events because the deleted row is no longer available for a normal access check. Also, REPLICA IDENTITY FULL is not a guarantee that clients will receive every old column: when RLS is enabled, payload.old can still contain only the primary key. See the current Postgres Changes guide for the exact behavior and trade-offs.

The example below broadcasts changes on a private topic and uses a policy to limit who can subscribe. Replace room_topic and the membership fields with names from your schema. The function is SECURITY DEFINER, so it pins search_path, qualifies database objects and revokes direct execution from API roles. Keep the private schema out of the Data API's exposed schemas.

create schema if not exists private;

create function private.broadcast_chat_message()
returns trigger
language plpgsql
security definer
set search_path = ''
as $$
begin
  perform realtime.broadcast_changes(
    coalesce(new.room_topic, old.room_topic),
    tg_op,
    tg_op,
    tg_table_name,
    tg_table_schema,
    new,
    old
  );
  return null;
end;
$$;

-- The trigger does not require API roles to call this function directly.
revoke execute on function private.broadcast_chat_message()
  from public, anon, authenticated, service_role;

create trigger chat_messages_broadcast
  after insert or update or delete on public.chat_messages
  for each row execute function private.broadcast_chat_message();

-- Only members of a room may receive broadcasts for that room.
create policy "members receive room broadcasts"
  on realtime.messages
  for select
  to authenticated
  using (
    realtime.messages.extension = 'broadcast'
    and exists (
      select 1
      from public.room_members m
      where m.user_id = (select auth.uid())
        and m.room_topic = (select realtime.topic())
    )
  );

The membership table also needs the right grants and RLS policy for users to read their own membership rows, where required. Set the channel to private on the client and turn off Allow public access in Realtime settings. Before deploying, confirm the function signature and policy against the current Broadcast guide.

await supabase.realtime.setAuth()

const channel = supabase
  .channel(roomTopic, { config: { private: true } })
  .on('broadcast', { event: 'INSERT' }, (payload) => render(payload))
  .subscribe((status, err) => {
    if (status !== 'SUBSCRIBED') console.error(err)
  })

The Realtime Authorization guide says authorization policies are cached for the life of a connection and refreshed when a new JWT arrives. A removed member may keep access until the token refreshes or expires, depending on the connection and token lifecycle. Set token expiry to match your risk level, and test member removal on an active connection. Complex policies can also slow connections.

9. Edge Functions: what the limits mean for design

Supabase Edge Functions run on Deno. The official limits page lists 256 MB of memory and two seconds of CPU time per request, excluding asynchronous I/O. The wall-clock limit is 150 seconds on Free and 400 seconds on paid plans; the request idle timeout is 150 seconds. Bundles can be up to 20 MB when built locally with the CLI, or 5 MB when bundled server-side through the Dashboard or Management API. Recursive or nested calls are limited to 30 requests per trace in 60 seconds.

Use Edge Functions for request handling and light orchestration, such as webhooks, auth hooks, short API calls and simple coordination. Move CPU-heavy image processing and large batch jobs to a more suitable runtime.

Two restrictions affect common workloads. Outbound SMTP on ports 25 and 587 is blocked, so use an email provider's HTTPS API instead of direct SMTP. Libraries that need unsupported multithreading or native image-processing dependencies—such as some sharp/libvips setups—may need another runtime or packaging approach. HTML responses are served as text/html only on custom domains; otherwise, a GET response with that content type is changed to text/plain. The runtime also lacks the Web Worker and Node vm APIs.

10. What it costs

The prices below come from the Supabase pricing page, checked on October 18, 2026. Plans are billed per organization, and every project has its own Postgres compute instance. Check the current pricing page before budgeting because quotas and overage rates can change.

Item

Free

Pro

Team

Base price

$0

$25/month

$599/month

Database disk

500 MB

8 GB per project, then $0.125/GB

Same as Pro

Monthly active users

50,000

100,000, then $0.00325 each

Same as Pro

Uncached egress

5 GB

250 GB, then $0.09/GB

Same as Pro

Cached egress

5 GB

250 GB, then $0.03/GB

Same as Pro

File storage

1 GB

100 GB, then $0.0213/GB

Same as Pro

Edge Function invocations

500,000

2 million, then $2/million

Same as Pro

Realtime peak connections

200

500, then $10 per 1,000

Same as Pro

Realtime messages

2 million

5 million, then $2.50/million

Same as Pro

Daily backups

None

7 days

14 days

Pausing

After one week of inactivity

No inactivity pause

No inactivity pause

Support

Community

Email

Priority email with SLAs

Compliance

SOC 2 and ISO 27001 not included

Not included

SOC 2 and ISO 27001 included; HIPAA available as a paid add-on

Source: Supabase pricing. Confirm plan-specific compliance terms and availability before relying on them for procurement or a regulatory decision.

Compute can make the final bill higher than the plan's headline price. Paid plans include $10 per month in compute credits, enough to cover one Micro instance. Compute itself is billed hourly.

Compute size

Approx. monthly price

Memory

CPU

Direct connections

Pooler connections

Micro

$10

1 GB

Shared

60

200

Small

$15

2 GB

Shared

90

400

Medium

$60

4 GB

Shared

120

600

Large

$110

8 GB

2 dedicated vCPUs

160

800

XL

$210

16 GB

4 dedicated vCPUs

240

1,000

These are the listed connection figures for the compute sizes shown; check the current pricing page for available sizes and exact limits. Connection counts are not a target pool size: keep application pools comfortably below database limits and account for all instances.

The following estimates use the listed prices: plan fee plus compute cost minus the $10 compute credit. They exclude overages, taxes and egress.

Setup

Calculation

Estimated monthly base

Pro with Micro

$25 + $10 - $10

$25

Pro with Small

$25 + $15 - $10

$30

Pro with Micro for production and a second Micro project for staging

$25 + $10 + $10 - $10

$35

Pro with Medium

$25 + $60 - $10

$75

Pro with Large

$25 + $110 - $10

$125

Pro with Large plus point-in-time recovery

$125 + $100

$225

Three more details affect your bill. The Pro spend cap is enabled by default, so check how your organization handles overages before relying on automatic scaling. Point-in-time recovery starts at $100 per month for seven days of retention, and custom domains add $10 per month per project. The Free plan suits prototypes and small experiments: projects may pause after a week of inactivity, only a limited number of projects can be active, and automatic backups are not included. Confirm these details on the current pricing page before launch.

11. Dated changes to put on your calendar

These dates come from the Supabase changelog and API keys guide. Confirm each deadline in the linked announcement before acting.

Date

Change

Action

2026-10-30

Data API default-grant change applies to existing projects

Audit table access and include grants in migrations

2026-12-01

Hono, H3, Elysia and NestJS adapters in @supabase/server are removed

Move to the supported framework bridges

By the end of 2026

Legacy anon and service_role keys are deprecated

Migrate to publishable and secret keys

2027-01-31

supabase-js requires TypeScript 5.0 or later

Upgrade older TypeScript toolchains

The framework-adapter date and TypeScript requirement are documented in the adapter deprecation notice and supabase-js deprecation notice. Keep Node.js requirements and supported framework integrations in view when upgrading client libraries.

Two database projects are worth watching, but avoid relying on them in production too soon. Multigres, an open-source Postgres system with multi-node failover and pooling, entered private alpha on October 1. Its announcement says it is not production-ready and has no uptime SLA; check its current status before use. OrioleDB reached public beta around the same time. Evaluate both only when their support and limitations meet your needs.

12. Where Supabase is the wrong tool

Postgres Changes may not suit workloads that send each database event to many subscribers; Section 8 explains when to use Broadcast instead. CPU-heavy or long-running jobs can also exceed Edge Functions' limits. Run those jobs on a worker or job runner designed for them.

If your service requires automatic multi-node failover, do not build production around Multigres until it is generally available and offers guarantees that meet your needs.

One advantage is portability. Supabase uses standard Postgres and familiar tools, so you can migrate if your needs change. Grants, RLS, SQL migrations and connection-pooling practices also apply to other Postgres hosts.

13. Pre-launch checklist

Check

How

Reference

RLS on every exposed table

Run the catalog audit in Section 1

RLS guide

Explicit grants for each role

Grant only required operations; test them

Securing your API

Tables with zero policies reviewed

Use the LEFT JOIN query in Section 1

RLS guide

Views and functions audited separately

Review invoker security, grants and execute privileges

Views guide

Policy tests pass

Test both successful and denied actions with supabase test db

RLS guide

Security-definer functions constrained

Pin search_path, qualify names and restrict EXECUTE

Database functions guide

No policy relies on user-editable metadata

Use trusted claims or database membership checks

RLS guide

Publishable and secret keys used correctly

Publishable keys in clients; secret keys only on trusted servers

API keys guide

Legacy keys deactivated safely

Check last use and test before deactivation

API keys guide

JWT verification matches signing mode

Validate issuer and use JWKS only for asymmetric keys

JWT guide

Connection mode matches runtime and driver

Test transaction-mode limitations and driver-specific behavior

Connection guide

Realtime fan-out is designed deliberately

Evaluate Broadcast and private-channel authorization

Realtime authorization

Costs are understood

Estimate compute, storage, uncached/cached egress, overages and recovery

Pricing page

Security Advisor reviewed

Resolve findings or document why a finding is acceptable

Securing your API

First, make explicit grants part of migrations before October 30, 2026. Then migrate legacy API keys before year-end and confirm that each database driver works with its connection mode. Once the security and reliability basics are in place, tune performance and costs.

Comments(0)

Join the discussion by logging into your account.

No comments yet. Be the first to comment!

Shobit Singh

Passionate developer sharing knowledge about modern web technologies and best practices.

Subscribe to Shobit Singh's Newsletter

Direct email dispatches when new stories are published. Zero algorithms.