Securing your API
This guide explains how to secure the Data API with Postgres grants, Row Level Security, dedicated schemas, and request checks.
Use the guide in two parts:
- Understand Data API security explains how the controls work and when to use them.
- Configure Data API security groups the procedures for applying those controls.
Read the first section when you need to choose a security approach. Go directly to the second section when you know which controls you need to configure.
Understand Data API security#
This section provides the context for the procedures later in the guide.
Grants and RLS#
The Data API works with two layers of Postgres access control:
- Grants determine which Postgres roles can reach a table, view, or function over the Data API. These roles include
anon,authenticated, andservice_role. - Row Level Security (RLS) policies determine which rows those roles can read or modify.
Grants control whether a role can access an object. RLS controls which rows the role can access. Use both controls for every exposed object.
To apply these controls, see Grant access explicitly and Enable RLS policies.
Default privileges#
On existing projects, tables created in public receive SELECT, INSERT, UPDATE, and DELETE privileges for anon, authenticated, and service_role by default. Functions receive EXECUTE. These grants make new objects reachable through the Data API, even when you don't intend to expose them.
Supabase is changing the platform default to revoke these automatic grants so that exposure becomes opt-in. See the platform defaults discussion in the Supabase GitHub discussions.
The default privileges are part of the standard Supabase permission model and don't bypass RLS. The internal supabase_admin role grants them to anon, authenticated, and service_role, but it can't authenticate through the Data API. See pg_default_acl in the Postgres documentation and supabase_admin in the Supabase documentation.
To prevent automatic grants on new objects, see Revoke default privileges.
Dedicated API schemas#
A dedicated schema adds another boundary around your Data API. Objects in a schema such as api define the API surface. Internal tables and helper functions remain in schemas that aren't exposed.
You can control access with grants in any schema. A dedicated schema makes the exposed surface easier to identify and audit. See Using Custom Schemas for setup steps.
Pre-request checks#
RLS policies don't cover every API security requirement. Add pre-request checks for requirements such as:
- Enforcing per-IP or per-user rate limits.
- Checking custom or additional API keys before allowing further access.
- Rejecting requests after exceeding a quota or requiring payment.
- Disallowing direct access to certain tables, views, or functions in exposed schemas.
A Postgres pre-request function reads request information and performs these checks before serving a response. For example, the function can count requests or verify an API key.
To add a check, see Configure a pre-request function.
The pgrst.db_pre_request configuration only works with the Data API (PostgREST). It does not work with Realtime, Storage, or other Supabase products.
If you're using db_pre_request to call a function (like set_information()) that sets up context or performs checks on every request, and you need similar behavior for other Supabase products, you must call the function directly in your Row Level Security (RLS) policies instead.
Example:
If you have a db_pre_request function that calls set_information() that returns true to set up context or perform checks, and you have an RLS policy like:
1create policy "Individuals can view their own todos."2on todos for select3using ( (select auth.uid()) = user_id );To achieve the same behavior with other Supabase products, you need to call the function directly in your RLS policy:
1create policy "Individuals can view their own todos."2on todos for select3using ( set_information() AND (select auth.uid()) = user_id );This ensures the function is called when evaluating RLS policies for all products, not only Data API requests.
Performance consideration:
Be aware that calling functions directly in RLS policies can impact database performance, as the function is evaluated for each row when the policy is checked. Consider optimizing your function or using caching strategies if performance becomes an issue.
Request information#
Use the Postgres current_setting() function to access request information:
1-- Get all headers sent in the request2select current_setting('request.headers', true)::json;34-- Get one header with a JSON arrow operator5select current_setting('request.headers', true)::json->>'user-agent';67-- Get cookies8select current_setting('request.cookies', true)::json;current_setting() | Example | Description |
|---|---|---|
request.method | GET, HEAD, POST, PUT, PATCH, DELETE | Request's method |
request.path | table | Table's path |
request.path | view | View's path |
request.path | rpc/function | Function's path |
request.headers | { "User-Agent": "...", ... } | JSON object of the request's headers |
request.cookies | { "cookieA": "...", "cookieB": "..." } | JSON object of the request's cookies |
request.jwt | { "sub": "a7194ea3-...", ... } | JSON object of the JWT payload |
To access the client's IP address, look up the X-Forwarded-For header in the request.headers setting:
1select split_part(2 current_setting('request.headers', true)::json->>'x-forwarded-for',3 ',', 1); -- takes the client IP before the first commaSee Pre-request in the PostgREST documentation and X-Forwarded-For in the MDN documentation.
For complete implementations that use this request information, see Pre-request examples.
Error responses#
A pre-request function can raise an exception to stop a request. This example returns an HTTP 402 Payment Required response with a hint and an X-Powered-By header:
1raise sqlstate 'PGRST' using2 message = json_build_object(3 'code', '123',4 'message', 'Payment Required',5 'details', 'Quota exceeded',6 'hint', 'Upgrade your plan')::text,7 detail = json_build_object(8 'status', 402,9 'headers', json_build_object(10 'X-Powered-By', 'Nerd Rage'))::text;The exception produces this HTTP response:
1HTTP/1.1 402 Payment Required2Content-Type: application/json; charset=utf-83X-Powered-By: Nerd Rage45{6 "message": "Payment Required",7 "details": "Quota exceeded",8 "hint": "Upgrade your plan",9 "code": "123"10}Use JSON functions and operators to build dynamic responses from exceptions. Include the status_text key in the detail clause when you use a custom HTTP status code such as 419. See JSON Functions and Operators in the Postgres documentation.
For PostgREST 11 or earlier, use the legacy syntax for raising errors. Check your PostgREST version in the Dashboard. See Raise errors with HTTP status codes in the PostgREST documentation.
Configure Data API security#
This section groups the procedures for configuring each security control. Apply the procedures that match your architecture.
Grant access explicitly#
A table isn't reachable through the Data API unless you have granted a role privileges on it. Grant the minimum privileges each role needs. For example:
1-- Read-only access for anonymous clients2grant select on table public.your_table to anon;34-- Full access for signed-in users; RLS still applies5grant select, insert, update, delete on table public.your_table to authenticated;67-- Full access for server-side code using the service role8grant select, insert, update, delete on table public.your_table to service_role;910-- For functions, grant EXECUTE to the roles that should call them11grant execute on function public.your_function() to anon, authenticated;If a required grant is missing, PostgREST returns a 42501 error with a hint that names the exact GRANT statement you need:
1{2 "code": "42501",3 "message": "permission denied for table your_table",4 "hint": "Grant the required privileges to the current role with: GRANT SELECT ON public.your_table TO anon;"5}See Database API 42501 errors for the full troubleshooting flow.
Migration: Bundle grants with your RLS setup in the same migration. The grant command controls role access. The enable row level security command and policies control row access.
Revoke default privileges#
Revoke automatic grants when you want new objects in public to remain inaccessible until you grant access:
-
Open the SQL Editor.
-
Run the following statements:
1alter default privileges for role postgres in schema public2revoke select, insert, update, delete on tables from anon, authenticated, service_role;34alter default privileges for role postgres in schema public5revoke execute on functions from anon, authenticated, service_role;67alter default privileges for role postgres in schema public8revoke usage, select on sequences from anon, authenticated, service_role;910alter default privileges for role postgres in schema public11revoke execute on functions from public;
New tables, functions, and sequences now require explicit grants before Data API roles can access them.
Disable the Data API#
If your app never uses Supabase client libraries, REST, or GraphQL data endpoints, turn the Data API off:
- Open the Data API integration overview in the Dashboard.
- Turn Enable Data API off.
With the Data API disabled, none of the auto-generated REST endpoints respond, regardless of grants or RLS.
Enable RLS policies#
Tables and views exposed through the Data API without RLS can be accessed by any role with matching grants. Enable RLS or add equivalent controls to prevent unauthorized access. RLS doesn't apply to functions, so grant EXECUTE only to the roles that need to call them. Review every SECURITY DEFINER function carefully.
Enable RLS on every table and view exposed through the Data API. You can then write policies that grant users access to specific rows based on their authentication token.
Tables created through the Supabase Dashboard have RLS enabled by default. Enable RLS explicitly for tables created in the SQL Editor or through another tool:
- Go to the Database > Policies page in the Dashboard.
- Select Enable RLS to enable Row Level Security.
With RLS enabled, create policies that control which data users can access and update. See Row Level Security.
Configure a pre-request function#
Create and register a Postgres function to run checks before each Data API request:
Before adding the check logic, review Request information and Error responses.
-
Create a pre-request function:
1create function public.check_request()2returns void3language plpgsql4security definer5as $$6begin7-- your logic here8end;9$$; -
Register the function to run on every Data API request:
1alter role authenticator2set pgrst.db_pre_request = 'public.check_request'; -
Reload the PostgREST configuration:
1notify pgrst, 'reload config';
The function now runs before every Data API request. Add the checks that match your security requirements.
Pre-request examples#
Use these examples after you configure the pre-request function. Each example replaces the placeholder logic with a complete request check.
You can only rate-limit POST, PUT, PATCH, and DELETE requests. GET and HEAD requests run in read-only mode. They can be served by Read Replicas, which don't support writing to the database.
Outcome:
- The
private.rate_limitstable records the IP address and timestamp of each write request. - The function rejects requests with an HTTP 420 response when an IP address makes more than 100 write requests in 5 minutes.
Create the table:
1create table private.rate_limits (2 ip inet,3 request_at timestamp4);56-- add an index so that lookups are fast7create index rate_limits_ip_request_at_idx on private.rate_limits (ip, request_at desc);The private schema prevents Data API access to the rate-limit records.
Create the request check: Create the public.check_request function:
1create function public.check_request()2 returns void3 language plpgsql4 security definer5 as $$6declare7 req_method text := current_setting('request.method', true);8 req_ip inet := split_part(9 current_setting('request.headers', true)::json->>'x-forwarded-for',10 ',', 1)::inet;11 count_in_five_mins integer;12begin13 if req_method = 'GET' or req_method = 'HEAD' or req_method is null then14 -- rate limiting can't be done on GET and HEAD requests15 return;16 end if;1718 select19 count(*) into count_in_five_mins20 from private.rate_limits21 where22 ip = req_ip and request_at between now() - interval '5 minutes' and now();2324 if count_in_five_mins > 100 then25 raise sqlstate 'PGRST' using26 message = json_build_object(27 'message', 'Rate limit exceeded, try again after a while')::text,28 detail = json_build_object(29 'status', 420,30 'status_text', 'Enhance Your Calm')::text;31 end if;3233 insert into private.rate_limits (ip, request_at) values (req_ip, now());34end;35 $$;Register the request check: Configure the public.check_request() function to run on every Data API request:
1alter role authenticator2 set pgrst.db_pre_request = 'public.check_request';34notify pgrst, 'reload config';Clean up old records: Set up a pg_cron job to delete old entries from private.rate_limits.