Database Security

Supabase vs Firebase Security: RLS, Rules, and Common Pitfalls

A practical security comparison of Supabase and Firebase: Firebase's common open rules mistakes, Supabase Row Level Security that you must never skip, storage bucket policies, and API key exposure risks.

October 1, 20259 min readShipSafer Team

Supabase and Firebase make database access from the frontend trivially easy. That convenience comes with a hidden risk: both platforms require you to configure access control rules correctly, and both platforms are wide open by default. When developers skip or misconfigure those rules, production databases with real user data become publicly readable — sometimes writable — to anyone on the internet.

This guide covers the specific failure modes for both platforms, the correct configuration for each, and how to audit your existing setup.

Firebase Security Rules: The Most Common Mistakes

Firebase Realtime Database and Firestore security rules run server-side and gate every read and write operation. The rules language is declarative and powerful — it's also easy to get wrong in ways that look correct but aren't.

Mistake 1: Open Development Rules Left in Production

During Firebase onboarding, the console suggests test-mode rules for quick setup:

// Firestore "test mode" — allows all access for 30 days
rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    match /{document=**} {
      allow read, write: if request.time < timestamp.date(2025, 10, 31);
    }
  }
}

Many teams deploy to production before the 30-day expiry and never update the rules. After the expiry, the database becomes fully locked — which can cause an outage, prompting a quick fix:

// "Quick fix" that opens everything permanently
allow read, write: if true;

Firebase sends a prominent warning email when rules are open, but those emails are often filtered to a developer alias that nobody monitors.

The fix: Set production rules before deploying. Use Firebase's security rules simulator and unit test suite to validate them:

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    // Only authenticated users can read/write their own data
    match /users/{userId} {
      allow read, write: if request.auth != null
        && request.auth.uid == userId;
    }

    // Default deny — explicit about rejecting everything else
    match /{document=**} {
      allow read, write: if false;
    }
  }
}

Mistake 2: Overly Broad Wildcard Rules

A rule at match /{document=**} applies recursively to all collections and subcollections. Teams often write a permissive rule here to "fill in the gaps":

// Dangerous: catches everything not explicitly matched
match /{document=**} {
  allow read: if request.auth != null;  // All authenticated users can read everything
}

This means a logged-in user can read every document in every collection — other users' profiles, private messages, payment records.

The fix: Write explicit rules for every collection and end with an explicit deny:

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    match /users/{userId} {
      allow read: if request.auth.uid == userId;
      allow write: if request.auth.uid == userId
        && validateUserUpdate(request.resource.data);
    }

    match /messages/{messageId} {
      allow read: if request.auth.uid == resource.data.senderId
        || request.auth.uid == resource.data.recipientId;
      allow create: if request.auth.uid == request.resource.data.senderId;
      allow delete: if request.auth.uid == resource.data.senderId;
    }

    // Explicit deny-all catch-all
    match /{anything=**} {
      allow read, write: if false;
    }
  }
}

function validateUserUpdate(data) {
  return data.keys().hasOnly(['displayName', 'photoURL', 'bio'])
    && data.displayName is string
    && data.displayName.size() <= 50;
}

Mistake 3: Trusting Client-Supplied Data in Rules

Rules that grant access based on a field value inside the document — where that field value is set by the client — create privilege escalation:

// Insecure: client can write role: "admin" and then pass this check
match /resources/{resourceId} {
  allow write: if request.auth != null
    && get(/databases/$(database)/documents/users/$(request.auth.uid)).data.role == 'admin';
}

If the users collection allows clients to write their own role field, an attacker sets role: "admin" and then has write access everywhere. Administrative roles should be set exclusively via the Firebase Admin SDK (server-side, authenticated with a service account):

// Server-side only — never exposed to client
import { getAuth } from 'firebase-admin/auth';

await getAuth().setCustomUserClaims(uid, { admin: true });

Then in rules:

allow write: if request.auth.token.admin == true;

Custom claims are set by the Admin SDK and cannot be forged by clients.

Supabase Row Level Security: Never Skip It

Supabase exposes PostgreSQL directly via REST and GraphQL APIs. By default, the anon key (distributed to all frontend clients) can access any table where RLS is disabled. This is explicitly documented — and frequently ignored.

The Default Insecure State

-- A freshly created Supabase table has RLS disabled
CREATE TABLE orders (
  id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
  user_id UUID REFERENCES auth.users,
  amount NUMERIC,
  status TEXT
);

-- With RLS disabled, any client with the anon key can:
-- SELECT * FROM orders;  -- reads ALL orders for ALL users
-- DELETE FROM orders;    -- deletes everything

The Supabase dashboard shows a warning for tables without RLS, but it's easy to dismiss.

Enabling RLS Correctly

-- Step 1: Enable RLS on the table
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;

-- Step 2: Write explicit policies
-- Users can only select their own orders
CREATE POLICY "users_select_own_orders"
  ON orders FOR SELECT
  TO authenticated
  USING (auth.uid() = user_id);

-- Users can insert orders where they are the owner
CREATE POLICY "users_insert_own_orders"
  ON orders FOR INSERT
  TO authenticated
  WITH CHECK (auth.uid() = user_id);

-- Users cannot update or delete orders (only your backend can)
-- No UPDATE or DELETE policies means those operations are denied

-- Service role (your backend) bypasses RLS entirely
-- Use the service_role key only in server-side code

auth.uid() returns the UID from the JWT that Supabase injects for every authenticated request. Because it comes from the JWT — not from a request parameter — it cannot be forged by the client.

Multi-Tenant RLS Pattern

For applications where users belong to organizations:

-- Organization membership table
CREATE TABLE org_members (
  org_id UUID,
  user_id UUID REFERENCES auth.users,
  role TEXT CHECK (role IN ('owner', 'admin', 'member')),
  PRIMARY KEY (org_id, user_id)
);

ALTER TABLE org_members ENABLE ROW LEVEL SECURITY;

-- Members can see their own memberships
CREATE POLICY "members_select_own"
  ON org_members FOR SELECT
  TO authenticated
  USING (auth.uid() = user_id);

-- Documents table scoped to organizations
CREATE TABLE documents (
  id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
  org_id UUID,
  content TEXT,
  created_by UUID REFERENCES auth.users
);

ALTER TABLE documents ENABLE ROW LEVEL SECURITY;

-- Users can read documents from organizations they belong to
CREATE POLICY "org_members_read_documents"
  ON documents FOR SELECT
  TO authenticated
  USING (
    org_id IN (
      SELECT org_id FROM org_members
      WHERE user_id = auth.uid()
    )
  );

-- Only admins/owners can delete documents
CREATE POLICY "org_admins_delete_documents"
  ON documents FOR DELETE
  TO authenticated
  USING (
    org_id IN (
      SELECT org_id FROM org_members
      WHERE user_id = auth.uid()
        AND role IN ('owner', 'admin')
    )
  );

The Service Role Key

Supabase provides two API keys:

  • anon key: used in frontend code, subject to RLS
  • service_role key: bypasses RLS entirely, has full database access

The service_role key is equivalent to PostgreSQL superuser access. It must never appear in frontend code, mobile apps, or any client-accessible location.

// CORRECT: server-side only (Next.js server action, Edge Function, etc.)
import { createClient } from '@supabase/supabase-js';

const supabaseAdmin = createClient(
  process.env.NEXT_PUBLIC_SUPABASE_URL!,
  process.env.SUPABASE_SERVICE_ROLE_KEY!  // Never prefix with NEXT_PUBLIC_
);

// WRONG: service_role key in a client component
// NEXT_PUBLIC_SUPABASE_SERVICE_ROLE_KEY is exposed to the browser

Storage Bucket Security

Both Firebase Storage and Supabase Storage require explicit access control on buckets and objects.

Firebase Storage Rules

rules_version = '2';
service firebase.storage {
  match /b/{bucket}/o {
    // User profile pictures: users can read any, write only their own
    match /profiles/{userId}/{allPaths=**} {
      allow read: if true;
      allow write: if request.auth != null
        && request.auth.uid == userId
        && request.resource.size < 5 * 1024 * 1024  // 5MB max
        && request.resource.contentType.matches('image/.*');
    }

    // Private documents: only owner can access
    match /documents/{userId}/{allPaths=**} {
      allow read, write: if request.auth != null
        && request.auth.uid == userId;
    }

    // Default deny
    match /{allPaths=**} {
      allow read, write: if false;
    }
  }
}

Supabase Storage Policies

-- Create a private bucket (not public by default)
INSERT INTO storage.buckets (id, name, public)
VALUES ('documents', 'documents', false);

-- Allow authenticated users to upload to their own folder
CREATE POLICY "users_upload_own_docs"
  ON storage.objects FOR INSERT
  TO authenticated
  WITH CHECK (
    bucket_id = 'documents'
    AND (storage.foldername(name))[1] = auth.uid()::text
  );

-- Allow users to read only their own files
CREATE POLICY "users_read_own_docs"
  ON storage.objects FOR SELECT
  TO authenticated
  USING (
    bucket_id = 'documents'
    AND (storage.foldername(name))[1] = auth.uid()::text
  );

API Key Exposure

Both Firebase and Supabase API keys are designed to be public — they identify the project, not grant admin access. Access control is enforced by security rules and RLS, not by key secrecy.

However, there is still a real exposure risk: unrestricted API keys that allow sign-ups let attackers create unlimited fake accounts, consuming your free tier quota or triggering billing.

For Firebase, restrict the API key in Google Cloud Console:

  • Go to APIs & Services > Credentials
  • Click the API key
  • Under "API restrictions", select "Restrict key" and only allow Firebase services
  • Under "Application restrictions", add your domain(s)

For Supabase, monitor Auth > Users for unusual sign-up patterns and configure rate limiting on authentication:

-- Check for recent bulk sign-ups (potential abuse)
SELECT
  date_trunc('hour', created_at) AS hour,
  COUNT(*) AS signups
FROM auth.users
WHERE created_at > now() - interval '24 hours'
GROUP BY 1
ORDER BY 1 DESC;

Security Audit Checklist

Run this audit on any Firebase or Supabase project:

Firebase:

  • No collections with allow read, write: if true
  • Rules tested with emulator unit tests
  • Admin role assigned via custom claims, not document fields
  • Storage rules have file size and content type limits
  • Firebase console alerts (open rules) are monitored

Supabase:

  • Every user-facing table has RLS enabled
  • Every table has explicit policies for each operation (SELECT/INSERT/UPDATE/DELETE)
  • service_role key is not in any NEXT_PUBLIC_ variable
  • Storage buckets are not public unless intended
  • Auth rate limiting is configured
  • Database roles reviewed (anon has no unnecessary table privileges)

Both platforms are excellent choices for rapid development. Their security model works correctly when you engage with it — the pitfalls come from skipping it.

supabase
firebase
rls
row level security
baaS security
database security
api key

Check Your Security Score — Free

See exactly how your domain scores on DMARC, TLS, HTTP headers, and 25+ other automated security checks in under 60 seconds.