# How to Secure Public API Endpoints in Supabase

- Tool: Supabase
- Difficulty: Intermediate
- Time required: 15-20 min
- Compatibility: Supabase (all plans), @supabase/supabase-js v2+
- Last updated: March 2026

## 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.

## Before you start

- 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.

```
-- Enable RLS on all your public tables
alter table public.profiles enable row level security;
alter table public.posts enable row level security;
alter table public.comments enable row level security;

-- Verify RLS is enabled
select tablename, rowsecurity
from pg_tables
where 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.

```
-- Public read access (anon + authenticated)
create policy "Anyone can read posts"
  on public.posts for select
  to anon, authenticated
  using (published = true);

-- Authenticated users can insert their own posts
create policy "Users can create their own posts"
  on public.posts for insert
  to authenticated
  with check ((select auth.uid()) = author_id);

-- Users can only update their own posts
create policy "Users can update own posts"
  on public.posts for update
  to authenticated
  using ((select auth.uid()) = author_id)
  with check ((select auth.uid()) = author_id);

-- Users can only delete their own posts
create policy "Users can delete own posts"
  on public.posts for delete
  to authenticated
  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.

```
-- Create a private schema for internal tables
create schema if not exists private;

-- Move sensitive tables out of public
alter table public.internal_logs set schema private;

-- Or revoke all API access to a specific public table
revoke all on public.admin_settings from anon;
revoke 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.

```
// supabase/functions/_shared/cors.ts
export const corsHeaders = {
  'Access-Control-Allow-Origin': 'https://yourdomain.com',
  'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
  'Access-Control-Allow-Methods': 'POST, GET, OPTIONS',
};

// In your Edge Function
import { corsHeaders } from '../_shared/cors.ts';

Deno.serve(async (req) => {
  if (req.method === 'OPTIONS') {
    return new Response('ok', { headers: corsHeaders });
  }

  // Your logic here
  return new Response(JSON.stringify({ ok: true }), {
    headers: { ...corsHeaders, 'Content-Type': 'application/json' },
  });
});
```

**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.

```
// supabase/functions/rate-limited-api/index.ts
import { createClient } from 'npm:@supabase/supabase-js@2';
import { corsHeaders } from '../_shared/cors.ts';

const rateLimits = new Map<string, { count: number; resetAt: number }>();

Deno.serve(async (req) => {
  if (req.method === 'OPTIONS') {
    return new Response('ok', { headers: corsHeaders });
  }

  const clientIp = req.headers.get('x-forwarded-for') || 'unknown';
  const now = Date.now();
  const limit = rateLimits.get(clientIp);

  if (limit && limit.resetAt > now && limit.count >= 100) {
    return new Response(
      JSON.stringify({ error: 'Rate limit exceeded' }),
      { status: 429, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
    );
  }

  if (!limit || limit.resetAt <= now) {
    rateLimits.set(clientIp, { count: 1, resetAt: now + 60000 });
  } else {
    limit.count++;
  }

  // Forward to Supabase with service role for server-side operations
  const supabase = createClient(
    Deno.env.get('SUPABASE_URL')!,
    Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!
  );

  const { data, error } = await supabase.from('posts').select('*').limit(20);
  return new Response(JSON.stringify(data), {
    headers: { ...corsHeaders, 'Content-Type': 'application/json' },
  });
});
```

**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.

```
# Test anonymous read access (should return only published posts)
curl 'https://YOUR_PROJECT.supabase.co/rest/v1/posts?select=*&published=eq.true' \
  -H 'apikey: YOUR_ANON_KEY' \
  -H 'Authorization: Bearer YOUR_ANON_KEY'

# Test anonymous insert (should fail with empty result)
curl -X POST 'https://YOUR_PROJECT.supabase.co/rest/v1/posts' \
  -H 'apikey: YOUR_ANON_KEY' \
  -H 'Authorization: Bearer YOUR_ANON_KEY' \
  -H 'Content-Type: application/json' \
  -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 code example

File: `secure-api-setup.sql`

```sql
-- ============================================
-- Secure Public API Endpoints in Supabase
-- Run in Supabase SQL Editor
-- ============================================

-- 1. Enable RLS on all public tables
alter table public.profiles enable row level security;
alter table public.posts enable row level security;
alter table public.comments enable row level security;

-- 2. Profiles: users can read any profile, edit only their own
create policy "Public profiles are readable"
  on public.profiles for select
  to anon, authenticated
  using (true);

create policy "Users can update own profile"
  on public.profiles for update
  to authenticated
  using ((select auth.uid()) = id)
  with check ((select auth.uid()) = id);

-- 3. Posts: public read for published, owner CRUD
create policy "Published posts are public"
  on public.posts for select
  to anon, authenticated
  using (published = true);

create policy "Authors can insert posts"
  on public.posts for insert
  to authenticated
  with check ((select auth.uid()) = author_id);

create policy "Authors can update own posts"
  on public.posts for update
  to authenticated
  using ((select auth.uid()) = author_id)
  with check ((select auth.uid()) = author_id);

create policy "Authors can delete own posts"
  on public.posts for delete
  to authenticated
  using ((select auth.uid()) = author_id);

-- 4. Add indexes on columns used in RLS policies
create index idx_posts_author_id on public.posts using btree (author_id);
create index idx_profiles_id on public.profiles using btree (id);

-- 5. Revoke direct access to sensitive tables
revoke all on public.admin_settings from anon;
revoke all on public.admin_settings from authenticated;

-- 6. Verify RLS is enabled everywhere
select tablename, rowsecurity
from pg_tables
where schemaname = 'public';
```

## Common mistakes

- **Forgetting to enable RLS on a new table, leaving it fully exposed via the REST API** — undefined Fix: Always enable RLS immediately after creating a table. Run a periodic audit query: SELECT tablename FROM pg_tables WHERE schemaname = 'public' AND NOT rowsecurity;
- **Using the service role key in client-side code, which bypasses all RLS policies** — undefined Fix: 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.
- **Granting INSERT or DELETE permissions to the anon role without a specific use case** — undefined Fix: Default to authenticated-only for all write operations. Only add anon write policies for explicit public features like anonymous feedback forms.
- **Creating views without security_invoker = true, which bypasses RLS** — undefined Fix: 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

## 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.

---

Source: https://www.rapidevelopers.com/supabase-tutorial/how-to-secure-public-api-endpoints-in-supabase
© RapidDev — https://www.rapidevelopers.com/supabase-tutorial/how-to-secure-public-api-endpoints-in-supabase
