← Back to Blog
DevOps2026-05-12·75 min read

I scanned 30 Supabase repos this morning and found 3 production-grade leaks (one with service_role committed)

By Perufitlife

Current Situation Analysis

Backend-as-a-Service (BaaS) platforms have dramatically accelerated application development by abstracting database provisioning, authentication, and REST/GraphQL APIs. Supabase, built on PostgreSQL, is among the most widely adopted solutions in this category. However, the convenience of rapid deployment introduces a persistent security debt: credential mismanagement and misconfigured access controls.

The core pain point is not the platform itself, but how development teams handle the boundary between public client environments and privileged backend operations. Supabase provides two primary JWT-scoped keys: the anon key, intended for unauthenticated client-side usage, and the service_role key, which carries full administrative privileges and bypasses all Row Level Security (RLS) policies. When these keys are hardcoded into version control or improperly scoped, the entire database surface becomes exposed.

This problem is frequently overlooked for three reasons:

  1. Fallback Convenience: Developers often write environment variable loaders with hardcoded fallbacks (process.env.KEY || 'fallback_value') to avoid local setup friction. These fallbacks frequently ship to production repositories.
  2. Misunderstood Key Scopes: Many teams assume the anon key is inherently safe because it lacks administrative privileges. This ignores the fact that without RLS, the anon key can still read, insert, update, or delete entire tables depending on default PostgreSQL permissions.
  3. RLL Blind Spots: Row Level Security is not automatically enabled on new tables in Supabase. Teams creating tables through the dashboard or migration scripts often forget to explicitly toggle RLS or define policies, leaving user data publicly accessible.

Industry scanning data reveals a consistent pattern across public repositories importing @supabase/supabase-js. Approximately 10% of scanned projects contain hardcoded project URLs paired with functional anon keys. Of those, roughly half lack RLS enforcement on at least one user-facing table (e.g., profiles, accounts, users). A smaller but critical subset (~5-7%) exposes service_role keys directly in configuration files or environment templates. The statistical probability of encountering a production-grade exposure in a randomly selected public Supabase project ranges from 1-in-15 to 1-in-30. This is not an anomaly; it is a systemic configuration gap that compounds as teams scale.

WOW Moment: Key Findings

The security posture of a Supabase integration does not depend solely on key secrecy. It depends on the intersection of credential exposure, key scope, and policy enforcement. The following comparison illustrates how minor configuration differences produce drastically different risk profiles.

Approach Credential Exposure Data Access Scope Remediation Complexity Business Risk
Proper Env Vars + RLS Enabled None (runtime-only) Policy-gated per row Low (standard deployment) Minimal
Hardcoded Anon Key + RLS Disabled High (public repo) Full table read/write Medium (key rotation + policy rewrite) High
Hardcoded Service Role Key Critical (public repo) Unrestricted admin access High (incident response + audit) Catastrophic

This finding matters because it shifts the security conversation from "keep keys secret" to "enforce least privilege at the database layer." Even if a key is leaked, properly configured RLS policies act as the final enforcement boundary. Conversely, a leaked service_role key renders all application-level access controls irrelevant, granting immediate, unfiltered database access. Understanding this hierarchy enables teams to prioritize RLS implementation alongside credential management, creating a defense-in-depth architecture that survives accidental exposure.

Core Solution

Securing a Supabase integration requires a systematic approach that validates credentials at startup, enforces key scope boundaries, and guarantees RLS coverage across all tables. The following implementation demonstrates a production-grade configuration validator and diagnostic runner built in TypeScript.

Step 1: Environment Variable Validation at Runtime

Hardcoded fallbacks must be eliminated. Configuration should fail fast if required variables are missing or malformed.

interface BaaSConfig {
  projectUrl: string;
  anonKey: string;
  serviceRoleKey?: string;
}

function validateBaaSConfig(): BaaSConfig {
  const projectUrl = process.env.SUPABASE_PROJECT_URL;
  const anonKey = process.env.SUPABASE_ANON_KEY;
  const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY;

  if (!projectUrl || !anonKey) {
    throw new Error('Missing required Supabase configuration. Check SUPABASE_PROJECT_URL and SUPABASE_ANON_KEY.');
  }

  if (!projectUrl.match(/^https:\/\/[a-z0-9-]+\.supabase\.co$/)) {
    throw new Error('Invalid Supabase project URL format.');
  }

  if (!anonKey.match(/^eyJ[A-Za-z0-9_-]+\.eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/)) {
    throw new Error('Anon key does not match expected JWT structure.');
  }

  return {
    projectUrl,
    anonKey,
    serviceRoleKey: serviceRoleKey || undefined
  };
}

Why this choice? Runtime validation prevents silent failures where missing environment variables default to undefined or empty strings, which can cause cryptic API errors later. The regex patterns enforce structural correctness without decoding the entire JWT, keeping the check lightweight.

Step 2: Key Scope Verification

The service_role key must never be exposed to client-side bundles. A runtime guard ensures it is only accessible in server contexts.

function isServerContext(): boolean {
  return typeof window === 'undefined' && process.env.NODE_ENV !== 'test';
}

function getServiceRoleKey(config: BaaSConfig): string {
  if (!isServerContext()) {
    throw new Error('Service role key cannot be accessed in client environments.');
  }
  if (!config.serviceRoleKey) {
    throw new Error('Service role key is not configured for this environment.');
  }
  return config.serviceRoleKey;
}

Why this choice? Separating client and server key access at the module level prevents accidental bundling of privileged credentials. The typeof window check is a standard Node.js/edge runtime pattern that reliably distinguishes server execution contexts.

Step 3: Safe Endpoint Probing for RLS Verification

Before deploying, teams should verify that tables are properly protected without exposing actual row data. The Supabase REST API supports count-only queries using standard HTTP headers.

async function verifyTableProtection(
  config: BaaSConfig,
  tableName: string
): Promise<{ rowCount: number; isProtected: boolean }> {
  const endpoint = `${config.projectUrl}/rest/v1/${tableName}`;
  
  const response = await fetch(endpoint, {
    method: 'GET',
    headers: {
      'apikey': config.anonKey,
      'Authorization': `Bearer ${config.anonKey}`,
      'Prefer': 'count=exact',
      'Range': '0-0'
    }
  });

  if (!response.ok) {
    throw new Error(`Failed to probe ${tableName}: ${response.status}`);
  }

  const contentRange = response.headers.get('content-range');
  const rowCount = contentRange ? parseInt(contentRange.split('/')[1], 10) : 0;
  
  return {
    rowCount,
    isProtected: rowCount === 0
  };
}

Why this choice? Using Prefer: count=exact and Range: 0-0 instructs the PostgreSQL REST layer to return only the total row count without transmitting payload data. This allows safe auditing of public exposure without violating data privacy or triggering rate limits. A rowCount of 0 indicates RLS is actively blocking unauthenticated access.

Step 4: Automated RLS Policy Enforcement

RLS must be explicitly enabled and configured per table. Supabase supports SQL-based policy definitions that should be version-controlled alongside schema migrations.

-- Enable RLS on target table
ALTER TABLE user_profiles ENABLE ROW LEVEL SECURITY;

-- Allow users to read only their own profile
CREATE POLICY "Users can view own profile"
ON user_profiles
FOR SELECT
USING (auth.uid() = user_id);

-- Allow authenticated inserts with matching user ID
CREATE POLICY "Users can insert own profile"
ON user_profiles
FOR INSERT
WITH CHECK (auth.uid() = user_id);

Why this choice? SQL-based policies are declarative, auditable, and execute at the database engine level, making them immune to application-layer bypasses. Version-controlling these statements ensures policy drift is caught during code review.

Pitfall Guide

1. The Convenience Fallback

Explanation: Developers write process.env.KEY || 'hardcoded_value' to avoid local setup friction. These fallbacks frequently commit to version control and survive into production. Fix: Remove all fallbacks. Use a configuration loader that throws on missing variables. Validate environment presence during CI/CD pipeline setup.

2. Assuming Anon Keys Are Inherently Safe

Explanation: The anon key lacks administrative privileges, but without RLS, it inherits default PostgreSQL table permissions, which often include full read/write access. Fix: Treat the anon key as a public credential. Enforce RLS on every table that stores user data, regardless of key type.

3. Ignoring Service Role Key Scope

Explanation: The service_role key bypasses RLS entirely. If committed to a repository, attackers gain unrestricted database access without needing to exploit application logic. Fix: Store service_role keys exclusively in server-side secret managers. Never bundle them in client applications or public configuration files.

4. RLS Blind Spots on New Tables

Explanation: Supabase does not automatically enable RLS on newly created tables. Teams often assume the platform enforces it by default. Fix: Add RLS enablement and policy definitions to every migration script. Implement a pre-deployment check that queries pg_tables for rls_enabled = false.

5. Client-Side Access Control Reliance

Explanation: Hiding UI elements or filtering data in JavaScript does not prevent direct API calls. Attackers can bypass frontend logic and query the REST endpoint directly. Fix: Move all authorization logic to database-level RLS policies. Treat the client as an untrusted interface.

6. Missing Prefer Header in Diagnostic Queries

Explanation: Standard GET requests return full row payloads. Running these against public endpoints during testing can accidentally expose sensitive data. Fix: Always include Prefer: count=exact and Range: 0-0 when auditing table exposure. Verify response headers instead of parsing body content.

7. Storing Keys in Build Artifacts

Explanation: Environment variables injected at build time can be baked into static assets or Docker layers, making them recoverable from deployed artifacts. Fix: Inject secrets at runtime using container orchestration, serverless environment configs, or secret management services. Never bake credentials into images.

Production Bundle

Action Checklist

  • Remove all hardcoded fallbacks from environment variable loaders
  • Validate JWT structure and URL format at application startup
  • Isolate service_role key access to server-side execution contexts
  • Enable RLS on every table containing user or business data
  • Write declarative SQL policies for SELECT, INSERT, UPDATE, and DELETE operations
  • Run count-only diagnostic queries before each deployment
  • Integrate secret scanning into CI/CD pipelines to catch accidental commits
  • Rotate keys immediately if exposure is detected, then audit access logs

Decision Matrix

Scenario Recommended Approach Why Cost Impact
Internal tool with limited users Anon key + strict RLS policies Minimizes attack surface while maintaining developer velocity Low (standard Supabase tier)
Public-facing SaaS with paid tiers Anon key + RLS + server-side service_role for webhooks Separates client access from backend automation securely Medium (requires server infrastructure)
Legacy app with disabled RLS Immediate RLS enablement + policy audit + key rotation Prevents data exfiltration while maintaining existing client logic High (requires migration window and testing)
Multi-tenant platform RLS with tenant_id filtering + row-level policies Ensures data isolation without application-level joins Medium (adds query complexity)

Configuration Template

// config/supabase.ts
import { createClient } from '@supabase/supabase-js';

function getSupabaseClient() {
  const projectUrl = process.env.SUPABASE_PROJECT_URL;
  const anonKey = process.env.SUPABASE_ANON_KEY;

  if (!projectUrl || !anonKey) {
    throw new Error('Supabase configuration missing. Verify environment variables.');
  }

  return createClient(projectUrl, anonKey, {
    auth: {
      autoRefreshToken: true,
      persistSession: true
    },
    global: {
      headers: { 'x-client-info': 'supabase-js/production' }
    }
  });
}

function getAdminClient() {
  if (typeof window !== 'undefined') {
    throw new Error('Admin client cannot be initialized in browser environments.');
  }

  const projectUrl = process.env.SUPABASE_PROJECT_URL;
  const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY;

  if (!projectUrl || !serviceRoleKey) {
    throw new Error('Admin credentials missing. Check server environment.');
  }

  return createClient(projectUrl, serviceRoleKey, {
    auth: { autoRefreshToken: false, persistSession: false }
  });
}

export const publicDb = getSupabaseClient();
export const adminDb = getAdminClient();

Quick Start Guide

  1. Audit Existing Configuration: Search your codebase for hardcoded URLs or JWT patterns. Replace all instances with environment variable references.
  2. Enable RLS Globally: Run ALTER TABLE <table_name> ENABLE ROW LEVEL SECURITY; for every user-facing table. Verify with SELECT tablename, rls_enabled FROM pg_tables;.
  3. Define Baseline Policies: Create USING and WITH CHECK policies that restrict access to auth.uid(). Test each policy using the count-only diagnostic query.
  4. Integrate Validation: Add the startup configuration validator to your application entry point. Fail deployments if required variables are missing or malformed.
  5. Schedule Periodic Audits: Run diagnostic probes against production tables weekly. Alert on any table returning a non-zero row count under the anon key.