RowShield
Guides

FlutterFlow + Supabase: the RLS half the tutorial skipped

The FlutterFlow-to-Supabase tutorial arc is consistent: configure the project URL and anon key in the integration panel, bind a query to a collection, wire authentication, ship. The app works, and the tutorial concludes. Missing is the half that decides whether the finished app is safe: Row Level Security on every table the integration touches, because the mobile bundle carries the same public anon key a web bundle does, and PostgREST honours the same policies regardless of which platform asked.

This page is that missing half. It explains what the phone in your user's pocket actually holds, why authenticated-in-the-app does not automatically mean authorised-in-the-database, and the policy work that binds the two — with the catalog checks to verify the result. A scoping note kept honest: RowShield does not analyse FlutterFlow projects and does not automate any interface-side review; what it automates is the database side — catalog audits and the anonymous probe — which is also where the risk concentrates.

RowShield does not detect this yet. This guide gives you the catalog queries to check it yourself. The nine rules that do ship are listed on the rules index.

Where tutorials end

The integration flow optimises for a visible result, and it delivers: queries return collections, lists populate on device, authentication screens behave. Nothing in the flow surfaces the fact that each successful query traverses PostgREST with credentials extracted from the app bundle, subject to whatever the target table's policies allow — or that scaffolded tables typically have no policies, making the anon identity maximally powerful rather than minimally.

The omission is structural rather than careless: authorisation happens server-side, invisibly, and its absence looks identical to its presence while the demo user is the only user. Tutorials teach the visible layer well. The invisible layer — who else could run these queries, with these credentials, from anywhere — is this page's subject, and it is the same subject for every mobile client of a Supabase backend, whatever built the interface.

What the phone actually holds

A distributed app ships its Supabase URL and anon key inside the binary and its assets. Extracting them from an APK is routine work with standard tools; treating app-store distribution as secrecy mistakes difficulty for impossibility. Plan for the credentials being public and permanent — published binaries cannot be recalled, only invalidated, and the anon key cannot be invalidated without breaking every installed copy.

In-app authentication changes the caller, not the surface: signed-in users present the authenticated role with a user identity attached, which policies then use for scoping. Unsigned-in sessions present anon. Either way the decisive logic lives in policies referencing auth.uid() — and where those policies are absent, both roles inherit whatever the bare grants allow, which in Supabase schemas is generous read access by design.

The policy half, done properly

Map bindings to policies before writing any: list every collection each screen queries and every mutation it performs, then confirm a matching policy exists for each command. Screens that legitimately read beyond one user — admin views, shared feeds — deserve their own explicit grants rather than a widened default that quietly covers every table in the project.

The work itself is the standard pattern applied to every table the integration touches. Ownership columns populated, RLS enabled and forced, policies scoped per command, WITH CHECK present on anything writable. The wrapped auth.uid() form costs nothing and keeps evaluation once-per-statement on the device-facing queries that grow largest:

ALTER TABLE public.journals ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.journals FORCE ROW LEVEL SECURITY;

CREATE POLICY "journals_owner_select"
  ON public.journals
  FOR SELECT
  TO authenticated
  USING ((SELECT auth.uid()) = user_id);

CREATE POLICY "journals_owner_insert"
  ON public.journals
  FOR INSERT
  TO authenticated
  WITH CHECK ((SELECT auth.uid()) = user_id);

Checking the result without guessing

Verification reads the catalog, which cannot be fooled by what the app seems to do. The first query lists every policy on every exposed table — the artifact tutorials never show; the second summarises posture per table. Between them they answer which of the queried tables are actually bounded:

Between the two outputs, the decisions write themselves: a queried table with zero policies is unfinished work, and a policy whose expression is a bare true is protection in name only. Everything else — roles, commands, expressions — is confirmation detail worth skimming once per release and whenever a screen gains a new binding.

SELECT tablename, policyname, cmd, roles, qual
FROM pg_catalog.pg_policies
WHERE schemaname = 'public'
ORDER BY tablename, policyname;

SELECT c.relname AS table_name,
       c.relrowsecurity AS rls_enabled,
       (SELECT count(*) FROM pg_catalog.pg_policies p
         WHERE p.schemaname = 'public'
           AND p.tablename = c.relname) AS policy_count
FROM pg_catalog.pg_class c
JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
WHERE c.relkind IN ('r', 'p') AND n.nspname = 'public';

Keeping it true across releases

Iterations tend to add tables and bindings quickly, which makes the database-side posture a moving target worth scheduling rather than remembering. RowShield audits the catalog on every scan — RLS state, policy presence, policy sanity — and its probe exercises the deployed backend from outside exactly as an extracted anon key would, using GET requests only. Findings alert on transitions, so a table added without policies surfaces as an event rather than lurking unseen in a list.

RowShield is an independent product, unaffiliated with and not endorsed by Supabase; it reads pg_catalog and bucket metadata only, never rows. The FlutterFlow side of the project remains yours to judge — what ultimately ships inside the bundle is visible to anyone who looks, which is the assumption this whole page is built on.

Frequently asked

Does FlutterFlow require RLS to function?
It does not — queries against unprotected tables succeed immediately, which is precisely the trap: the integration works identically with and without the security layer. Functioning proves connectivity, never authorisation, and the difference only becomes visible when someone other than you issues the same queries.
My users sign in through Supabase auth. Is that enough?
Sign-in gives each user an identity that policies can reference; it grants nothing by itself. Without policies scoped to auth.uid(), signed-in users fall back to the same bare grants as anonymous callers. Authentication supplies the principal; Row Level Security supplies the permissions — both halves are required.
How do I test what my shipped app exposes?
Extract nothing from the binary — the same requests work from a terminal: issue GETs against each table with the project URL and anon key, expecting permission errors wherever policies engage. Rows returned mean the table is readable anonymously, with the usual caveat that an empty array cannot distinguish filtered from unfilled.

Check your project in about ten seconds

Paste a URL. No signup, no writes, nothing stored.

Run the free audit
flutterflow supabase rlsflutterflow supabase policiesflutterflow supabase security setupsupabase rls mobile client