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.