Securing your API

Lock down a SupaHost project's Data API with schemas, grants and Row Level Security, control function access, and understand the apikey check and the 1000-row limit.

Your project's Data API is on the public internet, and the anon key that opens it is inside your app for anyone to read. That's how Supabase is designed, and it's safe as long as the database itself decides what each caller may do. This page goes through the layers in the order a request meets them.

The layers

Layer Decides Where you set it
API key check Whether the request is let in at all Fixed by SupaHost's gateway
Exposed schemas Which schemas have endpoints Fixed: public, storage, graphql_public, pgmq_public
Grants Whether a role may touch a table, view or function SQL grant / revoke
Row Level Security Which rows a role and user may see or change SQL policies

Get the last two right and the anon key being public doesn't matter.

1. The API key check

SupaHost's gateway (Caddy) rejects any request to the project URL that carries no key:

HTTP/1.1 401
{"message":"No API key found in request"}

A key is accepted in the apikey header or the ?apikey= query parameter. If apikey arrives without an Authorization header, the gateway copies it into Authorization: Bearer <key>, and PostgREST runs the request as that key's role: anon for the anon key, service_role for the service_role key. A signed-in user's request carries their access token in Authorization, which takes precedence.

Some paths skip the key check because they're opened by browsers or other services directly: Auth's verify, callback and authorize links, /.well-known/*, public Storage objects, and /functions/v1/*. Edge Functions do their own check instead: with "Require JWT" on, any valid JWT for the project is accepted, including the anon key, so check the user inside the function when it matters. See Authentication and JWT verification.

The key check only proves the caller has your anon key, which everyone does. Treat it as a gate against random traffic, not as access control.

2. Keep internals out of exposed schemas

Everything in public gets an endpoint. Tables and functions that only your backend, triggers or cron jobs use belong in a schema the Data API doesn't serve:

create schema if not exists private;

create table private.stripe_events (
  id text primary key,
  payload jsonb not null,
  received_at timestamptz not null default now()
);

The list of exposed schemas can't be changed on SupaHost, which works in your favour here: a new schema is private unless you deliberately expose its data through a view or function in public. See Exposed schemas.

3. Grants: who may touch an object

In the standard Supabase image, new tables, views and sequences in public are granted to anon, authenticated and service_role automatically, and every new function is executable by everyone. That's convenient, but it means a new table is reachable the moment it exists. Narrow it when a role shouldn't have access:

-- visitors may read rooms but never change them
revoke insert, update, delete, truncate on public.rooms from anon;

-- a table only signed-in users may use
revoke all on public.bookings from anon;

-- a function only signed-in users may call
revoke execute on function public.cancel_booking(bigint) from public, anon;
grant execute on function public.cancel_booking(bigint) to authenticated;

Check what a role can do:

select table_name, string_agg(privilege_type, ', ' order by privilege_type) as privileges
from information_schema.role_table_grants
where table_schema = 'public' and grantee = 'anon'
group by table_name
order by table_name;

To stop granting new tables to anon and authenticated automatically, change the defaults for tables the postgres role creates from now on (existing tables keep their grants):

alter default privileges for role postgres in schema public
  revoke all on tables from anon, authenticated;

After that, grant each new table explicitly in the migration that creates it.

4. Row Level Security: which rows

Grants are all-or-nothing per table. RLS filters by row. Turn it on for every table in public, then add policies:

alter table public.bookings enable row level security;

create policy "Users see their own bookings"
  on public.bookings for select
  to authenticated
  using ((select auth.uid()) = user_id);

With RLS on and no policy, anon and authenticated get nothing. Requests with the service_role key bypass RLS. Full guide: Row Level Security.

Remember that authenticated means "anyone who has signed up". If your app is invite-only, turn on Disable new sign-ups under your project → Auth tab → Sign-in & security, or write policies that check membership rather than just being signed in.

5. Views and functions

  • Views bypass RLS by default because they run as their owner. Create them with (security_invoker = true) so the caller's policies apply.
  • security definer functions in public can be called by anyone with the anon key and skip RLS. Keep them in private, or check auth.uid() inside and revoke execute from anon. See security definer functions and RLS.

The 1000-row limit

The Data API never returns more than 1000 rows per request, however the query is written. Larger results are cut off, and clients page through with .range(). That limits how much one request can pull, but it isn't access control: a client can still request page after page. Only grants and RLS decide what can be read.

Audit your setup

  • Ask an AI agent connected over MCP to run get_advisors. It reports tables in public with RLS disabled (rls_disabled), with RLS on but no policies (rls_no_policies), and functions without a fixed search path (function_search_path_mutable).
  • Try your own API as a stranger: call each table with only the anon key and confirm you get [] or a permission error.
curl "https://app-one.acme.supahost.space/rest/v1/bookings?select=*" \
  -H "apikey: $SUPABASE_ANON_KEY"
# expect [] (RLS) or a 401 / 42501 permission error (grants)

Updated 11 November 2026 · View as Markdown · Something wrong or missing? Tell us