Supabase auto-generates a public REST API from your database schema, which means every table in the public schema is accessible by default. To lock it down, enable Row Level Security on every table, restrict the anon role, configure CORS headers, and add rate limiting via Edge Functions. These layers together ensure your API only serves authorized data to authorized users.
Locking Down Supabase's Auto-Generated REST API
Supabase uses PostgREST to automatically generate a REST API from your public schema. This is powerful for rapid development but means any table without RLS is fully readable and writable by anyone with your anon key. This tutorial walks through enabling RLS, restricting roles, configuring CORS, and adding rate limiting so your API is production-ready and secure.
Prerequisites
- A Supabase project with at least one table in the public schema
- Basic understanding of SQL and REST APIs
- Supabase CLI installed for Edge Function deployment
- Access to the Supabase Dashboard
Step-by-step guide
Enable Row Level Security on every public table
Enable Row Level Security on every public table
Row Level Security is the primary defense for your Supabase API. When RLS is enabled on a table with no policies, the API returns zero rows instead of all rows. Go to the Supabase Dashboard, open the SQL Editor, and run the ALTER TABLE command for each table. This is the single most important step — without it, your anon key grants full read and write access to the table via the REST API.
1-- Enable RLS on all your public tables2alter table public.profiles enable row level security;3alter table public.posts enable row level security;4alter table public.comments enable row level security;56-- Verify RLS is enabled7select tablename, rowsecurity8from pg_tables9where schemaname = 'public';Expected result: All tables in the public schema have RLS enabled. API requests without matching policies return empty arrays instead of all data.
Write targeted RLS policies for each operation
Write targeted RLS policies for each operation
After enabling RLS, you need explicit policies to allow legitimate access. Create separate policies for SELECT, INSERT, UPDATE, and DELETE. Use auth.uid() to scope operations to the authenticated user. The anon role should only have SELECT policies on truly public data. Never grant INSERT, UPDATE, or DELETE to the anon role unless you have a specific use case like anonymous feedback forms.
1-- Public read access (anon + authenticated)2create policy "Anyone can read posts"3 on public.posts for select4 to anon, authenticated5 using (published = true);67-- Authenticated users can insert their own posts8create policy "Users can create their own posts"9 on public.posts for insert10 to authenticated11 with check ((select auth.uid()) = author_id);1213-- Users can only update their own posts14create policy "Users can update own posts"15 on public.posts for update16 to authenticated17 using ((select auth.uid()) = author_id)18 with check ((select auth.uid()) = author_id);1920-- Users can only delete their own posts21create policy "Users can delete own posts"22 on public.posts for delete23 to authenticated24 using ((select auth.uid()) = author_id);Expected result: Anonymous users can only read published posts. Authenticated users can create, update, and delete only their own posts.
Restrict which schemas and tables are exposed to the API
Restrict which schemas and tables are exposed to the API
By default, Supabase exposes the public schema via the REST API. If you have internal tables that should never be accessible via the API, move them to a different schema. You can also revoke permissions from the anon and authenticated roles on specific tables. This adds defense in depth beyond RLS — even if a policy is misconfigured, the role cannot access the table.
1-- Create a private schema for internal tables2create schema if not exists private;34-- Move sensitive tables out of public5alter table public.internal_logs set schema private;67-- Or revoke all API access to a specific public table8revoke all on public.admin_settings from anon;9revoke all on public.admin_settings from authenticated;Expected result: Internal tables are no longer accessible through the REST API. Only tables you explicitly want to expose remain in the public schema with appropriate RLS policies.
Configure CORS to restrict API access by origin
Configure CORS to restrict API access by origin
Supabase's REST API allows requests from any origin by default. While CORS is a browser-level protection and does not prevent server-to-server abuse, it stops unauthorized websites from making requests on behalf of your users. For Edge Functions, you must set CORS headers manually. For the main REST API, CORS is handled by Supabase's API gateway, but you should ensure your application only sends the anon key from trusted domains.
1// supabase/functions/_shared/cors.ts2export const corsHeaders = {3 'Access-Control-Allow-Origin': 'https://yourdomain.com',4 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',5 'Access-Control-Allow-Methods': 'POST, GET, OPTIONS',6};78// In your Edge Function9import { corsHeaders } from '../_shared/cors.ts';1011Deno.serve(async (req) => {12 if (req.method === 'OPTIONS') {13 return new Response('ok', { headers: corsHeaders });14 }1516 // Your logic here17 return new Response(JSON.stringify({ ok: true }), {18 headers: { ...corsHeaders, 'Content-Type': 'application/json' },19 });20});Expected result: Edge Functions only accept requests from your specified domain. Browsers block cross-origin requests from unauthorized sites.
Add rate limiting with an Edge Function proxy
Add rate limiting with an Edge Function proxy
Supabase has built-in rate limits, but they may not be granular enough for your needs. You can build a lightweight rate limiter using an Edge Function that tracks request counts in a Supabase table or uses in-memory counters. Route sensitive operations through this Edge Function instead of calling the REST API directly. This prevents abuse from bots or scrapers that might hammer your API with the publicly available anon key.
1// supabase/functions/rate-limited-api/index.ts2import { createClient } from 'npm:@supabase/supabase-js@2';3import { corsHeaders } from '../_shared/cors.ts';45const rateLimits = new Map<string, { count: number; resetAt: number }>();67Deno.serve(async (req) => {8 if (req.method === 'OPTIONS') {9 return new Response('ok', { headers: corsHeaders });10 }1112 const clientIp = req.headers.get('x-forwarded-for') || 'unknown';13 const now = Date.now();14 const limit = rateLimits.get(clientIp);1516 if (limit && limit.resetAt > now && limit.count >= 100) {17 return new Response(18 JSON.stringify({ error: 'Rate limit exceeded' }),19 { status: 429, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }20 );21 }2223 if (!limit || limit.resetAt <= now) {24 rateLimits.set(clientIp, { count: 1, resetAt: now + 60000 });25 } else {26 limit.count++;27 }2829 // Forward to Supabase with service role for server-side operations30 const supabase = createClient(31 Deno.env.get('SUPABASE_URL')!,32 Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!33 );3435 const { data, error } = await supabase.from('posts').select('*').limit(20);36 return new Response(JSON.stringify(data), {37 headers: { ...corsHeaders, 'Content-Type': 'application/json' },38 });39});Expected result: Clients making more than 100 requests per minute receive a 429 response. Legitimate traffic passes through to the database.
Audit your security setup and test with the anon key
Audit your security setup and test with the anon key
After applying all security measures, test your API by making direct HTTP requests using just the anon key. Use curl or a REST client to attempt reads, inserts, updates, and deletes on each table. Verify that unauthorized operations return empty arrays or errors. Check that your RLS policies do not accidentally expose data through joins or views. Review the Supabase Dashboard's Auth Policies panel for a visual overview of all policies.
1# Test anonymous read access (should return only published posts)2curl 'https://YOUR_PROJECT.supabase.co/rest/v1/posts?select=*&published=eq.true' \3 -H 'apikey: YOUR_ANON_KEY' \4 -H 'Authorization: Bearer YOUR_ANON_KEY'56# Test anonymous insert (should fail with empty result)7curl -X POST 'https://YOUR_PROJECT.supabase.co/rest/v1/posts' \8 -H 'apikey: YOUR_ANON_KEY' \9 -H 'Authorization: Bearer YOUR_ANON_KEY' \10 -H 'Content-Type: application/json' \11 -d '{"title": "hack", "author_id": "fake-uuid"}'Expected result: Anonymous reads return only published data. Anonymous writes are blocked. Authenticated users can only access their own data.
Complete working example
1-- ============================================2-- Secure Public API Endpoints in Supabase3-- Run in Supabase SQL Editor4-- ============================================56-- 1. Enable RLS on all public tables7alter table public.profiles enable row level security;8alter table public.posts enable row level security;9alter table public.comments enable row level security;1011-- 2. Profiles: users can read any profile, edit only their own12create policy "Public profiles are readable"13 on public.profiles for select14 to anon, authenticated15 using (true);1617create policy "Users can update own profile"18 on public.profiles for update19 to authenticated20 using ((select auth.uid()) = id)21 with check ((select auth.uid()) = id);2223-- 3. Posts: public read for published, owner CRUD24create policy "Published posts are public"25 on public.posts for select26 to anon, authenticated27 using (published = true);2829create policy "Authors can insert posts"30 on public.posts for insert31 to authenticated32 with check ((select auth.uid()) = author_id);3334create policy "Authors can update own posts"35 on public.posts for update36 to authenticated37 using ((select auth.uid()) = author_id)38 with check ((select auth.uid()) = author_id);3940create policy "Authors can delete own posts"41 on public.posts for delete42 to authenticated43 using ((select auth.uid()) = author_id);4445-- 4. Add indexes on columns used in RLS policies46create index idx_posts_author_id on public.posts using btree (author_id);47create index idx_profiles_id on public.profiles using btree (id);4849-- 5. Revoke direct access to sensitive tables50revoke all on public.admin_settings from anon;51revoke all on public.admin_settings from authenticated;5253-- 6. Verify RLS is enabled everywhere54select tablename, rowsecurity55from pg_tables56where schemaname = 'public';Common mistakes when securing Public API Endpoints in Supabase
Why it's a problem: Forgetting to enable RLS on a new table, leaving it fully exposed via the REST API
How to avoid: Always enable RLS immediately after creating a table. Run a periodic audit query: SELECT tablename FROM pg_tables WHERE schemaname = 'public' AND NOT rowsecurity;
Why it's a problem: Using the service role key in client-side code, which bypasses all RLS policies
How to avoid: Only use SUPABASE_ANON_KEY in browsers and mobile apps. The service role key should only be used in server-side code like Edge Functions or backend APIs.
Why it's a problem: Granting INSERT or DELETE permissions to the anon role without a specific use case
How to avoid: Default to authenticated-only for all write operations. Only add anon write policies for explicit public features like anonymous feedback forms.
Why it's a problem: Creating views without security_invoker = true, which bypasses RLS
How to avoid: On Postgres 15+, always create views with: CREATE VIEW my_view WITH (security_invoker = true) AS ...;
Best practices
- Enable RLS on every table in the public schema immediately after creation — treat it as mandatory, not optional
- Use (select auth.uid()) instead of auth.uid() in policy expressions to enable per-statement caching
- Add btree indexes on columns referenced in RLS policies to avoid full table scans
- Move internal tables to a private schema so they are never exposed via the auto-generated REST API
- Restrict CORS to your specific production domain in Edge Functions instead of using wildcard origins
- Test your security by making API requests with just the anon key to verify unauthorized operations are blocked
- Never expose the SUPABASE_SERVICE_ROLE_KEY in client-side code — it bypasses all RLS policies
- Schedule monthly security audits to check for tables without RLS and overly permissive policies
Still stuck?
Copy one of these prompts to get a personalized, step-by-step explanation.
I have a Supabase project with tables for profiles, posts, and comments. Help me write RLS policies so that posts are publicly readable when published, but only the author can create, edit, and delete their own posts. Also show me how to restrict the anon role from writing to any table.
Set up Row Level Security on my posts table. Published posts should be readable by anyone. Only the authenticated author (matched by author_id = auth.uid()) can insert, update, or delete. Also add an index on author_id for policy performance.
Frequently asked questions
Is the Supabase anon key safe to expose in client-side code?
Yes, the anon key is designed to be public. It maps to the anon Postgres role, which is restricted by RLS policies. As long as RLS is enabled and your policies are correct, the anon key only grants the access you explicitly allow.
What happens if I enable RLS but do not create any policies?
All API access to that table is silently denied. SELECT queries return empty arrays, and INSERT, UPDATE, DELETE operations silently fail. This is secure by default but can be confusing when you first set it up.
Can someone bypass RLS by calling the REST API directly?
No, RLS is enforced at the PostgreSQL level. Whether the request comes from the Supabase JS client, a direct HTTP call, or any other method, the same RLS policies apply as long as the request uses the anon key or an authenticated JWT.
Should I use CORS to protect my Supabase API?
CORS is a browser-level protection and does not prevent server-to-server attacks. It helps prevent unauthorized websites from making requests on behalf of your users. Always combine CORS with RLS for comprehensive security.
How do I find tables that do not have RLS enabled?
Run this query in the SQL Editor: SELECT tablename FROM pg_tables WHERE schemaname = 'public' AND NOT rowsecurity; Any table returned needs RLS enabled immediately.
Can RapidDev help audit and secure my Supabase API endpoints?
Yes, RapidDev can review your RLS policies, API configuration, and overall Supabase security posture. They specialize in helping teams get production-ready security configurations right the first time.
What is the difference between the anon key and the service role key?
The anon key maps to the anon Postgres role and respects all RLS policies. The service role key bypasses RLS entirely and has full database access. Never use the service role key in client-side code — it should only be used in server-side environments like Edge Functions.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation