A Lovable preview that works proves exactly one thing: the app works for the account that built it. Every request made during the build carried your session, so every policy that ran evaluated against your user ID and handed back your rows. The paths with no you in them were never exercised, and those are the paths a client’s users take.
That is the gap sitting behind the question is my Lovable app production ready. RLS being on, a clean pre-publish scan and a chat request to “make it secure” all leave it open, because none of the three tests the app from any vantage point except the builder’s. This is a predictable shape rather than a sign that the build went wrong.
TL;DR
- The builder’s vantage point is the first of nine failures, and it is the one the other eight come from.
- The other eight: a permissive RLS policy, a secret key in the bundle, user-writable role metadata, a double-submitted checkout, unheard webhook retries, signups the email provider refuses, no repository outside the tool, and the scan tier you did not run.
- Run the nine-item pre-handover pass below. One action per item, with the pass and fail condition stated.
What you see
The failure arrives in one of two shapes. Sometimes it is silent: nobody sees an error, and the only trace is a row somebody read that was not theirs, or a payment the app has no record of. Otherwise it is an error string that reached a user before it reached you, forwarded out of the client’s support inbox:
permission denied for table your_table
42501
new row violates row-level security policy for table "your_table"
All three carry the same SQLSTATE. Postgres raises 42501 when the request lacked adequate privileges, and Supabase notes these are often reported by clients as 401 or 403 (42501 errors, verified 2026-08). We ran both causes against a purpose-built table on 2026-08-27, and each came back as HTTP 401 with code 42501.
A role with no grant on the table:
{"code":"42501","details":null,"hint":"Grant the required privileges to the current role with: GRANT INSERT ON public.rls_probe TO anon;","message":"permission denied for table rls_probe"}
A role that has the grant, refused by the policy:
{"code":"42501","details":null,"hint":null,"message":"new row violates row-level security policy for table \"rls_probe\""}
The code is identical, so it does not tell you which of the two you are holding. The field that separates them is hint. A missing grant comes back with a hint naming the exact statement that would fix it. A policy rejection comes back with hint null, because there is nothing to suggest: the privilege was already there and a policy declined the row. That second case names the table, which is what misleads, since the cause is the policy that evaluated against the request and nothing in the response says which policy that was. The phrase itself is documented: Supabase carries it verbatim in a storage troubleshooting note (403 on upload, verified 2026-08).
What everyone checks first, and why it is the wrong layer
Three things usually get checked before a handover, and all three can pass while every row is still reachable.
Dead end 1: the RLS toggle is green on every table
Row-level security, meaning the Postgres rules that decide which rows a role may see, can be switched on for a table whose policy permits everything. The toggle reports that policies are being enforced, not what they allow. Supabase’s advisors do cover the permissive case, and it is worth knowing how far: permissive_rls_policy (0024, “Security policy allows unrestricted access”) sits alongside rls_enabled_no_policy (0008) and policy_exists_rls_disabled (0007) (database advisors, verified 2026-08).
The 0024 lint matches the literal always-true forms, meaning USING (true), 1=1 or a missing WITH CHECK, and reports them as a warning in a panel you have to open. A policy that is permissive in effect without being literally true appears on neither that list nor the toggle you looked at.
Dead end 2: the pre-publish scan came back clean
What you meet in the publish dialog is the Basic scan (publish, verified 2026-08), and the Deep scan does not run on its own (Lovable security view, verified 2026-08). The scan you ran is not the scan that looks for most of what follows.
Dead end 3: “there’s a key in the bundle”
A publishable key belongs there. Supabase documents access under a publishable key as guarded by Postgres through the built-in anon and authenticated roles, on the condition that RLS is enabled on all tables (API keys, verified 2026-08). That conditional is doing all the work. An alarm trained on key visibility fires on the normal case and stays quiet about the dangerous one, which is a secret key sitting in the same bundle.
What is actually happening: the preview is signed in as you
One mechanism sits under all three. Every path exercised during the build ran inside an authenticated session belonging to the owner account, which is yours.
- You build while signed in.
- Requests carry your JWT and resolve to the
authenticatedrole, one of the two roles the API-keys page above names. - Policies evaluate against your
auth.uid(). - Your rows come back.
- The screen is correct, and nothing on it distinguishes the policy restricted this correctly from the policy restricts nothing.
The paths that never ran are the ones with no you in them: signed-out, a second tenant’s account, a fresh signup, any request arriving without your session. That is why the errors above never fired during the build. They belong to the paths you did not take.
So this is one blind spot with nine consequences rather than nine separate oversights. The vantage point is the first of the nine, and the eight below follow from it.
The eight failures that follow from it
Each entry covers mechanism only: what is wrong, and why a working preview could not have shown it. The checks come in the next section.
RLS is on and the policy says true
A policy whose USING expression is true imposes no row restriction. Policies sit on top of the SQL-standard privilege system, and a row the expression does not return true for is not processed (PostgreSQL row security, verified 2026-08). When the expression returns true for every row, the granted role reaches everything the underlying grant already allows.
The 0024 lint catches the literal spelling of this. What nothing catches for you is the policy that is permissive in effect, keyed on a column every row satisfies or a role every visitor already has. Preview could not show either, because your session returned your own rows whether the policy restricted nothing or restricted correctly. Checked by pass items 2 and 4.
Two keys in the bundle, only one of them is fine
A secret or service_role key is categorically different: Supabase documents it as skipping any and all Row Level Security policies you attach, and as never for use in a browser, even on localhost (API keys, verified 2026-08). Stripe secret keys in client code are the same category. Preview could not show it, because a bypassing key renders the same working screen a correct one does. Checked by pass item 3.
The signup form can hand out roles
Supabase documents user_metadata as editable by the user without any checks, and says not to use it in a security-sensitive context such as an RLS policy or authorization logic (users, verified 2026-08). app_metadata is not user-updatable.
The advisor carries this at error level, as rls_references_user_metadata (0015), but it reads policies rather than the application code doing the same thing in a route handler. Reading a role out of user_metadata is reading user input and calling it a permission. Preview could not show it, because you signed up through your own form and sent only the fields it displays. Checked by pass item 8.
The double-click creates a second checkout session
A create-checkout handler with no idempotency key has nothing preventing a second session on a second click. With a key, Stripe stores the first request’s status code and body and returns that result for repeats (idempotent requests, verified 2026-08). A second session becomes a second charge only if both are completed, which is why it commonly sits latent rather than announcing itself. Preview could not show it, because you clicked once, watching the button work. Checked by pass item 5.
Stripe is retrying and nothing is listening
In live mode Stripe attempts delivery of an event for up to 3 days with an exponential backoff (webhooks, verified 2026-08). An endpoint that never returns 2xx fails every one of those attempts. If the app records the sale only on the webhook, the customer has been charged and there is no row for it. Preview could not show it, because a failed delivery is silent on the app’s side and you were testing the checkout screen. Checked by pass item 6.
The email provider stops at two
The built-in email provider sends 2 emails per hour, a figure the docs say you can only change by setting up custom SMTP (auth rate limits, verified 2026-08). The same documentation says the service is not meant for production use, gives it no delivery SLA, and states that Supabase Auth will refuse to deliver messages to addresses that are not part of the project’s team (custom SMTP, verified 2026-08). That is fine for a demo you run yourself.
On launch day the rate limit alone is enough. Four people sign up inside the same hour, and the third and fourth are refused outright with a rate-limit error rather than merely missing their email, so the response carries no user object and they have no account either.
The team restriction is the part not to trust in either direction. The documented wording is unambiguous, but when we ran this check on 2026-08-27 against a project with custom SMTP disabled and one member on the team, both of the messages that were not rate-limited were delivered to an address outside that team. One project proves nothing about yours, and the same page reserves the right to change the service without notice. So do not plan around the restriction, and do not read its absence in your own testing as evidence that it will not apply on launch day. Preview could not show any of this, because you signed up once, days apart, and the message arrived. Checked by pass item 7.
There is no copy outside Lovable
A project with no connected repository has no history outside the tool to restore from. Lovable documents the connection as two-way, so changes in Lovable sync to GitHub and pushes to the active branch sync back, and disconnecting leaves the repository, history and files, on GitHub (GitHub integration, verified 2026-08). Downloading the code, where your plan offers it, gives you a copy of one moment rather than the history. Preview could not show it, because nothing in a working app displays whether it can be restored. Checked by pass item 9.
The scan that passed was the Basic one
Basic covers RLS policy linting, schema review and dependency audits. Deep covers access control issues, unprotected backend endpoints, exposed secrets and unsafe input handling, and does not run on its own. The publish dialog offers it when Basic finds critical issues (security view, verified 2026-08).
Only one of the two is in front of you at publish time, and the split is not arbitrary. Basic’s RLS policy linting is where a literally always-true policy or a policy reading user_metadata would surface, if you open the findings. The code-level ones (a secret key in the bundle, a create-checkout call with no idempotency key, a handler that never returns 2xx) sit in the Deep scan’s description, and Deep is the one that does not run on its own. Run it before handover. It is the platform’s own check rather than an item in the pass below.
How to confirm it: the pre-handover pass
Nine checks, each with one action and both outcomes, all run by you against a project you control. An hour is enough, and the result pastes into a handover document. The checks and the failures are not one-to-one: the permissive policy takes two checks, and the scan tier has none, because that one belongs to Lovable.
- Signed-out session against your own project. Open the app in a clean browser profile, signed out. Pass: paths that should require auth render no data. Fail: content loads.
- Second-account read. Sign in as a second account you created in your own project. Pass: no rows belonging to account one. Fail: any row from account one.
Both run through the app, so they report what the app renders. A screen showing nothing is weaker evidence than a policy returning nothing, which is what item 4 is for.
- Bundle search for secret-key prefixes. Search your own built client bundle for
sb_secret_(Supabase API keys, verified 2026-08) and forsk_live_,sk_test_andrk_(Stripe API keys, verified 2026-08). Pass: the publishable key, meaningsb_publishable_orpk_, and nothing else. Fail: any of the above. One caveat decides this check: on legacy Supabase keys,anonandservice_roleare both JWTs and prefix-identical, so if what you find is a JWT, decode it and read itsroleclaim before passing the item. - Policy inventory. Run this in your SQL editor and keep the output. It is the rollback point for the next section.
select schemaname, tablename, policyname, cmd, qual, with_check
from pg_policies
where schemaname = 'public'
order by tablename, policyname;
Pass: no policy on a table holding user data has true as its qual or with_check. Fail: one or more do. Read the output rather than searching it for the word. true is the literal shape the 0024 lint also catches, and a policy can leave every row reachable without containing it, since a qualifier every row satisfies passes this check while restricting nothing. The query covers public only. One more thing the editor does quietly: results are capped at 100 rows by default, so on a project carrying more policies than that the inventory truncates without saying so. Raise the limit in the editor toolbar, or check the output against select count(*) from pg_policies where schemaname = 'public'; before you treat the list as complete.
- Stripe test-mode double-submit. In test mode, click the checkout button twice, quickly. Pass: one session. Fail: two.
- Test-mode event replay. Replay a test-mode event against the deployed endpoint, not the preview one. Pass: a 2xx and a recorded row. Fail: a non-2xx, or a 2xx with no row.
- Auth email burst. Fire four signups in quick succession, on the provider you intend to launch with, from addresses that are not on the project’s team. The hour in the rate limit is a window, not a waiting period: on the built-in provider the third request is refused within seconds. Pass: four users created and four emails delivered. Fail: any request coming back as HTTP 429 with
over_email_send_rate_limitand the messageemail rate limit exceeded, or a 200 whoseconfirmation_sent_atis set while nothing reaches the inbox. A refused request returns no user object at all. The 429 is loud at the API and usually silent in the interface, so check what your UI does with it, not only what the inbox shows. - Anything reading
user_metadatafor authorization. Run this in your SQL editor, then search the application code for the same read.
select schemaname, tablename, policyname, qual, with_check
from pg_policies
where qual ilike '%user_metadata%'
or with_check ilike '%user_metadata%';
Pass: no rows, and no application code path doing the same read. Fail: either. The 0015 lint finds the policy half for you. The code half is yours.
- Repository-connected check. Confirm the project is connected to a repository you or the client controls. Pass: a repository exists outside the tool. Fail: none.
Items 4, 7 and 8 were run against a live project on 2026-08-27 and behaved as described. The other six are stated from documented behaviour and have not been reproduced end to end.
Clearing all nine means these nine failures are closed for the cases you tested. It does not mean the app is secure. It means the list you could check has been checked, and the tenth item usually arrives from someone using the app in a way you did not model.
How to fix it safely
Before any schema or policy change: connect the repository and take a backup. Your rollback point is the policy inventory from item 4, captured before anything is edited. A tightened policy is undone by putting back the definition you saved, not by remembering what it said.
What you can do yourself, in the dashboard and SQL editor:
- Key policies on
auth.uid()rather thantrue. - Move role data out of
user_metadataintoapp_metadataor a roles table. - Configure custom SMTP before launch day.
- Keep the repository connection from step one, permanently.
What is a code change, and needs whoever built it:
- Secret-key calls moved server-side; Stripe secrets the same.
- An idempotency key on the create-checkout call.
- A webhook handler that returns 2xx and records the sale.
Each changes what the app permits, and each can break something that worked. Tightening a policy can lock out a feature that was fine five minutes ago, because it was relying on the permissive read. That is the trade, and it is why the backup comes first.
Why “make it secure” doesn’t close this
This is a claim about the scope of a request, not about what a model does or does not catch. A request covers what the asker knew to name. None of the nine announces itself, so none of them was in the prompt. The request is the limit.
The scan tiers have the same shape: Basic looked for the classes of finding it looks for, and the chat answered the question it was asked. A better prompt does not fix this. What fixes it is making the nine into named line items in your definition of done and in the handover document, checked by a person, with a date next to each.
When this is bigger than it looks
Most of the time the nine checks are the whole job: the list gets ticked, the two things it found get fixed, and the handover document says who checked what and when. You do not need us for that.
There is a variant where it stops being a checklist exercise. The client app already has real users, the policy inventory comes back permissive on tables holding other people’s data, and nobody can say from when. We have seen this shape of handover go wrong.
The question is then not which fix to apply but what was reachable and for how long, which is not a question you want to open at the end of a delivery week. A Production Readiness Audit produces a written list of launch blockers, prioritised by what blocks the handover.
Working through this list is a review rather than a penetration test. This desk does not attack apps. And if you have reason to think data has already been taken, that is live-incident work for a security-incident specialist rather than a readiness audit, and evidence should not go through a public contact form.
More on how this desk handles access and evidence.
So, is my Lovable app production ready?
The gap between “looks done” and “ready for anyone but its builder” is not a quality gap. It is a testing-vantage gap, and a list closes it faster than more building does.
Something built fast can still go to production. The useful question is not whether it works today but whether it works for someone who is not you. Nine is what is currently nameable, and the tenth will come from a user doing something nobody modelled, which is why the pass gets a date on it and gets re-run.
Run the nine checks first. If the answers come back unclear, or they come back clear and you still would not sign the handover, the Production Readiness Audit turns them into a written list of launch blockers, with scope and price agreed before anything in production changes.