Retour au blog
13 mai 2026

Supabase RLS Misconfiguration: How a Missing Policy Exposed Every User's Profile

Viktor Bulanek
Founder & CTO, Penetrify
MSc IT Security · 20+ years in security · 4x Ex-CTO

The application looked production-ready. Clean UI, working Stripe integration, a polished onboarding flow. It had been featured on Product Hunt, picked up over 200 users in the first week, and was already generating MRR. The founder had built it in 48 hours during a hackathon weekend using Next.js and Supabase, the stack of choice for everyone who wants to ship fast.

Eight minutes after the Penetrify scan started, we flagged a Critical finding: every user's profile record — name, email, account metadata — was readable by any other authenticated user via Supabase's auto-generated REST API. No attacker sophistication required. Just swap your user ID for another one in the URL.

This is the story of how that happened, why it's more common than anyone wants to admit, and exactly what the fix looked like.


What Supabase Row Level Security Is — And What Happens When It's Off

Supabase is built on PostgreSQL and exposes your database tables directly over a REST API (via PostgREST) and a JavaScript client library. This is genuinely powerful for rapid development: you can query your database from the frontend without writing a single API route.

The mechanism that keeps users from reading each other's data is called Row Level Security (RLS). RLS is a PostgreSQL feature that lets you define policies controlling which rows a given database user can SELECT, INSERT, UPDATE, or DELETE. In a Supabase application, you'd typically write a policy like:

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

When RLS is enabled and a policy like this is in place, a query for another user's profile returns zero rows — the database enforces the access control at the row level, before any data reaches the application.

When RLS is disabled or no policy exists, the table behaves as if every row is public to any authenticated request. Supabase's REST API, accessible at https://[project].supabase.co/rest/v1/profiles, will return all rows in the table — every user's data — to anyone who has a valid JWT from that project.


What We Found: Five Findings, Two of Them Critical

The scan ran for eight minutes in quick mode against the live application. Here are the findings in order of severity:

CRITICAL — Supabase RLS not enabled on the profiles table

The profiles table had RLS disabled. Any authenticated user could query it via the Supabase REST endpoint and retrieve all user records. The request that demonstrated it:

GET /rest/v1/profiles?select=*
Authorization: Bearer [any valid user JWT]

HTTP/1.1 200 OK
[
  {"id": "uuid-1", "email": "user1@example.com", "full_name": "...", ...},
  {"id": "uuid-2", "email": "user2@example.com", "full_name": "...", ...},
  ... (all 200+ user records)
]

Under GDPR, this is a personal data exposure affecting every registered user. The Supabase anon key is embedded in the frontend JavaScript bundle by design — it's safe to expose publicly when RLS is configured correctly, because the key alone gives no elevated access. Without RLS, it becomes a master key to all user data.

CRITICAL — Email verification not enforced on protected endpoints

Supabase allows sign-ups with email and password by default, and sends a confirmation email to verify the address. However, the application's backend API routes were not checking whether the requesting user had confirmed their email before granting access to protected functionality.

An attacker could register with victim@example.com (a real user's email address), skip email verification entirely, and immediately access the application's protected API routes as if they owned that email. Combined with the RLS issue, this meant an unauthenticated attacker could exfiltrate the entire user database by creating a throwaway account with any email address.

MEDIUM — IDOR on /api/export

The application had an export endpoint that accepted a user ID as a query parameter:

GET /api/export?userId=abc123

No ownership check was performed server-side. Any authenticated user could export any other user's data by substituting their own ID with a target's. User IDs were exposed in API responses throughout the application, making enumeration trivial.

MEDIUM — No rate limiting on the login endpoint

The login endpoint accepted authentication requests at approximately 500 requests per second without throttling or lockout. A credential stuffing attack against the application's user base would encounter no friction.

MEDIUM — JWT tokens stored in localStorage without rotation

Supabase JWTs were stored in localStorage, accessible to any JavaScript running on the page. No token rotation occurred on privilege changes. A successful XSS attack on any page would give an attacker a persistent, valid session.


Why This Happens: The Supabase Defaults Trap

This is not a story about a careless developer. It's a story about defaults.

When you create a new table in Supabase, RLS is disabled by default. Supabase's own documentation describes this clearly and recommends enabling it, but the quickstart examples — the ones developers actually follow when building at 2am during a hackathon — often skip RLS for brevity. You see a working query in the example, you copy the pattern, you ship it.

The Supabase dashboard shows a yellow warning icon on tables without RLS. It's easy to miss when you're focused on the UI, the payments integration, the onboarding flow. There's no error, no runtime failure, no obvious symptom. Everything works perfectly. Users can sign up, log in, and use the application. The vulnerability is completely silent.

The most dangerous security bugs are the ones that look exactly like correct behavior.

This pattern shows up in nearly every Supabase-backed application we scan that was built for speed. Not because the developers don't care about security, but because the time pressure of a hackathon or launch sprint doesn't leave room to read every section of the documentation.


The Fix: Two Hours, No Code Rewrite

The founder fixed all five findings in under two hours on the same evening the report arrived. Here's exactly what was done:

RLS — 15 minutes in the Supabase dashboard

Enabling RLS and adding appropriate policies required no code changes. In the Supabase dashboard, under Table Editor → profiles → RLS Policies:

-- Enable RLS on the table (one toggle in the dashboard)
-- Then add policies:

CREATE POLICY "Users can view own profile"
  ON profiles FOR SELECT
  USING (auth.uid() = user_id);

CREATE POLICY "Users can update own profile"
  ON profiles FOR UPDATE
  USING (auth.uid() = user_id);

-- For admin access (if needed):
CREATE POLICY "Service role has full access"
  ON profiles
  USING (auth.role() = 'service_role');

After enabling RLS and adding these policies, the bulk query returned zero rows for any authenticated user making a request for data that wasn't theirs.

Email verification — one middleware check

The Next.js API routes were already reading the Supabase user object from the JWT on each request. Adding email verification enforcement was a one-line check in the auth middleware:

const { data: { user } } = await supabase.auth.getUser()
if (!user?.email_confirmed_at) {
  return res.status(403).json({ error: 'Email verification required' })
}

IDOR on /api/export — one-line middleware fix

The fix was to replace the user-supplied userId parameter with the authenticated user's own ID, extracted from the verified JWT:

// Before (vulnerable)
const userId = req.query.userId

// After (fixed)
const { data: { user } } = await supabase.auth.getUser()
const userId = user.id  // always the authenticated user — can't be spoofed

Rate limiting — Vercel's built-in rate limiting

The application was hosted on Vercel. Adding rate limiting to the login endpoint required adding the @upstash/ratelimit package and wrapping the route — approximately 20 lines of code. The founder shipped this fix the following morning.


The GDPR Exposure

The RLS issue affected all 200+ registered users. Their full profile records — email addresses, display names, and any other fields stored in the profiles table — were readable by any other authenticated user for the entire time the application had been live.

Under GDPR Article 33, a personal data breach that is "likely to result in a risk to the rights and freedoms of natural persons" must be reported to the relevant supervisory authority within 72 hours of becoming aware of it. Whether this particular breach crossed that threshold would depend on the sensitivity of the data stored and how many users' data was actually accessed — but the exposure window was open.

The founder rotated all sensitive configuration values, enabled RLS, and fixed the remaining issues within the same evening. No evidence of unauthorized access was found in the Supabase logs. The issue was contained before it became an incident.


How to Check Your Own Supabase Application

If you're running a Supabase-backed application, here's a quick self-audit you can do in five minutes:

  1. Open the Supabase dashboard → Table Editor. Any table without a green shield icon has RLS disabled. If that table contains user data, it's likely exposed.
  2. Open your browser's developer tools on your production app → Network tab → filter by your Supabase project URL. Look for requests to /rest/v1/[tablename]?select=*. If you see requests returning many records, check whether they should be scoped to the authenticated user.
  3. Check your email verification flow. Register a new account, skip the confirmation email, and attempt to access protected parts of your application directly. If you can reach protected functionality without confirming your email, your backend is not enforcing verification.
  4. Check your export/download endpoints. Any endpoint that accepts a user ID as a parameter should cross-check it against the authenticated user's JWT. If the parameter can be freely substituted to access another user's data, that's an IDOR.

These four checks take less than ten minutes and cover the most common Supabase security gaps we see in production applications.


The Broader Pattern

This case study isn't unusual. It's representative of what we find in the majority of Supabase-backed applications that were built during a hackathon, a rapid launch sprint, or by a solo founder without a security background.

The combination of fast tooling, excellent developer experience, and public defaults that favor ease of use over security means that many applications ship with these gaps. The gaps are fixable — often in minutes, rarely requiring anything more than a SQL policy or a one-line middleware check. The hard part is knowing they exist in the first place.

The Supabase team has done significant work to make RLS more prominent in the dashboard and documentation. But the gap between "documentation says enable this" and "every production app actually has it enabled" remains wide. Until automated security testing becomes part of the standard launch checklist, this pattern will keep appearing.

Frequently Asked Questions

Quels types de vulnérabilités Penetrify détecte-t-il ?

Penetrify détecte toutes les catégories de vulnérabilités OWASP Top 10, notamment les injections SQL, XSS, CSRF, IDOR, les failles d'authentification, les mauvaises configurations de sécurité et l'exposition de données sensibles. Il teste également la sécurité des API, la gestion des sessions et les mauvaises configurations courantes dans Supabase, Firebase et Bubble.

Combien de temps dure un test de pénétration IA ?

Un scan rapide se termine en 15–30 minutes. Un scan standard dure 1–2 heures avec une couverture plus large. Un scan approfondi peut durer plusieurs heures pour les applications complexes.

Que contient un rapport Penetrify ?

Chaque rapport comprend un résumé exécutif, un score de sécurité global, des résultats classés par gravité (Critique, Élevé, Moyen, Faible), des étapes de reproduction détaillées et des recommandations de remédiation concrètes rédigées pour les développeurs – pas pour les responsables conformité.

Retour au blog