AI-coded apps

    Supabase RLS is on but SELECT is true for all: am I exposed?

    Supabase RLS enabled with permissive SELECT policy USING true

    You opened the Supabase dashboard, saw a green RLS Enabled badge on every table, and felt fine. Then someone showed you that the SELECT policy body reads USING (true). This page is for the developer who needs to decide, in the next hour, whether the table is actually safe or whether the policy may as well not be there.

    Short answer

    A Postgres policy with the body USING (true) evaluates the boolean true for every visitor and lets every row through, while still counting as a policy in the pg_policies catalog. Per the Postgres CREATE POLICY documentation, permissive policies combine with the Boolean OR operator, so one USING (true) policy effectively cancels out every stricter policy on the same table. For user-owned rows, the correct body is auth.uid() IS NOT NULL AND auth.uid() = user_id.

    What you should know

    • USING (true) on a SELECT policy is functionally identical to no policy for read access. The literal boolean true matches every row.
    • "RLS enabled" in the dashboard means the feature is turned on, not that any policy filters anything. The two states are independent.
    • Permissive policies combine with OR. A stricter policy beside a USING (true) policy does not constrain access; the OR resolves to true on the open branch.
    • AI builders default to USING (true) because their dev-time tests run without a JWT, and a stricter body would correctly return no rows.
    • The Supabase database advisor flags multiple overlapping permissive policies, but a single USING (true) on a single table passes the advisor and Lovable's 2.0 security scan.

    What does "permissive" mean in Postgres RLS, and why is USING (true) the default?

    Postgres has two policy types: PERMISSIVE, the default when you write CREATE POLICY without modifiers, and RESTRICTIVE. Per the Postgres CREATE POLICY documentation, "all permissive policies which are applicable to a given query will be combined together using the Boolean OR operator." A single permissive policy with the body true returns every row in the table, because the OR resolves to true on the first branch.

    Supabase wraps this model and exposes policy creation through the dashboard. According to Supabase's official documentation on Row Level Security, auth.uid() returns null for unauthenticated requests, and the expression null = user_id evaluates to null, which Postgres treats as a denial. That is why the documentation recommends the explicit defensive form USING (auth.uid() IS NOT NULL AND auth.uid() = user_id) rather than the shorter USING (auth.uid() = user_id). Both work for authenticated traffic; the explicit form survives later edits unambiguously.

    Three reasons the AI agent reaches for USING (true) on SELECT.

    First, the agent's tests pass. When Cursor, Bolt, Lovable, v0, or Replit Agent scaffolds a feature, the test that follows runs without a JWT. A real policy that depends on auth.uid() correctly returns no rows. A USING (true) policy returns every row, and the feature passes its end-to-end check. The agent picks the body that makes the test green.

    Second, the training data leans toward the simplest example. Public CREATE POLICY snippets on GitHub and Stack Overflow over-represent USING (true) because the example is teaching syntax, not access control. The agent sees the simple body far more often than the defensive auth.uid() pattern and weights its output accordingly. The dev.to writeup that surveyed AI-generated RLS bodies across Cursor, Bolt, v0, and Lovable reports that the most common AI-generated SELECT body in audited projects is the bare USING (true).

    Third, dashboard tooling rewards the green check. The Supabase RLS toggle is binary. As soon as a table has any policy attached, the dashboard reports it as protected, and Lovable's security scan agrees. Per Supabase's published AI prompt for generating RLS policies, the recommended template explicitly avoids USING (true) for user-owned tables, but the prompt is opt-in. Agents that do not load that prompt fall back to the simpler body.

    What does a correct SELECT policy body look like?

    The correct body depends on what the table holds and who is allowed to read what. Three patterns cover most schemas an AI agent will scaffold.

    Access shapeUSING clauseWITH CHECK clauseNotes
    User-owned rows (todos, files, notes)auth.uid() IS NOT NULL AND auth.uid() = user_idauth.uid() = user_idThe IS NOT NULL guard makes the policy fail closed for unauthenticated traffic.
    Multi-tenant rows (org-scoped data)org_id IN (SELECT org_id FROM memberships WHERE user_id = auth.uid())Same expressionThe memberships table needs its own policy or the join leaks who belongs where.
    Public read, authenticated writeUSING (true) for SELECTauth.uid() IS NOT NULL for INSERT or UPDATEPublic read is intentional for blog posts, catalogs, public profiles.

    The trap is the third row. USING (true) is correct on a SELECT policy for a public catalog. The same body on a table that holds user data is the vulnerability. The check is whether the rows in the table should appear in a search engine result. If no, the policy belongs in row one or two.

    For INSERT and UPDATE on user-owned tables, the WITH CHECK clause is required separately. The Postgres docs are explicit: "existing table rows are checked against the expression specified in USING, while new rows that would be created via INSERT or UPDATE are checked against the expression specified in WITH CHECK." Without WITH CHECK, a user can update their own rows but write someone else's user_id into a new row.

    How do you find every USING (true) policy in your project?

    Four checks, in order of confidence:

    1. Run this SQL in the Supabase SQL editor: SELECT schemaname, tablename, policyname, cmd, qual, with_check FROM pg_policies WHERE schemaname = 'public' AND (qual = 'true' OR with_check = 'true');. The qual column is the USING clause; with_check is the WITH CHECK clause. Any row returned is a permissive body, and you can decide per row whether the table is supposed to be public.
    2. Open the Supabase dashboard, go to Authentication, then Policies. Sort by the column showing the USING expression and scan for any row reading true.
    3. From a logged-out browser session, copy the anon key from your bundle and run fetch('https://<project>.supabase.co/rest/v1/<table>?select=*', { headers: { apikey: '<key>', Authorization: 'Bearer <key>' }}) against each sensitive table. Tables that return rows are accessible to unauthenticated traffic.
    4. Use the Supabase database advisor under Database, Advisors. The advisor flags overlapping permissive policies and tables with RLS disabled, although it does not catch a single USING (true) on its own.

    For mobile apps that compile a Supabase backend into the binary, PTKD.com (https://ptkd.com) parses the project URL and anon key out of an APK or IPA and tests each REST endpoint against an unauthenticated query, surfacing the tables where a permissive policy is the only thing between an attacker and the data.

    What to watch out for

    Four details that show up in audits of fixed projects.

    First, permissive and restrictive are not interchangeable labels: they change how Postgres combines policies. Per the Postgres documentation, "all restrictive policies which are applicable to a given query will be combined together using the Boolean AND operator," and "when a mix of permissive and restrictive policies are present, a record is only accessible if at least one of the permissive policies passes, in addition to all the restrictive policies." A stricter permissive policy alongside a USING (true) policy is dead weight, because the OR short-circuits on true. If you want layered enforcement, the second policy has to be declared AS RESTRICTIVE.

    Second, the policy you see in the dashboard is not always the policy in your source. AI builders sometimes regenerate the migration that created the table, dropping and recreating it without carrying forward the policies you wrote by hand. Move the corrected policy into the schema migration file so the next scaffold includes it in source.

    Third, auth.role() = 'authenticated' is not the same check as auth.uid() IS NOT NULL AND auth.uid() = user_id. Some templates check the JWT audience instead of the user id, which lets every logged-in user read every other user's rows. Audit the qual column for any policy that names role instead of uid.

    Fourth, fixing the policy does not retroactively unbreach the data. If the Supabase logs show queries against the affected tables from unexpected IPs in the weeks before the fix, treat the affected records as compromised and follow your incident-response process. Supabase retains query logs for seven days on the free tier and longer on paid plans.

    Key takeaways

    • A SELECT policy with USING (true) is the body AI builders generate by default, and it is functionally identical to no policy at all for read access.
    • "RLS enabled" in the Supabase dashboard says nothing about what the policy body contains. Audit qual and with_check directly in pg_policies.
    • Permissive policies combine with OR, so a single USING (true) cancels out every stricter policy beside it. Use AS RESTRICTIVE only when you genuinely need an extra layer on top of a permissive base.
    • For mobile builds that ship a Supabase backend, PTKD.com (https://ptkd.com) scans the compiled APK or IPA and flags endpoints that respond to unauthenticated queries, which is one way teams catch the issue before submission to the App Store or Google Play.
    • Move the corrected policy into your migration source so the next AI scaffold cannot drop and recreate the table without it.
    • #supabase
    • #rls
    • #permissive-policy
    • #using-true
    • #ai-coded apps
    • #postgres

    Frequently asked questions

    What is the difference between RLS disabled and a SELECT policy of USING (true)?
    Functionally, none for read access. RLS disabled means Postgres skips policy evaluation; USING (true) means Postgres evaluates a policy that always returns true. Both let the anon key read every row. The Supabase database advisor warns about disabled RLS, but a single USING (true) policy passes the advisor and Lovable's security scan, which is why it is the more dangerous failure mode.
    Why does my AI agent keep writing USING (true) instead of auth.uid()?
    Because the agent tests its own work, and the test script almost never carries a JWT. A policy that depends on auth.uid() correctly returns no rows for an anonymous request, which the agent reads as a failure. USING (true) is the body that lets the feature run end to end without a logged-in user, so the agent picks it over the auth-scoped pattern.
    Will a RESTRICTIVE policy fix a USING (true) bug if I leave the permissive one in place?
    It can, but only if you write the second policy explicitly with AS RESTRICTIVE. By default Postgres creates PERMISSIVE policies, which combine with OR. A RESTRICTIVE policy combines with AND on top of the OR result. The simpler fix is to delete the permissive USING (true) policy outright and replace it with the auth-scoped pattern, rather than layering a restrictive one on top.
    Do I need a separate WITH CHECK clause for writes?
    Yes. USING governs which existing rows are visible to SELECT, UPDATE, and DELETE; WITH CHECK governs which new rows INSERT and UPDATE are allowed to write. A user-owned table needs both clauses present: USING (auth.uid() = user_id) and WITH CHECK (auth.uid() = user_id). Without WITH CHECK, a user can update their own rows but write someone else's user_id into a new row.
    Is this only a Supabase problem, or does it apply to any Postgres database?
    It applies to any Postgres database that exposes a public REST or GraphQL layer with row-level security on top. Supabase ships the pattern most commonly because PostgREST and the anon key make it easy to query tables directly from a browser. The same risk exists for Hasura, self-hosted PostgREST behind a gateway, and any custom Postgres deployment with a public anon role attached.

    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