A practical guide to Supabase RLS policies for Flutter apps, including the pattern I use for user-owned data, the mistakes that silently break it, and how to verify your policies actually work.
I shipped a Supabase backend without enabling RLS on one table. Not because I didn't know about it. I had RLS on every other table, had been careful throughout the project. I just missed one. The table stored user notes, and for about two weeks, any authenticated user could query any other user's notes by changing the user_id in the request filter.
Nobody did. Or at least, nobody reported it. But the query wasn't restricted. If someone had been curious, it was right there.
That was the last time I treated RLS as something to add after the schema is done. It goes in at table creation, every time, before writing any Flutter code that touches it.
What RLS does and why the default is dangerous
Supabase uses PostgreSQL under the hood, and RLS is a PostgreSQL feature. When you enable it on a table, every query run against that table has to satisfy at least one policy or it returns nothing. No policy, no rows.
By default, when you create a table in Supabase, RLS is disabled. That means any authenticated user with the anon or authenticated role can read every row in that table if they construct the right query. The Flutter Supabase client uses these roles. A user who knows your table name can query it from their own client and get back data that isn't theirs.
The Supabase dashboard has a toggle to enable RLS on each table. Once enabled, the table returns zero rows to anyone until you create policies. Policies define the conditions under which rows are visible, insertable, updatable, or deletable.
This is the pattern I use for any table where rows belong to individual users.
The user-owned data policy pattern
Most tables in a mobile app follow a simple ownership model: a row belongs to a user, that user can read and write it, nobody else can. Here's the SQL I write for every table that fits this model:
-- Enable RLS on the table
ALTER TABLE user_notes ENABLE ROW LEVEL SECURITY;
-- Users can only see their own rows
CREATE POLICY "Users can view own notes"
ON user_notes
FOR SELECT
USING (auth.uid() = user_id);
-- Users can only insert rows where they are the owner
CREATE POLICY "Users can insert own notes"
ON user_notes
FOR INSERT
WITH CHECK (auth.uid() = user_id);
-- Users can only update their own rows
CREATE POLICY "Users can update own notes"
ON user_notes
FOR UPDATE
USING (auth.uid() = user_id)
WITH CHECK (auth.uid() = user_id);
-- Users can only delete their own rows
CREATE POLICY "Users can delete own notes"
ON user_notes
FOR DELETE
USING (auth.uid() = user_id);The auth.uid() function is provided by Supabase. It returns the UUID of the currently authenticated user from the JWT in the request. When a Flutter client makes a query, that JWT is attached automatically by the Supabase client. The policy checks it against the user_id column in the table.
A few things to get right in the table schema: user_id should be a UUID column with a foreign key reference to auth.users(id). Add NOT NULL and a default of auth.uid() on the column so that the value is always set correctly on insert, even if the client forgets to include it.
CREATE TABLE user_notes (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
user_id UUID REFERENCES auth.users(id) NOT NULL DEFAULT auth.uid(),
content TEXT NOT NULL,
created_at TIMESTAMPTZ DEFAULT now()
);The DEFAULT auth.uid() on user_id means even if the Flutter client sends an insert without specifying user_id, the database fills it in from the authenticated session. This prevents a class of bug where the client accidentally omits it or a developer sets it to null.
Calling the table from Flutter
With these policies in place, the Flutter Supabase client just works. You don't need to filter by user_id in your queries, the database enforces it automatically.
// Fetch the current user's notes
// No need to filter by userId RLS does it
Future<List<Note>> fetchNotes() async {
final response = await Supabase.instance.client
.from('user_notes')
.select()
.order('created_at', ascending: false);
return (response as List)
.map((json) => Note.fromJson(json))
.toList();
}
// Insert a new note user_id is set by the database default
Future<void> createNote(String content) async {
await Supabase.instance.client
.from('user_notes')
.insert({'content': content});
}The select() on line 4 returns only the rows where user_id matches the authenticated user. No where clause needed. If someone intercepts the request and tries to remove the filter, the RLS policy blocks them at the database level. The Flutter client's filtering is a convenience, not a security boundary.
This is the conceptual shift that matters: your security model lives in the database, not in the Flutter code. The app can have bugs, someone can modify the client, network traffic can be inspected. None of that can bypass a correctly written RLS policy.
The mistakes that silently break RLS
The most common issue I've run into: forgetting to create policies for all four operations. If you create SELECT and INSERT policies but forget UPDATE and DELETE, updates and deletes silently fail. The client gets back no error. The operation just doesn't happen. You find out because data in your app never changes when the user edits it.
Supabase shows you which policies exist in the table editor. After creating policies, check that you have coverage for all four operations your app uses.
The second issue: using service role keys in Flutter. The service role key bypasses RLS entirely. It's meant for server-side code that needs unrestricted access. If you accidentally include it in your Flutter app's Supabase initialization, every user query runs with full database access and your RLS policies do nothing. Only the anon key goes in the Flutter client.
// This is correct, use the anon key in Flutter
Supabase.initialize(
url: 'https://your-project.supabase.co',
anonKey: 'your-anon-key', // Public, goes in the app
);
// NEVER put your service role key in Flutter code.
// It bypasses RLS and gives the client full database access.The third issue: testing with the dashboard SQL editor while logged in as a project admin. Admin queries bypass RLS. If you run SELECT * FROM user_notes in the Supabase SQL editor and see all rows, that doesn't mean your policies are working. Admin access always sees everything. To verify your policies, you need to query as an authenticated user, not as the admin.
Verifying your policies actually work
I use two methods to verify RLS before shipping.
First: the Supabase SQL editor has a way to impersonate a user role. You can set the role to authenticated and provide a user's JWT to test queries as that user. If the query returns only that user's rows and nothing else, the policy is working.
Second: write a test in your Flutter codebase that logs in as two separate test users, has user A create a record, and then verifies that user B's query doesn't return that record. This takes ten minutes to write and is far more reliable than manually checking the dashboard.
Future<void> verifyRlsIsolation() async {
// Log in as user A
await Supabase.instance.client.auth.signInWithPassword(
email: 'test_user_a@test.com',
password: 'testpassword',
);
// User A creates a note
await Supabase.instance.client
.from('user_notes')
.insert({'content': 'This belongs to user A'});
// Log in as user B
await Supabase.instance.client.auth.signInWithPassword(
email: 'test_user_b@test.com',
password: 'testpassword',
);
// User B should see zero notes
final notes = await Supabase.instance.client
.from('user_notes')
.select();
assert(
notes.isEmpty,
'RLS failure: user B can see user A notes',
);
}Run this against your test environment before any release that modifies the schema. A passing assertion means your policies are actually isolating data. A failure means you have a problem to fix before it reaches production.
Takeaways
- Enable RLS at table creation time, not as an afterthought.
- The user-owned data pattern: USING (auth.uid() = user_id) covers SELECT, UPDATE, and DELETE. WITH CHECK (auth.uid() = user_id) covers INSERT and UPDATE.
- Set user_id as NOT NULL with DEFAULT auth.uid() so the database fills it in automatically.
- Never put the service role key in a Flutter app. It bypasses RLS completely.
- The Supabase dashboard SQL editor runs as admin. Admin queries bypass RLS. Verify your policies by testing as an authenticated user.
RLS is the one place where a missed step has real consequences for users who never did anything wrong. Get the policies in place from the start and verify them before shipping. The pattern is simple once you've written it twice.
AUTHOR BIO Ali Wajdan is a Senior Flutter & Mobile Engineer with 5+ years of experience shipping apps used by 120k+ people across iOS and Android. I build cross-platform mobile apps, AI-integrated backends, and everything in between.
Portfolio: https://aliwajdanpasha.netlify.app LinkedIn: https://www.linkedin.com/in/aliwajdanpasha GitHub: https://github.com/Raees453 Twitter / X: https://x.com/awp453