Back to blog
·11 min read·Kactuz Team

Your Bolt.new Supabase Backend Is Probably Leaking Data. Here's How to Check.

We've seen 8 Bolt.new + Supabase apps this year. 7 had RLS policies that didn't actually protect user data. Here's the 5-minute security check.

Vibe CodeSupabaseSecurityBolt.newRLS

Seven out of eight.

That's the score from our Bolt.new + Supabase audits so far in 2026. Seven of the eight apps we reviewed had Row Level Security policies that looked correct but didn't actually protect user data. One of them was processing payment information.

This isn't a hit piece on Bolt.new. The same patterns show up in Cursor, Lovable, and Replit-generated Supabase backends. The problem is structural: AI code generators produce RLS policies that pass syntax checks but fail logic checks. And most founders don't know how to tell the difference.

Here's a 5-minute security check you can run right now. If any of these checks fail, your users' data may be exposed.

Check 1: Count your RLS policies vs. your tables

Open your Supabase dashboard. Go to the SQL editor and run:

-- Count tables without RLS enabled
SELECT schemaname, tablename
FROM pg_tables
WHERE schemaname = 'public'
AND tablename NOT IN (
  SELECT tablename
  FROM pg_tables t
  JOIN pg_class c ON c.relname = t.tablename
  WHERE c.relrowsecurity = true
  AND t.schemaname = 'public'
);

This returns every table in your public schema that doesn't have RLS enabled. If any table with user data appears in this list, you have an open door.

What Bolt.new gets wrong: It often enables RLS on the "main" tables (users, projects, etc.) but forgets about junction tables, audit logs, and metadata tables. We've seen user_settings, api_keys, payment_methods, and team_invitations tables with RLS completely disabled.

A more thorough check:

-- Show all tables with their RLS status and policy count
SELECT
  t.tablename,
  c.relrowsecurity AS rls_enabled,
  COUNT(p.policyname) AS policy_count
FROM pg_tables t
JOIN pg_class c ON c.relname = t.tablename
LEFT JOIN pg_policies p ON p.tablename = t.tablename
WHERE t.schemaname = 'public'
GROUP BY t.tablename, c.relrowsecurity
ORDER BY c.relrowsecurity, policy_count;

Every table with user-accessible data should have rls_enabled = true AND at least one policy. A table with RLS enabled but zero policies blocks all access by default — which is safe but probably means a feature is broken.

Check 2: Test cross-user data access from the browser

This is the test that catches the real vulnerabilities. You need two user accounts in your app.

  1. Log in as User A. Open the browser console (F12).
  2. Get User A's Supabase client. In most Bolt.new apps, it's accessible from the console:
// Find the Supabase client — it's usually on the window or in a module
// Try these common patterns:
const { data } = await supabase.from('projects').select('*');
console.log('User A sees:', data.length, 'projects');
  1. Now, the critical test. Try to access User B's data:
// If you know User B's ID (or any other user's ID):
const { data } = await supabase
  .from('projects')
  .select('*')
  .eq('user_id', 'USER_B_ID_HERE');

console.log('Can User A see User B data?', data);
// If this returns anything other than an empty array, RLS is broken
  1. Try without any filter at all:
const { data } = await supabase.from('projects').select('*');
console.log('Total projects visible:', data.length);
// If this returns more than User A's own projects, RLS is broken

What Bolt.new gets wrong: The generated RLS policies often use auth.uid() = user_id which only works for single-user tables. For multi-tenant apps (which most B2B SaaS products are), the policy needs to check organization membership, not just user ID. Bolt.new almost never generates org-level isolation.

-- What Bolt.new generates (insufficient for multi-tenant)
CREATE POLICY "Users can view own projects"
ON public.projects FOR SELECT
USING (auth.uid() = user_id);

-- What multi-tenant apps actually need
CREATE POLICY "Users can view org projects"
ON public.projects FOR SELECT
USING (
  EXISTS (
    SELECT 1 FROM public.org_members
    WHERE org_members.org_id = projects.org_id
    AND org_members.user_id = auth.uid()
  )
);

Check 3: What keys are in your client-side code?

Open your app's source code (or view source in the browser). Search for supabase:

# In your repo
grep -r "supabaseKey\|supabase_key\|SUPABASE_KEY\|anon.*key\|service_role" \
  --include="*.ts" --include="*.tsx" --include="*.js" --include="*.jsx" \
  --include="*.env*" .

You should find exactly one Supabase key in client-side code: the anon key. This key is designed to be public. It's rate-limited and respects RLS policies.

The service_role key bypasses all RLS. If this key is anywhere in client-side code, every RLS policy you have is irrelevant — anyone can extract it and access everything.

What Bolt.new gets wrong: We've seen three patterns:

Pattern 1: service_role in environment variables that ship to the client.

// DANGEROUS — this .env is bundled into the client
// .env (or .env.local)
NEXT_PUBLIC_SUPABASE_URL=https://xxx.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbG...  // This is fine
NEXT_PUBLIC_SUPABASE_SERVICE_ROLE_KEY=eyJhbG...  // THIS IS NOT FINE

Any environment variable prefixed with NEXT_PUBLIC_ is included in the client bundle. If your service_role key has this prefix, it's publicly accessible. Same applies to VITE_ in Vite projects and REACT_APP_ in Create React App.

Pattern 2: service_role in API routes that run on the client.

// This looks like a server-side API route, but in some frameworks
// the code can be inspected or the key extracted from the bundle
import { createClient } from '@supabase/supabase-js';

const supabase = createClient(
  process.env.SUPABASE_URL!,
  process.env.SUPABASE_SERVICE_ROLE_KEY! // Only safe if this NEVER reaches the client
);

Verify that this code is in a file that is genuinely server-only. In Next.js, that means app/api/ routes or files with "use server". In other frameworks, the boundaries may be less clear.

Pattern 3: Both keys initialized in the same file.

// Bolt.new sometimes generates this — two clients, one file
export const supabase = createClient(url, anonKey); // For client
export const supabaseAdmin = createClient(url, serviceRoleKey); // For server

// If this file is imported anywhere in the client tree,
// the service_role key ships to the browser

Check 4: Is your service_role key in a public repo?

This takes 30 seconds:

# Check your git history for leaked keys
git log -p --all -S 'service_role' -- '*.env*' '*.ts' '*.js'

If this returns any results, your service_role key has been committed to Git at some point. Even if you've since removed it, it's in the Git history. If the repo is (or was ever) public, assume the key is compromised.

What to do if it's compromised:

  1. Go to Supabase Dashboard > Settings > API
  2. Rotate your service role key immediately
  3. Update your server-side environment variables with the new key
  4. Check access logs for unauthorized API calls

Also check for the key in other common places:

# Check for keys in Docker files, CI configs, and documentation
grep -r "service_role\|serviceRole\|SERVICE_ROLE" \
  --include="*.yml" --include="*.yaml" --include="*.toml" \
  --include="*.json" --include="Dockerfile*" --include="*.md" .

Check 5: Send a malformed request

This tests error handling. Open your browser console and send a deliberately broken request:

// Test 1: Malformed query
const { data, error } = await supabase
  .from('nonexistent_table')
  .select('*');
console.log('Error response:', error);
// Should return a generic error, NOT a stack trace or table listing

// Test 2: SQL injection attempt
const { data: data2, error: error2 } = await supabase
  .from('projects')
  .select('*')
  .or('id.eq.1,id.eq.1); DROP TABLE projects; --');
console.log('SQL injection test:', error2);
// Supabase should prevent this, but check the response

// Test 3: Oversized request
const { data: data3, error: error3 } = await supabase
  .from('projects')
  .select('*')
  .limit(1000000);
console.log('Oversized request:', data3?.length, error3);
// Should be rate-limited or capped

What you're looking for: The error responses should be generic. If you see Postgres error messages with table names, column names, or query details in the response, that's information leakage. It won't directly expose user data, but it gives attackers a map of your database.

What Bolt.new gets wrong: AI-generated error handling is almost always minimal. Errors get passed directly to the client without sanitization. In development this is helpful for debugging. In production it's an information leak.

Why AI tools consistently get Supabase wrong

After auditing 15+ AI-generated Supabase backends, we see the same root causes:

1. RLS is generated but not tested

Bolt.new generates RLS policies that are syntactically correct. They pass SQL validation. What they don't pass is logic validation — testing whether the policy actually prevents the access it should prevent.

RLS policies are tricky because they interact. A SELECT policy might be correct, but if the INSERT policy doesn't set the user_id correctly, new rows are created without ownership and become invisible to everyone. AI tools don't simulate these multi-step interactions.

2. No distinction between public and private operations

AI code generators treat all database operations the same. They don't understand that "create a new user profile" requires the service_role key (because the user doesn't exist yet in the auth context), while "update my profile" should use the anon key with RLS.

This leads to one of two problems: either everything uses the anon key (and some operations fail silently) or everything uses the service_role key (and RLS is bypassed entirely).

3. Multi-tenancy is an afterthought

Most B2B SaaS apps are multi-tenant. Users belong to organizations. Data belongs to organizations. Access control is at the organization level.

Bolt.new and similar tools generate per-user RLS policies because that's the pattern in tutorials and documentation. Organization-level isolation requires an additional join, an org_members table, and consistent enforcement across every table. AI tools generate the simple version because the simple version works in a single-user test.

4. Edge cases are invisible

Consider this scenario: User A creates a project. User A invites User B to the organization. User B should now see the project. Later, User A removes User B from the organization. User B should no longer see the project.

AI-generated RLS handles the first two steps. It almost never handles the third. The policy checks membership at query time, so removal works — but only if the policy uses a subquery against the org_members table. If it cached the membership or used a JOIN that doesn't reflect current state, removed users retain access.

These edge cases don't show up in demos. They show up 6 months into production when someone leaves the company and still has access to customer data.

5. DELETE and UPDATE policies are forgotten

Most AI-generated RLS focuses on SELECT (who can read) and INSERT (who can create). DELETE and UPDATE policies are either missing or copied from SELECT without modification.

-- This is a common Bolt.new output — SELECT only
CREATE POLICY "Users can view own projects"
ON public.projects FOR SELECT
USING (auth.uid() = user_id);

-- Missing: what about UPDATE and DELETE?
-- Without these policies, no one can update or delete
-- (which might seem safe, but it means your app is broken)

The correct approach is explicit policies for all four operations:

-- Complete policy set
CREATE POLICY "select_own" ON public.projects
FOR SELECT USING (auth.uid() = user_id);

CREATE POLICY "insert_own" ON public.projects
FOR INSERT WITH CHECK (auth.uid() = user_id);

CREATE POLICY "update_own" ON public.projects
FOR UPDATE USING (auth.uid() = user_id)
WITH CHECK (auth.uid() = user_id);

CREATE POLICY "delete_own" ON public.projects
FOR DELETE USING (auth.uid() = user_id);

The fix: A systematic approach

If your checks revealed problems, here's the priority order for fixing them:

Priority 1 (do this today)

  • Remove service_role key from any client-side code or public environment variables
  • Rotate the key if it was ever committed to Git
  • Enable RLS on every table in your public schema

Priority 2 (this week)

  • Audit every RLS policy for logic correctness, not just syntax
  • Add policies for all four operations (SELECT, INSERT, UPDATE, DELETE) on every table
  • Test cross-user access for every table that contains user data
  • Move all admin operations to server-side API routes

Priority 3 (this month)

  • Add automated RLS tests to your CI pipeline
  • Implement proper error handling that doesn't leak database structure
  • Set up monitoring for unusual access patterns (Supabase logs + alerts)
  • Document your security model so future developers (human or AI) don't reintroduce vulnerabilities

The bigger picture

Bolt.new, Cursor, Lovable — these tools are incredible for velocity. We use AI code generation every day. But velocity without security is a liability.

The fix isn't to stop using AI tools. The fix is to verify what they generate, especially in three areas: authentication, authorization, and data access. These are the areas where "works in the demo" and "secure in production" diverge the most.

Run the 5 checks. If anything fails, fix it now. The cost of a proactive audit is a tiny fraction of the cost of a data breach — and the reputational damage of telling your customers their data was exposed because your RLS policies were generated by an AI and never verified.

Ready to get started?

Not sure if your Supabase backend is secure? We check everything AI generated and give you a prioritized fix list.

Your Bolt.new Supabase Backend Is Probably Leaking Data. Here's How to Check. | Kactuz Blog