AI-coded apps

    Lovable App Supabase RLS Bypass (CVE-2025-48757 Fix)

    Supabase table editor showing Row Level Security disabled, the root cause behind the Lovable CVE-2025-48757 data exposure.

    If your Lovable app uses Supabase and you never explicitly locked down Row Level Security, treat the data as exposed until you prove otherwise. CVE-2025-48757 showed that a large share of Lovable projects shipped with missing or weak RLS, which let anyone read whole tables using the public anon key that is embedded in every client. The fix is not a patch you install, it is a database change: enable RLS on every table, replace any permissive policy with real owner checks, rotate any secret that reached the client, and verify with an anonymous request.

    Short answer

    CVE-2025-48757 is a Supabase Row Level Security exposure in Lovable-generated apps, disclosed by security researcher Matt Palmer on May 29, 2025. Because tables often had RLS disabled, the public anon key could query entire tables directly, exposing user lists, payment records, and more. According to Palmer's disclosure, the pattern affected any Lovable project using a database created on or before April 15, 2025. The fix is to enable and enforce RLS on every table, rotate exposed service keys, and test an unauthenticated request to confirm access is denied.

    What CVE-2025-48757 actually is

    CVE-2025-48757 is not a bug in Supabase or a single broken line of code, it is a systematic gap in how Lovable projects were generated: tables shipped without Row Level Security while the app relied entirely on the public anon key for access. Matt Palmer discovered the issue on March 20, 2025, notified the vendor on March 21, and published on May 29 after the coordinated disclosure window closed. His scan of Lovable projects found 303 exposed endpoints across 170 projects, roughly one in ten of the 1,645 he analyzed.

    The mechanism is simple and that is why it scaled. Supabase is designed so the anon key is safe to ship in the browser, because Row Level Security is supposed to decide which rows each request can see. When RLS is off, that safety net is gone, and the same anon key that loads your app can also dump your tables. Palmer notes that attackers could not only read data they should not see, but also modify REST requests to insert, update, or delete records.

    What made this a CVE rather than a one-off mistake is that the insecure pattern was generated automatically. When an AI tool scaffolds a database and wires it to a public key, it reproduces the same gap across thousands of projects, and most builders never open the Postgres layer where RLS actually lives. That is how a single default turned into 170 exposed projects: the people shipping the apps did not write the policy that was missing, and had no reason to know it should be there.

    Which keys are safe to expose and which are not

    The anon key is designed to be public, and the service_role key must never leave your server. This distinction is the whole game, and getting it wrong is how secrets end up in a shipped app. Supabase is explicit that the service keys "can be used to bypass RLS" and "should never be used in the browser or exposed to customers," per its Row Level Security documentation. The table below sums up the difference.

    KeySafe in the client?What it can doIf it leaks
    anon (public)Yes, when RLS is correctOnly what RLS policies allowLow risk on its own, but exposes everything when RLS is missing
    service_role (secret)NeverBypasses RLS entirely, full read and writeCritical, rotate immediately and treat all data as compromised
    Personal access tokenNeverManages your Supabase account and projectsCritical, revoke immediately

    The uncomfortable takeaway is that a public anon key is only as safe as your RLS. Shipping the anon key was never the vulnerability. Shipping it while tables had no policies was.

    The (select auth.uid()) trap

    The (select auth.uid()) trap is writing a policy that looks correct while the table it protects is still wide open. Developers copy an owner policy such as USING ((select auth.uid()) = user_id), see that it references the logged-in user, and assume the table is safe. Two things break that assumption. First, auth.uid() returns null for an anonymous request, so the check only helps if Row Level Security is actually enabled on the table, and tables created through raw SQL or the SQL editor have RLS off by default, as Supabase warns. A perfect policy on a table with RLS disabled protects nothing.

    Second, there is a performance edge that pushes people toward unsafe shortcuts. Calling auth.uid() unwrapped re-runs it for every row, which crawls on large tables, so Supabase recommends wrapping it: doing so "causes an initPlan to be run by the Postgres optimizer, which allows it to cache the results per-statement, rather than calling the function on each row." The trap is that a developer fighting slow queries loosens the policy to USING (true) to make the pain go away, and quietly reopens the whole table to the anon key. The correct move is to keep the owner check, wrap auth.uid() in a select for speed, and confirm RLS is enabled.

    How to patch it now, step by step

    Start by assuming exposure and closing the hole before you investigate the details. The order matters: stop the bleeding first, then rotate, then verify. The checklist below is the fast path.

    StepAction
    1. Enable RLS everywhereTurn on Row Level Security for every table, especially any created in the SQL editor
    2. Kill permissive policiesFind and remove any policy using USING (true) or an unscoped condition
    3. Add owner checksRecreate policies as USING ((select auth.uid()) = user_id) per operation (select, insert, update, delete)
    4. Move admin work off the clientEnsure no service_role key or admin query runs in the app; move it to an edge function or server
    5. Rotate exposed secretsRotate any service_role key or token that ever shipped in client code
    6. Verify as an anonymous userQuery each table with only the anon key and confirm you get nothing back

    Work table by table rather than trying to fix everything with one broad policy. Least privilege means each role and each operation gets only the access it needs, which is slower to write but far harder to get catastrophically wrong.

    Rotate credentials and verify the fix

    Rotating keys matters most for anything that bypasses RLS, and verification is what turns a hopeful fix into a confirmed one. If a service_role key or personal access token was ever present in your frontend bundle, a repository, or a build artifact, rotate it now and assume the old value is public. The anon key usually does not need rotation, because fixing RLS removes the actual exposure, but rotate it too if you are unsure what leaked.

    Verification is the step people skip and the one that actually proves you are safe. Using only the anon key, send a direct REST query to each sensitive table, exactly as an outsider would, and confirm it returns no rows. Repeat for insert, update, and delete. If any request succeeds when it should not, a policy is missing or RLS is still off on that table. Do not rely on the app's own UI to test this, because the UI only issues the queries you designed, while an attacker issues the ones you did not. Keep a short record of the tables you tested and the date, because you will want proof that access was closed if you ever have to answer users or a customer about the incident.

    Where a build scan fits, and where it does not

    A security scan of your shipped app catches the client half of this problem, not the database half. Many exposures in this class start in the build itself: a service_role key baked into the bundle, a hardcoded token, or an insecure network call that leaks credentials. Scanning the packaged app surfaces those before you release, which is exactly the kind of avoidable mistake that turns a private key into a public one.

    A scanner like PTKD.com analyzes your built .ipa or .apk and returns a graded report mapped to OWASP MASVS, flagging embedded secrets, exposed keys, and insecure configuration. It is important to be honest about the boundary: a build scanner cannot read or fix your Supabase Row Level Security policies, because those live server-side in your database, and only a real RLS review plus an anonymous test can confirm the backend is closed. Use the scan to catch what you shipped in the app, and use the RLS steps above to close what lives in the database. Neither one replaces the other.

    What to take away

    • CVE-2025-48757 exposed 170+ Lovable projects because Supabase RLS was missing, letting the public anon key read whole tables.
    • The anon key is meant to be public; the real fix is enabling and enforcing Row Level Security, not hiding that key.
    • The service_role key bypasses RLS and must never ship in client code; rotate it immediately if it ever did.
    • Watch the (select auth.uid()) trap: a correct-looking policy protects nothing if RLS is disabled, so confirm RLS is on and test as an anonymous user.
    • Scan the built app with PTKD.com to catch embedded secrets and exposed keys, then fix and verify RLS in Supabase.
    • #supabase
    • #row level security
    • #lovable
    • #cve-2025-48757
    • #api key exposure

    Frequently asked questions

    Am I affected by CVE-2025-48757?
    If your app was built on Lovable and uses a Supabase database, especially one created on or before April 15, 2025, assume you are affected until you confirm otherwise. The exposure came from Row Level Security being off, not from anything you did in the app. Check every table for RLS and test an anonymous query; if it returns rows, you are exposed.
    Is the Supabase anon key safe to expose?
    Yes, the anon key is designed to ship in the client, but only when Row Level Security is correct. It is unprivileged and returns just what your policies allow. The danger in CVE-2025-48757 was never the key itself, it was shipping the key while tables had no RLS, which let anyone read everything. Fix RLS and the anon key is safe again.
    What is the (select auth.uid()) trap?
    It is trusting a policy that looks right while the table stays open. A check like USING ((select auth.uid()) = user_id) only works if RLS is enabled, and tables made in the SQL editor have RLS off by default. Wrapping auth.uid() in a select is Supabase's performance guidance, but the trap is loosening the policy to USING (true) to fix slow queries and reopening the table.
    Do I need to rotate my Supabase keys?
    Rotate any service_role key or personal access token that ever appeared in client code, a repository, or a build artifact, and treat the old value as public. The anon key usually does not need rotation once RLS is fixed, because that removes the real exposure. When in doubt about what leaked, rotate it as well and re-test access.
    How do I verify the RLS fix worked?
    Using only the anon key, send a direct REST query to each sensitive table exactly as an outsider would, and confirm it returns no rows. Repeat for insert, update, and delete. Do not test through your app's UI, because it only issues the queries you designed. If any anonymous request succeeds when it should not, a policy is missing or RLS is still off.

    Keep reading

    Scan your app in minutes

    Upload an APK, AAB, or IPA. PTKD returns an OWASP-aligned report with copy-paste fixes.

    Try PTKD free