Skip to main content
RapidDev - Software Development Agency
supabase-tutorial

How to Secure Public API Endpoints in Supabase

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.

What you'll learn

  • How Supabase exposes your database as a public REST API and why that requires security measures
  • How to enable and configure Row Level Security to restrict API access
  • How to set up CORS headers and API key restrictions
  • How to add rate limiting with Edge Functions
Book a free consultation
4.9Clutch rating
600+Happy partners
17+Countries served
190+Team members
Intermediate10 min read15-20 minSupabase (all plans), @supabase/supabase-js v2+March 2026RapidDev Engineering Team
TL;DR

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

1

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.

typescript
1-- Enable RLS on all your public tables
2alter table public.profiles enable row level security;
3alter table public.posts enable row level security;
4alter table public.comments enable row level security;
5
6-- Verify RLS is enabled
7select tablename, rowsecurity
8from pg_tables
9where 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.

2

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.

typescript
1-- Public read access (anon + authenticated)
2create policy "Anyone can read posts"
3 on public.posts for select
4 to anon, authenticated
5 using (published = true);
6
7-- Authenticated users can insert their own posts
8create policy "Users can create their own posts"
9 on public.posts for insert
10 to authenticated
11 with check ((select auth.uid()) = author_id);
12
13-- Users can only update their own posts
14create policy "Users can update own posts"
15 on public.posts for update
16 to authenticated
17 using ((select auth.uid()) = author_id)
18 with check ((select auth.uid()) = author_id);
19
20-- Users can only delete their own posts
21create policy "Users can delete own posts"
22 on public.posts for delete
23 to authenticated
24 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.

3

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.

typescript
1-- Create a private schema for internal tables
2create schema if not exists private;
3
4-- Move sensitive tables out of public
5alter table public.internal_logs set schema private;
6
7-- Or revoke all API access to a specific public table
8revoke 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.

4

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.

typescript
1// supabase/functions/_shared/cors.ts
2export 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};
7
8// In your Edge Function
9import { corsHeaders } from '../_shared/cors.ts';
10
11Deno.serve(async (req) => {
12 if (req.method === 'OPTIONS') {
13 return new Response('ok', { headers: corsHeaders });
14 }
15
16 // Your logic here
17 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.

5

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.

typescript
1// supabase/functions/rate-limited-api/index.ts
2import { createClient } from 'npm:@supabase/supabase-js@2';
3import { corsHeaders } from '../_shared/cors.ts';
4
5const rateLimits = new Map<string, { count: number; resetAt: number }>();
6
7Deno.serve(async (req) => {
8 if (req.method === 'OPTIONS') {
9 return new Response('ok', { headers: corsHeaders });
10 }
11
12 const clientIp = req.headers.get('x-forwarded-for') || 'unknown';
13 const now = Date.now();
14 const limit = rateLimits.get(clientIp);
15
16 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 }
22
23 if (!limit || limit.resetAt <= now) {
24 rateLimits.set(clientIp, { count: 1, resetAt: now + 60000 });
25 } else {
26 limit.count++;
27 }
28
29 // Forward to Supabase with service role for server-side operations
30 const supabase = createClient(
31 Deno.env.get('SUPABASE_URL')!,
32 Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!
33 );
34
35 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.

6

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.

typescript
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'
5
6# 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

secure-api-setup.sql
1-- ============================================
2-- Secure Public API Endpoints in Supabase
3-- Run in Supabase SQL Editor
4-- ============================================
5
6-- 1. Enable RLS on all public tables
7alter table public.profiles enable row level security;
8alter table public.posts enable row level security;
9alter table public.comments enable row level security;
10
11-- 2. Profiles: users can read any profile, edit only their own
12create policy "Public profiles are readable"
13 on public.profiles for select
14 to anon, authenticated
15 using (true);
16
17create policy "Users can update own profile"
18 on public.profiles for update
19 to authenticated
20 using ((select auth.uid()) = id)
21 with check ((select auth.uid()) = id);
22
23-- 3. Posts: public read for published, owner CRUD
24create policy "Published posts are public"
25 on public.posts for select
26 to anon, authenticated
27 using (published = true);
28
29create policy "Authors can insert posts"
30 on public.posts for insert
31 to authenticated
32 with check ((select auth.uid()) = author_id);
33
34create policy "Authors can update own posts"
35 on public.posts for update
36 to authenticated
37 using ((select auth.uid()) = author_id)
38 with check ((select auth.uid()) = author_id);
39
40create policy "Authors can delete own posts"
41 on public.posts for delete
42 to authenticated
43 using ((select auth.uid()) = author_id);
44
45-- 4. Add indexes on columns used in RLS policies
46create index idx_posts_author_id on public.posts using btree (author_id);
47create index idx_profiles_id on public.profiles using btree (id);
48
49-- 5. Revoke direct access to sensitive tables
50revoke all on public.admin_settings from anon;
51revoke all on public.admin_settings from authenticated;
52
53-- 6. Verify RLS is enabled everywhere
54select tablename, rowsecurity
55from pg_tables
56where 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.

ChatGPT Prompt

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.

Supabase Prompt

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.

RapidDev

Talk to an Expert

Our team has built 600+ apps. Get personalized help with your project.

Book a free consultation

Need help with your project?

Our experts have built 600+ apps and can accelerate your development. Book a free consultation — no strings attached.

Book a free consultation

We put the rapid in RapidDev

Need a dedicated strategic tech and growth partner? Discover what RapidDev can do for your business! Book a call with our team to schedule a free, no-obligation consultation. We'll discuss your project and provide a custom quote at no cost.