Row Level Security (RLS)
Protecting user data with PostgreSQL policies — the most important security feature in Supabase.
Row Level Security (RLS) is a PostgreSQL feature that restricts which rows a user can access in a database table. It's the most important security feature in Supabase because it ensures that users can only access data they're authorized to see.
🔐 Why RLS Matters
- Data Isolation – Users only see their own data
- Security – Even if the API key is exposed, data is protected
- Multi-tenant – Build apps with multiple users safely
- Defense in Depth – Security at the database level
Without RLS
- All users can see all data
- Security depends on client-side code
- Easy to accidentally expose data
- Not suitable for multi-tenant apps
With RLS
- Users only see their own data
- Security at the database level
- Data is protected even if client code is compromised
- Essential for multi-tenant apps
⚠️ Critical: RLS Is Off by Default
In Supabase, RLS is disabled by default . You must explicitly enable it and create policies for each table. Without RLS, anyone with the anon key can read and write all data .
There are four main types of policies you can create:
Policies are created using SQL in the Supabase SQL Editor or through the Dashboard UI.
-- 1. Enable RLS on the table
ALTER TABLE profiles ENABLE ROW LEVEL SECURITY;
-- 2. Allow users to read any profile (public data)
CREATE POLICY "Allow public read access"
ON profiles
FOR SELECT
USING (true);
-- 3. Allow users to read only their own data
CREATE POLICY "Users can read their own profile"
ON profiles
FOR SELECT
USING (auth.uid() = id);
-- 4. Allow users to insert their own profile
CREATE POLICY "Users can insert their own profile"
ON profiles
FOR INSERT
WITH CHECK (auth.uid() = id);
-- 5. Allow users to update their own profile
CREATE POLICY "Users can update their own profile"
ON profiles
FOR UPDATE
USING (auth.uid() = id)
WITH CHECK (auth.uid() = id);
-- 6. Allow users to delete their own profile
CREATE POLICY "Users can delete their own profile"
ON profiles
FOR DELETE
USING (auth.uid() = id);
🔑 Understanding Policy Syntax
- USING – Checks if the row should be visible/accessible
- WITH CHECK – Checks if the new row should be allowed (INSERT/UPDATE)
- auth.uid() – Returns the current user's ID from the auth system
- auth.role() – Returns the user's role (authenticated, anon, service_role)
-- Pattern 1: Users can only see their own posts
CREATE POLICY "Users can see own posts"
ON posts
FOR SELECT
USING (auth.uid() = user_id);
-- Pattern 2: Users can see public posts and their own private posts
CREATE POLICY "Users can see public posts and their own"
ON posts
FOR SELECT
USING (
is_public = true OR auth.uid() = user_id
);
-- Pattern 3: Only admins can see all posts
CREATE POLICY "Admins can see all posts"
ON posts
FOR SELECT
USING (
EXISTS (
SELECT 1 FROM profiles
WHERE profiles.id = auth.uid()
AND profiles.role = 'admin'
)
);
-- Pattern 4: Users can only see posts from their team
CREATE POLICY "Users can see team posts"
ON posts
FOR SELECT
USING (
team_id IN (
SELECT team_id FROM team_members
WHERE user_id = auth.uid()
)
);
-- Pattern 5: Users can only update their own posts
CREATE POLICY "Users can update own posts"
ON posts
FOR UPDATE
USING (auth.uid() = user_id)
WITH CHECK (auth.uid() = user_id);
-- Pattern 6: Users can insert posts with their user_id
CREATE POLICY "Users can insert own posts"
ON posts
FOR INSERT
WITH CHECK (auth.uid() = user_id);
Here's how RLS works in practice with a Flutter app.
class
PostService
{
final
SupabaseClient client = Supabase.instance.client;
// When RLS is enabled, this will only return posts the user can see
Future
<List<Map<String, dynamic>>>
getPosts
() async {
return
await
client
.from(
'posts'
)
.select(
'*, profiles(username)'
)
.order(
'created_at'
, ascending:
false
);
}
// Create a post - RLS will ensure user_id matches auth.uid()
Future
<Map<String, dynamic>>
createPost
({
required
String title,
required
String content,
}) async {
final
user = client.auth.currentUser;
if
(user ==
null
)
throw
Exception(
'Not authenticated'
);
return
await
client
.from(
'posts'
)
.insert({
'user_id'
: user.id,
'title'
: title,
'content'
: content,
})
.select()
.single();
}
// Update a post - RLS will ensure the user owns the post
Future
<Map<String, dynamic>>
updatePost
({
required
String id,
required
String title,
required
String content,
}) async {
return
await
client
.from(
'posts'
)
.update({
'title'
: title,
'content'
: content,
})
.eq(
'id'
, id)
.select()
.single();
}
// Delete a post - RLS will ensure the user owns the post
Future
<
void
>
deletePost
(String id) async {
await
client
.from(
'posts'
)
.delete()
.eq(
'id'
, id);
}
}
⚠️ Important: RLS Errors
If a user tries to perform an operation that violates RLS policies, Supabase will return a 403 Forbidden error. Always handle these errors gracefully in your Flutter app.
RLS policies also apply to Realtime subscriptions . This means users will only receive updates for rows they're authorized to see.
class
RealtimeWithRLSService
{
final
SupabaseClient client = Supabase.instance.client;
RealtimeChannel? _channel;
final
List<Map<String, dynamic>> _messages = [];
final
List<Function> _listeners = [];
void
subscribeToMessages
(String roomId) {
_channel = client.channel(
'public:messages:room_id=eq.$roomId'
)
.onPostgresChange(
PostgresChangeEvent.insert,
(payload) {
// RLS automatically filters which messages are sent
// The client will only receive messages it's authorized to see
final
newRecord = payload.newRecord
as
Map<String, dynamic>;
_messages.add(newRecord);
_notifyListeners();
},
)
.subscribe();
}
void
_notifyListeners
() {
for
(
var
listener
in
_listeners) {
listener();
}
}
}
💡 RLS + Realtime = Secure Live Data
- Users only receive updates they're authorized to see
- No need to filter on the client side
- Security is enforced at the database level
Here's a complete blog app with RLS policies ensuring users only see and modify their own data.
import
'package:flutter/material.dart'
;
import
'package:supabase_flutter/supabase_flutter.dart'
;
import
'package:intl/intl.dart'
;
void
main
() async {
await
Supabase.initialize(
url:
'https://your-project.supabase.co'
,
anonKey:
'your-anon-key'
,
);
runApp(
MyApp
());
}
class
MyApp
extends
StatelessWidget {
@override
Widget
build
(BuildContext context) {
return
MaterialApp(
title:
'Secure Blog'
,
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
useMaterial3:
true
,
),
home: BlogScreen(),
);
}
}
// ==========================================
// 1. SECURE BLOG SERVICE (with RLS)
// ==========================================
class
SecureBlogService
{
final
SupabaseClient client = Supabase.instance.client;
Future
<List<Map<String, dynamic>>>
getMyPosts
() async {
final
user = client.auth.currentUser;
if
(user ==
null
)
return
[];
// RLS ensures only the user's posts are returned
return
await
client
.from(
'posts'
)
.select()
.eq(
'user_id'
, user.id)
.order(
'created_at'
, ascending:
false
);
}
Future
<Map<String, dynamic>>
createPost
({
required
String title,
required
String content,
}) async {
final
user = client.auth.currentUser;
if
(user ==
null
)
throw
Exception(
'Not authenticated'
);
// RLS WITH CHECK ensures user_id matches auth.uid()
return
await
client
.from(
'posts'
)
.insert({
'user_id'
: user.id,
'title'
: title,
'content'
: content,
})
.select()
.single();
}
Future
<Map<String, dynamic>>
updatePost
({
required
String id,
required
String title,
required
String content,
}) async {
// RLS ensures the user owns the post before updating
return
await
client
.from(
'posts'
)
.update({
'title'
: title,
'content'
: content,
})
.eq(
'id'
, id)
.select()
.single();
}
Future
<
void
>
deletePost
(String id) async {
// RLS ensures the user owns the post before deleting
await
client
.from(
'posts'
)
.delete()
.eq(
'id'
, id);
}
}
// ==========================================
// 2. UI
// ==========================================
class
BlogScreen
extends
StatefulWidget {
@override
State<BlogScreen>
createState
() => _BlogScreenState();
}
class
_BlogScreenState
extends
State<BlogScreen> {
final
SecureBlogService _blogService = SecureBlogService();
List<Map<String, dynamic>> _posts = [];
bool
_isLoading =
true
;
bool
_hasError =
false
;
String? _errorMessage;
final
TextEditingController _titleController = TextEditingController();
final
TextEditingController _contentController = TextEditingController();
@override
void
initState
() {
super
.initState();
_loadPosts();
}
Future
<
void
>
_loadPosts
() async {
setState(() {
_isLoading =
true
;
_hasError =
false
;
});
try
{
final
posts =
await
_blogService.getMyPosts();
setState(() {
_posts = posts;
_isLoading =
false
;
});
}
catch
(e) {
setState(() {
_isLoading =
false
;
_hasError =
true
;
_errorMessage = e.toString();
});
}
}
Future
<
void
>
_createPost
() async {
final
title = _titleController.text.trim();
final
content = _contentController.text.trim();
if
(title.isEmpty || content.isEmpty)
return
;
setState(() => _isLoading =
true
);
try
{
await
_blogService.createPost(title: title, content: content);
_titleController.clear();
_contentController.clear();
await
_loadPosts();
Navigator.pop(context);
}
catch
(e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'Error: $e'
),
backgroundColor: Colors.red,
),
);
setState(() => _isLoading =
false
);
}
}
Future
<
void
>
_deletePost
(String id) async {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text(
'Delete Post'
),
content: Text(
'Are you sure you want to delete this post?'
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(
'Cancel'
),
),
ElevatedButton(
onPressed: () async {
await
_blogService.deletePost(id);
Navigator.pop(context);
await
_loadPosts();
},
child: Text(
'Delete'
),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.red,
),
),
],
),
);
}
void
_showCreateDialog
() {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text(
'Create Post'
),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: _titleController,
decoration: InputDecoration(
hintText:
'Title...'
,
border: OutlineInputBorder(),
),
),
SizedBox(height:
8
),
TextField(
controller: _contentController,
decoration: InputDecoration(
hintText:
'Content...'
,
border: OutlineInputBorder(),
),
maxLines:
5
,
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(
'Cancel'
),
),
ElevatedButton(
onPressed: _createPost,
child: Text(
'Publish'
),
),
],
),
);
}
@override
Widget
build
(BuildContext context) {
final
user = Supabase.instance.client.auth.currentUser;
if
(user ==
null
) {
return
Scaffold(
appBar: AppBar(title: Text(
'Secure Blog'
)),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.lock, size:
64
, color: Colors.grey.shade400),
SizedBox(height:
16
),
Text(
'Please log in to view your posts'
,
style: TextStyle(fontSize:
18
),
),
SizedBox(height:
16
),
ElevatedButton(
onPressed: () {
// Navigate to login
},
child: Text(
'Log In'
),
),
],
),
),
);
}
return
Scaffold(
appBar: AppBar(
title: Text(
'🔒 My Posts'
),
centerTitle:
true
,
actions: [
IconButton(
icon: Icon(Icons.refresh),
onPressed: _loadPosts,
),
],
),
body: _isLoading
? Center(child: CircularProgressIndicator())
: _hasError
? Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.error, size:
64
, color: Colors.red),
SizedBox(height:
16
),
Text(_errorMessage ??
'Error loading posts'
),
SizedBox(height:
16
),
ElevatedButton(
onPressed: _loadPosts,
child: Text(
'Retry'
),
),
],
),
)
: _posts.isEmpty
? Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.post_add, size:
80
, color: Colors.grey.shade400),
SizedBox(height:
16
),
Text(
'No posts yet'
,
style: TextStyle(fontSize:
20
, fontWeight: FontWeight.w600),
),
Text(
'Create your first post!'
,
style: TextStyle(color: Colors.grey.shade400),
),
],
),
)
: ListView.builder(
padding: EdgeInsets.all(
8
),
itemCount: _posts.length,
itemBuilder: (context, index) {
final
post = _posts[index];
final
createdAt = DateTime.parse(post[
'created_at'
]);
return
Card(
margin: EdgeInsets.symmetric(horizontal:
8
, vertical:
4
),
child: Padding(
padding: EdgeInsets.all(
12
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
post[
'title'
],
style: TextStyle(
fontSize:
18
,
fontWeight: FontWeight.bold,
),
),
),
IconButton(
icon: Icon(Icons.delete, size:
20
, color: Colors.red),
onPressed: () => _deletePost(post[
'id'
]),
),
],
),
SizedBox(height:
4
),
Text(
post[
'content'
],
style: TextStyle(color: Colors.grey.shade700),
),
SizedBox(height:
8
),
Text(
DateFormat('MMM d, yyyy • h:mm a').format(createdAt),
style: TextStyle(
fontSize:
12
,
color: Colors.grey.shade500,
),
),
],
),
),
);
},
),
floatingActionButton: FloatingActionButton(
onPressed: _showCreateDialog,
child: Icon(Icons.add),
),
);
}
}
ALTER TABLE table_name ENABLE ROW LEVEL SECURITY;
for each table.
USING
conditions.
WITH CHECK
conditions.
USING
and
WITH CHECK
.
USING
conditions.
RLS is disabled by default. If you don't enable it, all data is accessible to everyone with the anon key.
Run
ALTER TABLE table_name ENABLE ROW LEVEL SECURITY;
for every table that contains user data.
Policies that don't check
auth.uid()
may expose data to unauthorized users.
Use
USING (auth.uid() = user_id)
or similar conditions to ensure data isolation.
When RLS blocks an operation, Supabase returns a 403 error. Your app must handle this gracefully.
Use try-catch blocks and show user-friendly messages when RLS blocks an operation.
🎯 Key Takeaway
Row Level Security is the most critical security feature in Supabase. Always enable RLS and create policies for each table . Use auth.uid() to ensure users can only access their own data. RLS protects your data even if client-side code is compromised.