Skip to content

Security Checklist and Production Readiness

A production-ready Supabase project is not any single setting done right — it is Row Level Security, deliberate Data API exposure, a service role key kept server-side, indexed policy columns, Supavisor in the right mode, migrations flowing through CI, and backups with point-in-time recovery, all holding together at once around one Postgres database.

Each of these was covered earlier in the course. On its own, skipping any one of them does not necessarily break anything visibly — that is exactly what makes them easy to skip, and exactly why they belong on a checklist rather than left to memory.

  • Row Level Security is enabled on every table holding user data, with policies for every operation you actually need. Enabling RLS with only a select policy silently makes every insert, update, and delete fail for ordinary users, since a missing policy for an operation denies it by default — covered in the Auth and RLS module.
  • Data API exposure is deliberate, per table and per function, not left on by default without thought. A table that does not need to be reachable over the REST or RPC interface should not be, since anything exposed there is reachable by anyone who can craft a valid request — covered in the Auto-Generated API module.
  • The service_role key never appears in client-side code. It bypasses RLS entirely, so it belongs only in trusted server-side contexts like Edge Functions, never shipped to a browser or mobile app bundle — covered in the Foundations and Edge Functions modules.
  • Indexes exist on columns referenced by RLS policies, and on other frequently filtered or joined columns. An unindexed policy column runs a full evaluation pass over every row on every query, which is fine on a small table and a production incident on a large one — covered in the previous lesson.
  • Supavisor runs in transaction mode for serverless and edge database access, with direct or session-mode connections reserved for cases that genuinely need them. Serverless and Edge Function traffic opens far more concurrent connections than Postgres can hold directly, which is exactly the problem the pooler exists to solve — covered in the first lesson of this module.
  • Migrations are tracked in version control and applied through CI, not run ad hoc against production. A migration file merged to the main branch should deploy the same way every time, through the same pipeline that already tested it on a preview branch — covered in the previous lesson.
  • Backups and point-in-time recovery are enabled for anything that matters in production. Point-in-time recovery, or PITR, lets you restore a project to any specific moment within its retention window, not just to the most recent daily backup, which matters when the incident you need to undo happened hours after the last snapshot was taken.
-- A quick self-check: any table with RLS enabled but zero policies for a given command
-- denies that command entirely rather than allowing it, so gaps here fail closed, not open
select relname as table_name, relrowsecurity as rls_enabled
from pg_class
where relnamespace = 'public'::regnamespace
and relkind = 'r';

Run for real against this course’s shared local stack — a genuine, unfiltered snapshot, not a staged example:

$ docker exec -i supabase_db_probe psql -U postgres -c "
select relname as table_name, relrowsecurity as rls_enabled
from pg_class
where relnamespace = 'public'::regnamespace and relkind = 'r'
order by relrowsecurity asc, relname;"
table_name | rls_enabled
------------------+-------------
fnd_courses | f
fnd_widgets | f
rt_items | f
rt_posts | f
cli_widget_notes | t
cli_widgets | t
rt_notes | t

A security definer function in an exposed schema runs with its owner’s privileges regardless of who calls it — the Auto-Generated API module proves the schema-injection risk this creates in depth; this checklist’s job is finding every function that needs that scrutiny in the first place:

$ docker exec -i supabase_db_probe psql -U postgres -c "
select p.proname as function_name, p.prosecdef as is_security_definer, coalesce(p.proconfig, '{}') as proconfig
from pg_proc p join pg_namespace n on n.oid = p.pronamespace
where n.nspname = 'public' and p.prosecdef
order by p.proname;"
function_name | is_security_definer | proconfig
--------------------+----------------------+----------------------
rt_posts_broadcast | t | {"search_path=\"\""}

One security definer function exists on this shared stack, and proconfig proves it was written correctly — search_path="" is set, exactly the hardening this checklist (and the RPC lesson it links to) requires. An audit query like this belongs in CI or a scheduled check precisely because it is easy to add a new security definer function later without remembering the search_path rule; the query does not care whether the human remembered.

$ grep -A6 '\[api\]' supabase/config.toml
[api]
enabled = true
port = 55321
schemas = ["public", "graphql_public"]
extra_search_path = ["public", "extensions"]

Only public and graphql_public are reachable through the Data API on this stack — the default, and the right default for most projects. A schema added here without a specific reason widens the Data API’s attack surface by definition, since Securing the Data API already proves that anything reachable through the Data API is one grant away from being queryable by anon.

$ supabase db lint --db-url "postgresql://postgres:[email protected]:55322/postgres?sslmode=disable" --schema public --level warning
Linting schema: public
No schema errors found
{"results":[],"message":"db lint"}

Clean today — the same command the Foundations migrations lesson proves in depth, worth running as its own CI gate (--fail-on error) specifically because a schema typing error is exactly the kind of thing a human reviewer skims past in a large migration diff.

Async writes and webhooks — proven elsewhere, linked here

Section titled “Async writes and webhooks — proven elsewhere, linked here”

pg_net’s asynchronous behavior and Database Webhooks calling an Edge Function from a database trigger are both proven in full, with real timing (net._http_response arriving milliseconds after the triggering insert returns) and pg_cron scheduling, in Background Tasks and Webhooks with Functions — a production checklist item (“does this project rely on outbound HTTP from the database, and does that receiver handle retries idempotently”) rather than a proof repeated here.

Backups, point-in-time recovery, and compute — docs, cited

Section titled “Backups, point-in-time recovery, and compute — docs, cited”

None of these three are locally provable — they depend on a hosted, billed project — but all three are as real a part of “production ready” as anything proven above:

  • Point-in-time recovery (PITR) lets a project restore to any specific moment within its retention window, not just the latest daily backup — available as an add-on since the original 2022 launch, documented today at supabase.com/docs/guides/platform/backups, which also covers listing and restoring backups through the Management API.
  • Compute add-ons are not just a CPU/RAM dial — they change real, documented connection limits too. Per Supabase’s own pricing data: Micro (the default, 1GB RAM, 2-core) allows 60 direct / 200 pooler connections; Small (2GB) allows 90 direct / 400 pooler; Medium (4GB) 120 direct / 600 pooler; Large (8GB, dedicated) 160 direct / 800 pooler. The pool-size math from Connection Pooling and Supavisor is not a fixed constant — it scales with the compute add-on a project is actually running.
  • Supabase’s own Production Checklist is the canonical, maintained reference for all of this — rate limits, custom domains, upgrading Postgres versions, and every item this lesson’s own checklist compresses into one page. Read it directly before a real launch; this lesson’s checklist is a teaching summary, not a replacement for it.

Picture a real app: a task manager with a todos table, RLS enabled with policies scoped to auth.uid() = user_id, and an index on that same user_id column so the policy check stays fast as the table grows. Only the todos table and the create_todo RPC function are exposed through the Data API — nothing else in the schema is reachable over REST. The app’s serverless backend connects through Supavisor in transaction mode, so a burst of concurrent requests never comes close to exhausting Postgres’s connection limit. An Edge Function handles the one operation that genuinely needs elevated access — say, an admin cleanup job — using the service_role key, which exists only in that function’s environment and nowhere in the client bundle. Every schema change, from the original create table to the index that came later, is a migration file that went through a preview branch and was applied to production by CI, not by someone pasting SQL into a dashboard. And point-in-time recovery is enabled, so a bad migration or an accidental bulk delete is a restore away from being undone, not a permanent loss.

None of these pieces is exotic on its own. What makes the project production-ready is that they hold together at the same time, around the same database, instead of being true in isolation while the rest of the checklist quietly does not apply.

flowchart TD
  db[("Postgres database")]
  rls["RLS policies scoped to auth.uid()"] --> db
  idx["Index on RLS policy columns"] --> db
  api["Data API - only todos table and create_todo RPC exposed"] --> db
  pool["Serverless backend via Supavisor - transaction mode"] --> db
  fn["Edge Function using service_role key for admin tasks"] --> db
  ci["Migrations via preview branch and CI"] --> db
  pitr["Backups with point-in-time recovery"] --> db
Every piece of a production-ready Supabase project ties back to one Postgres database

An AI assistant, asked to “write the production readiness check for our RLS policies,” produced this:

🐛 Spot the bug

-- An AI assistant's "RLS is enabled everywhere" check
select count(*) as tables_without_rls
from pg_class
where relnamespace = 'public'::regnamespace
and relkind = 'r'
and not relrowsecurity;
-- if this returns 0, we're good to ship
Show the answer

Run this against this course’s own shared stack and it returns a non-zero count — proved above, four real tables (two of which are this very lesson’s own scratch tables). The check itself is not wrong, but the comment attached to it is: “if this returns 0, we’re good to ship” treats RLS coverage as the entire security checklist, when it is one line item among the seven this lesson opens with. A table can have RLS enabled and still be wide open — the classic gap from the Auth and RLS module is enabling RLS with only a select policy and forgetting insert/update/delete, which this count-based check cannot see at all, since relrowsecurity is true either way. The fix is not a different query — it is not treating any single query’s zero as a green light. A real checklist needs this query, the security definer audit, the exposed-schemas check, and a per-table review of which operations actually have policies, all proven above, run together, not the first one alone with an “if 0, ship it” comment.

Treat “production ready” as a conjunction, not a checklist you tick off in sequence — every item proved or cited in this lesson has to hold true simultaneously, around the same database, for the whole thing to actually be safe. A project that aces the RLS audit but exposes an extra schema, or hardens every security definer function but runs Supavisor in the wrong mode for its traffic shape, is not “mostly production ready” — it has exactly the one gap an attacker or an outage will find. This is why this lesson runs real queries against a real stack rather than listing bullet points: a checklist item you cannot verify is not actually checked.

  • Running these audit queries in CI catches drift the moment it happens (a new table without RLS, a new unaudited security definer function) — at the cost of needing the queries to actually run somewhere automated, not just live in this lesson as a one-time manual exercise.
  • Compute add-ons buy higher connection limits and dedicated resources (proved above: Micro’s 60 direct connections versus Large’s 160) — at real, ongoing cost, and picking a size is inseparable from the pooling decisions in the earlier lesson — more compute does not substitute for choosing transaction mode where it is needed.
  • PITR makes almost any mistake reversible — at the cost of only protecting what happened within the retention window, and it protects data, not access control: a leaked service_role key or a missing RLS policy is not something PITR undoes.
  • “If the RLS-coverage query returns zero, the project is secure.” Disproved directly above: this course’s own shared stack fails that exact check today, and even a table that passes it can still have an insert/update/delete gap the query cannot see.
  • “A security definer function is inherently dangerous and should be avoided.” The audit above found exactly one, correctly hardened with search_path="" — the function itself is a legitimate, documented pattern; the danger is specifically an unhardened one, which is why the audit checks proconfig, not just prosecdef.
  • “Backups alone make a project production ready.” Cited above: PITR restores data, not the access-control mistakes (an over-exposed schema, a missing policy) that made a restore necessary in the first place — this lesson’s other checks exist because a good backup strategy does not substitute for the rest of the checklist.
A table has Row Level Security enabled with only a select policy defined. What happens when an ordinary user tries to insert a row?
Why should the service_role key never appear in client-side code?
Why does running migrations through CI rather than manually matter for production readiness?
What does point-in-time recovery, or PITR, specifically let you do that a daily backup alone does not?
An AI-generated "production readiness" check counts public tables with RLS disabled and comments "if this returns 0, we're good to ship." What is wrong with treating that single query as sufficient?