Hardening the Data API for Self-Hosted Supabase: A Security Guide

Secure your self-hosted Supabase Data API with custom schemas, rate limiting, and access controls. Complete guide to PostgREST hardening.

Cover Image for Hardening the Data API for Self-Hosted Supabase: A Security Guide

Self-hosting Supabase gives you full control over your data and infrastructure—but that control comes with responsibility. The auto-generated Data API that makes Supabase so powerful can also become a security liability if left unconfigured. Every table in your public schema gets exposed by default, powerful query filters can be abused for data extraction, and without proper hardening, your API becomes an open door.

If you're running a self-hosted Supabase instance, securing your Data API isn't optional—it's essential. This guide walks through the key hardening techniques: custom schema isolation, rate limiting, access controls, and the upcoming breaking changes you need to prepare for.

Understanding the Data API Attack Surface

Supabase auto-generates a RESTful API from your PostgreSQL schema using PostgREST. This is incredibly convenient—you get CRUD operations without writing backend code. But convenience has a cost.

Without Row Level Security (RLS) and proper grants, your API exposes entire datasets. Query filters like eq, neq, gt, and ilike become tools for data extraction. Security researchers have demonstrated how misconfigured Supabase instances can be exploited at scale, turning powerful features into vulnerabilities.

The default configuration assumes you'll lock things down. Self-hosted deployments need to make that assumption explicit.

Breaking Change: Default API Exposure

This is critical for anyone running self-hosted Supabase in 2026. Supabase is changing how tables are exposed to the Data API:

  • April 28, 2026: New projects can opt into not exposing public schema tables by default
  • May 30, 2026: This becomes the default for all new projects
  • October 30, 2026: The setting applies to all existing projects

After this change, new tables in the public schema require an explicit GRANT before the Data API can see them. This is a significant security improvement, but it will break existing applications that rely on automatic exposure.

For self-hosted deployments, you'll need to update your configuration and migration scripts to account for this change. Start preparing now by auditing which tables actually need API exposure.

Custom Schema Isolation

The most effective hardening technique is moving away from the public schema entirely. Create a dedicated api schema that explicitly defines what's exposed:

-- Create a schema specifically for API-exposed objects
CREATE SCHEMA IF NOT EXISTS api;

-- Create a view that exposes only what clients need
CREATE VIEW api.user_profiles AS
SELECT 
  id,
  display_name,
  avatar_url,
  created_at
FROM public.users
WHERE deleted_at IS NULL;

-- Grant access to the view, not the underlying table
GRANT SELECT ON api.user_profiles TO anon, authenticated;

This approach has several benefits:

  1. Explicit exposure: Only objects in your api schema are accessible
  2. Column filtering: Views expose specific columns, hiding sensitive data
  3. Transformation layer: You can rename columns, compute values, or filter rows
  4. Cleaner separation: Business logic stays in public, API contracts live in api

Configuring PostgREST for Custom Schemas

In your self-hosted deployment, update the PostgREST configuration to use your custom schema. Edit your docker-compose.yml or environment variables:

rest:
  environment:
    PGRST_DB_SCHEMAS: api  # Only expose the api schema

If you need multiple schemas exposed (for backward compatibility), you can specify them comma-separated:

PGRST_DB_SCHEMAS: api,public

You'll also need to update the authenticator role's settings:

ALTER ROLE authenticator SET pgrst.db_schemas TO 'api';

Rate Limiting at the Database Level

PostgREST supports a pre-request function that runs before every API call. You can use this to implement rate limiting directly in PostgreSQL:

-- Create a table to track rate limits
CREATE TABLE private.rate_limits (
  ip_address inet NOT NULL,
  request_time timestamptz NOT NULL DEFAULT now()
);

-- Create index for efficient lookups
CREATE INDEX rate_limits_ip_time_idx 
ON private.rate_limits (ip_address, request_time);

-- Create the pre-request function
CREATE OR REPLACE FUNCTION public.check_rate_limit()
RETURNS void
LANGUAGE plpgsql
SECURITY DEFINER
AS $$
DECLARE
  client_ip inet;
  request_count int;
BEGIN
  -- Get the client IP from request headers
  client_ip := current_setting('request.headers', true)::json->>'x-forwarded-for';
  
  -- Only rate limit write operations
  IF current_setting('request.method', true) IN ('POST', 'PUT', 'PATCH', 'DELETE') THEN
    -- Record this request
    INSERT INTO private.rate_limits (ip_address) VALUES (client_ip);
    
    -- Count requests in the last 5 minutes
    SELECT count(*) INTO request_count
    FROM private.rate_limits
    WHERE ip_address = client_ip
      AND request_time > now() - interval '5 minutes';
    
    -- Reject if over limit
    IF request_count > 100 THEN
      RAISE EXCEPTION 'Rate limit exceeded' 
        USING ERRCODE = 'P0001';
    END IF;
  END IF;
  
  -- Clean up old entries periodically
  DELETE FROM private.rate_limits
  WHERE request_time < now() - interval '10 minutes';
END;
$$;

Configure PostgREST to call this function:

PGRST_DB_PRE_REQUEST: public.check_rate_limit

Important: The pgrst.db_pre_request configuration only works with the Data API (PostgREST). It doesn't apply to Realtime, Storage, or other Supabase services. For those, implement checks directly in your RLS policies.

Grants and RLS: Two Layers of Defense

Grants and RLS serve different purposes:

  • Grants control whether a role can access a table at all
  • RLS controls which rows that role can see

Bundle them together in your migrations:

-- Create the table
CREATE TABLE public.projects (
  id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  name text NOT NULL,
  owner_id uuid REFERENCES auth.users(id),
  created_at timestamptz DEFAULT now()
);

-- Enable RLS
ALTER TABLE public.projects ENABLE ROW LEVEL SECURITY;

-- Create policies
CREATE POLICY "Users can view own projects"
ON public.projects FOR SELECT
TO authenticated
USING (owner_id = auth.uid());

CREATE POLICY "Users can create projects"
ON public.projects FOR INSERT
TO authenticated
WITH CHECK (owner_id = auth.uid());

-- Grant access (required after the 2026 change)
GRANT SELECT, INSERT ON public.projects TO authenticated;

For self-hosted deployments, consider creating custom roles for specific access levels:

-- Create a manager role with additional permissions
CREATE ROLE manager;
GRANT SELECT, UPDATE ON public.projects TO manager;

-- Combine with RLS for granular control
CREATE POLICY "Managers can update team projects"
ON public.projects FOR UPDATE
TO manager
USING (
  owner_id IN (
    SELECT user_id FROM public.team_members 
    WHERE team_id = projects.team_id
  )
);

Disabling or Restricting the Data API

If your application doesn't need the auto-generated REST API, disable it entirely. This is the most secure option—what doesn't exist can't be exploited.

For self-hosted deployments, you can remove PostgREST from your stack or restrict which schemas it exposes. Update your docker-compose.yml:

# Option 1: Remove PostgREST entirely (most secure)
services:
  # rest: (commented out or removed)

# Option 2: Restrict to an empty or minimal schema
rest:
  environment:
    PGRST_DB_SCHEMAS: internal  # A schema with minimal exposure

Your application can still connect directly to PostgreSQL or use the connection pooler.

Protecting the OpenAPI Spec

Supabase has deprecated anonymous access to the Data API's OpenAPI spec endpoint. Starting March 2026, only secret keys or service roles can access schema information.

For self-hosted deployments, you may want to go further and block this endpoint entirely for external access. Configure your reverse proxy to deny requests to /rest/v1/:

# Nginx configuration
location /rest/v1/ {
  # Block access to the OpenAPI spec
  if ($request_uri ~ "^/rest/v1/$") {
    return 403;
  }
  # Allow other requests through
  proxy_pass http://kong:8000;
}

Service Role Key Isolation

The service_role key bypasses RLS and grants full database access. It should never appear in client code, environment variables exposed to the frontend, or public repositories.

For self-hosted deployments:

  1. Store keys in a secrets manager: Use HashiCorp Vault, AWS Secrets Manager, or similar
  2. Rotate keys regularly: Supascale's API makes this straightforward
  3. Monitor for leaks: Set up alerts for any service role key usage from unexpected IPs
  4. Segment by environment: Use different keys for development, staging, and production

If you need elevated access for specific operations, consider creating dedicated database functions with SECURITY DEFINER that perform the privileged action and expose only that function via the API.

Monitoring and Auditing

Visibility is essential for security. Enable audit logging to track who accessed what:

-- Enable pgaudit for comprehensive logging
CREATE EXTENSION IF NOT EXISTS pgaudit;

-- Log all DML statements
ALTER SYSTEM SET pgaudit.log = 'write';
SELECT pg_reload_conf();

Set up monitoring for anomalous patterns:

  • Sudden spikes in API requests
  • Unusual query patterns (bulk selects, broad filters)
  • Failed authentication attempts
  • Access from unexpected IP ranges

Hardening with Supascale

Self-hosted Supabase security doesn't have to be a manual process. Supascale provides tools to manage multiple projects with consistent security configurations:

  • Centralized key management: Rotate API keys across projects from one dashboard
  • Environment configuration: Apply security settings consistently across deployments
  • Backup and recovery: Automated backups ensure you can recover from security incidents
  • Custom domains: Configure SSL certificates for secure API access

At $39.99 one-time for unlimited projects, it's a practical investment in operational security.

Conclusion

Hardening your self-hosted Supabase Data API comes down to a few principles:

  1. Minimize exposure: Use custom schemas, disable what you don't need
  2. Layer defenses: Combine grants, RLS, and rate limiting
  3. Prepare for changes: The October 2026 breaking change will affect existing projects
  4. Monitor continuously: What you can't see, you can't protect

The auto-generated API is powerful—that's exactly why it needs careful configuration. Take the time to lock it down properly, and your self-hosted deployment will be as secure as it is flexible.


Further Reading