Skip to main content

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.

Updated 32 min readMarkdown
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.

Mermaid Diagram

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.


Published via ZyVOP — Write once in Markdown, auto-backup to GitHub, and syndicate to Dev.to, Medium & Hashnode in 1 click.

About the author

  • One platform to write, cross-post to Dev.to, Medium, WordPress, Hashnode, and Bluesky, and protect your SEO with canonical links. Zero paywalls and full content ownership.

No comments yet. Be the first to comment.