All articles

Why Your Lovable App Is Probably Leaking User Data Right Now

Why Your Lovable App Is Probably Leaking User Data Right Now

Lovable generates apps fast but creates predictable security gaps. What leaks, why it happens, and how to fix it before attackers find it.

April 4, 2026VibeWShield Team7 min read

Lovable is genuinely impressive. Describe a SaaS product, and in 20 minutes you have a working app with auth, a database, and a polished UI. The pace is intoxicating, and that's where the danger hides.

Lovable's code generator is optimized to produce apps that work. Authentication flows complete. Database reads succeed. UI components render. What it does not optimize for (and cannot, without explicit instructions) is whether your users' data stays private.

We've analyzed dozens of Lovable-generated apps and found the same security patterns appearing repeatedly. This post covers the five most common, with the exact fixes you need to apply before you share your app with real users.


Why Lovable Apps Have Predictable Security Gaps

Lovable builds on a consistent stack: Next.js frontend, Supabase backend, and increasingly — edge functions or API routes for business logic. The security assumptions baked into this stack are reasonable for experienced developers who know what the defaults mean.

For a developer who's never worked with Supabase RLS, those same defaults look like fully locked-down security. Until they look more carefully.

The result: apps that are functionally complete but structurally open in ways that aren't obvious during development.


Gap 1: The Service Role Key in the Frontend

This is the most severe issue we see in Lovable apps. It comes from a misunderstanding of Supabase client types.

Supabase has two clients:

// Anon client — safe for the browser
// Respects Row Level Security policies
const supabase = createClient(url, process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!)
 
// Service role client — admin access, bypasses ALL security
// Never use in the browser — ever
const supabaseAdmin = createClient(url, process.env.SUPABASE_SERVICE_ROLE_KEY!)

When Lovable generates code to "access the database from a component," it sometimes reaches for the service role key to make things work quickly, especially when RLS policies haven't been configured yet and the anon key returns empty results.

The service_role key in a NEXT_PUBLIC_ environment variable ships to every user's browser. Anyone who opens DevTools → Application → Local Storage, or just looks at the network request, has full admin access to your entire Supabase database.

The fix:

# .env — correct classification
NEXT_PUBLIC_SUPABASE_URL=https://xxxx.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbGci...   # ✅ Browser-safe, constrained by RLS
 
SUPABASE_SERVICE_ROLE_KEY=eyJhbGci...        # ✅ Server only — NO NEXT_PUBLIC_ prefix
// components/DataTable.tsx — client component
import { createClient } from "@supabase/supabase-js"
 
// Only anon key in client components
const supabase = createClient(
  process.env.NEXT_PUBLIC_SUPABASE_URL!,
  process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
)
// app/api/admin/route.ts — server only
import { createClient } from "@supabase/supabase-js"
 
export async function POST(req: Request) {
  // Service role only on the server
  const supabaseAdmin = createClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.SUPABASE_SERVICE_ROLE_KEY!  // Not NEXT_PUBLIC_
  )
  // ...
}

Run grep -r "NEXT_PUBLIC_.*SERVICE_ROLE\|NEXT_PUBLIC_.*SECRET" .env* to check your project right now.


Gap 2: Row Level Security Disabled or Set to "Allow All"

Lovable generates Supabase schemas, but the RLS policies it adds are often permissive. They're designed to make the app work during development, not to protect production data.

The two most common patterns:

-- Pattern A: RLS disabled entirely
-- (Supabase table created without enabling RLS)
-- Any authenticated user can read/write all rows
 
-- Pattern B: Policy that looks secure but isn't
CREATE POLICY "Authenticated users can read posts"
ON posts FOR SELECT
TO authenticated
USING (true);   -- ❌ "true" means every authenticated user sees every row

In a multi-user app, Pattern B means user A can read all of user B's posts, orders, messages, or whatever the table contains. Just by being logged in.

The fix — ownership-based policies:

-- Enable RLS on every table first
ALTER TABLE posts ENABLE ROW LEVEL SECURITY;
 
-- Each user reads only their own rows
CREATE POLICY "Users read own posts"
ON posts FOR SELECT
TO authenticated
USING (auth.uid() = user_id);
 
-- Each user modifies only their own rows
CREATE POLICY "Users modify own posts"
ON posts FOR ALL
TO authenticated
USING (auth.uid() = user_id)
WITH CHECK (auth.uid() = user_id);

To audit your existing policies, run this in the Supabase SQL editor:

SELECT
  schemaname,
  tablename,
  policyname,
  qual
FROM pg_policies
WHERE qual = 'true' OR qual IS NULL
ORDER BY tablename;

Any row where qual is true is a policy that allows all authenticated users access. Fix those first.


Gap 3: User IDs Exposed in URLs (IDOR)

Lovable naturally generates routes that include record identifiers in URLs — /orders/1042, /profile/user_abc123, /documents/doc_xyz. This is a normal pattern. The problem is what happens when there's no server-side ownership check.

// Lovable-generated page — works for the legitimate user
// app/orders/[id]/page.tsx
export default async function OrderPage({ params }: { params: { id: string } }) {
  const { data: order } = await supabase
    .from("orders")
    .select("*")
    .eq("id", params.id)
    .single()
 
  return <OrderDetail order={order} />
}

An attacker increments the ID: /orders/1041, /orders/1040. Each request returns another user's order. The query filters by ID but not by owner.

The fix — always filter by both ID and user:

export default async function OrderPage({ params }: { params: { id: string } }) {
  const supabase = createServerClient()   // Server-side client with user session
  
  const { data: { user } } = await supabase.auth.getUser()
  if (!user) redirect("/login")
 
  const { data: order } = await supabase
    .from("orders")
    .select("*")
    .eq("id", params.id)
    .eq("user_id", user.id)   // ← ownership check
    .single()
 
  if (!order) notFound()   // Returns 404, not someone else's data
 
  return <OrderDetail order={order} />
}

If you've set up correct RLS policies (Gap 2), the database enforces this for you automatically — the anon client with a user session will only return rows where user_id = auth.uid(). Defense in depth: do both.


Gap 4: No Rate Limiting on Auth and AI Endpoints

Lovable apps often include login, registration, password reset, and increasingly — AI-powered chat or generation endpoints. None of these have rate limiting by default.

Without rate limiting:

  • An attacker can try every password in rockyou.txt against your login endpoint — 14 million attempts
  • OTP codes (6 digits = 1,000,000 combinations) can be brute-forced in minutes
  • Your OpenAI or Anthropic bill can be destroyed overnight by a single script

The fix with Upstash Redis:

// lib/ratelimit.ts
import { Ratelimit } from "@upstash/ratelimit"
import { Redis } from "@upstash/redis"
 
export const authLimiter = new Ratelimit({
  redis: Redis.fromEnv(),
  limiter: Ratelimit.slidingWindow(5, "15 m"),  // 5 attempts per 15 minutes
  prefix: "auth",
})
 
export const aiLimiter = new Ratelimit({
  redis: Redis.fromEnv(),
  limiter: Ratelimit.slidingWindow(20, "1 h"),   // 20 AI requests per hour
  prefix: "ai",
})
// app/api/login/route.ts
import { authLimiter } from "@/lib/ratelimit"
 
export async function POST(req: Request) {
  const ip = req.headers.get("x-forwarded-for")?.split(",")[0].trim() ?? "unknown"
  const { success } = await authLimiter.limit(ip)
 
  if (!success) {
    return new Response("Too many attempts", { status: 429 })
  }
 
  // proceed with auth
}

Apply the same pattern to: registration, password reset, email verification resend, and every AI endpoint.


Gap 5: Sensitive Data in Client-Side State

Lovable apps often store fetched data in React state or Zustand stores to power the UI. This is correct. What's wrong is when that state includes fields that should never reach the browser.

Common examples:

// Fetched from Supabase and stored in client state
const [users, setUsers] = useState([])
 
// The query returns every column — including hashed passwords,
// internal flags, admin notes, billing details
const { data } = await supabase.from("users").select("*")
setUsers(data)

select("*") returns everything. Even if the UI only displays name and email, password_hash, stripe_customer_id, is_admin, and internal_notes are sitting in JavaScript memory — readable via DevTools or injected scripts.

The fix — explicit column selection:

// Only fetch what the UI actually needs
const { data } = await supabase
  .from("users")
  .select("id, name, email, avatar_url, created_at")  // explicit — no sensitive fields

On the API side — never return internal fields:

// app/api/users/me/route.ts
export async function GET(req: Request) {
  const user = await db.user.findUnique({ where: { id: session.user.id } })
 
  // Explicitly strip sensitive fields before returning
  const { passwordHash, stripeCustomerId, internalNotes, ...safeUser } = user
  return Response.json(safeUser)
}

How to Check Your Lovable App Right Now

Quick manual checks (5 minutes):

# 1. Service role key in browser bundle?
grep -r "NEXT_PUBLIC_.*SERVICE_ROLE\|NEXT_PUBLIC_.*SECRET\|NEXT_PUBLIC_.*PRIVATE" .env*
 
# 2. Tables with RLS disabled? (Supabase SQL editor)
# SELECT tablename FROM pg_tables WHERE schemaname = 'public'
# EXCEPT
# SELECT tablename FROM pg_policies GROUP BY tablename;
 
# 3. Permissive policies?
# SELECT tablename, policyname, qual FROM pg_policies WHERE qual = 'true';
 
# 4. select("*") queries that return sensitive tables?
grep -rn 'select("\*")' ./app --include="*.ts" --include="*.tsx"

Automated scan — paste your Lovable app URL into VibeWShield. It checks for exposed secrets in your JavaScript bundle, missing security headers, CORS misconfigurations, and rate limiting gaps in under 3 minutes — without requiring access to your source code.


The One Thing to Do Before Sharing Your App

Before you share your Lovable app with anyone outside your development environment: enable RLS on every table and audit every policy.

Everything else on this list matters, but a misconfigured RLS policy is the most common way Lovable apps expose all of one user's data to every other user. Fix that first, then work through the rest systematically.

Your app should ship fast. It doesn't have to ship insecure.

Frequently Asked Questions

Is every Lovable app vulnerable? Not every app, but the same security gaps appear in the majority of Lovable-generated projects. If you haven't manually reviewed RLS policies and API routes, assume there are issues.

Can I fix these without rewriting my app? Yes. Most fixes are small: add RLS policies, move API keys to server-side routes, add .eq("user_id", userId) filters. VibeWShield generates ready-to-paste fix prompts for each finding.

Does VibeWShield work with Lovable's Supabase setup? Yes. VibeWShield includes a dedicated Supabase auditor that checks RLS policies, storage bucket permissions, and exposed service keys specific to the Supabase + Lovable stack.


Check your Lovable app before someone else does. Scan now →

Scan your Lovable app →

Free security scan

Test your app for these vulnerabilities

VibeWShield automatically scans for everything covered in this article and more — 18 security checks in under 3 minutes.

Scan your app free