TL;DR
Supabase exposes your database directly to the frontend, which means Row Level Security (RLS) is essential. Without RLS, anyone with your anon key can read and write all your data. Enable RLS on every table, write policies that check user identity, and never expose your service role key to the browser. The anon key is public, so your security comes from RLS policies.
Understanding Supabase's Security Model
Supabase gives your frontend direct database access through its JavaScript client. This is powerful but requires understanding how security works:
- Anon key: Public key that can be in your frontend code
- Service role key: Private key that bypasses RLS (server-side only)
- Row Level Security: Database policies that control access
- Auth: User authentication that policies can reference
Critical: Without RLS enabled, anyone with your anon key (which is public) can read, modify, or delete all data in your tables. This is the #1 security mistake we see in Supabase projects.
The Two Keys You Need to Know
| Key Type | Can Be Public? | Respects RLS? | Use Case |
|---|---|---|---|
| anon key | Yes | Yes | Frontend/client-side |
| service_role key | No, never | No (bypasses) | Server-side only |
Never expose your service role key. If it's in your frontend code, environment variables visible to the browser, or a public repository, attackers can bypass all your security policies.
Row Level Security (RLS) Explained
RLS lets you define rules at the database level that control who can access which rows. Think of it as a filter that runs on every query.
Step 1: Enable RLS on Your Tables
-- Enable RLS on a table
ALTER TABLE posts ENABLE ROW LEVEL SECURITY;
-- Important: RLS is OFF by default on new tables
-- You must enable it explicitly for each table
Step 2: Create Policies
Without policies, RLS blocks all access. You need to create policies that define who can do what:
-- Anyone can read published posts
CREATE POLICY "Public posts are viewable by everyone"
ON posts FOR SELECT
USING (published = true);
-- Users can only insert their own posts
CREATE POLICY "Users can create their own posts"
ON posts FOR INSERT
WITH CHECK (auth.uid() = user_id);
-- Users can only update their own posts
CREATE POLICY "Users can update their own posts"
ON posts FOR UPDATE
USING (auth.uid() = user_id)
WITH CHECK (auth.uid() = user_id);
-- Users can only delete their own posts
CREATE POLICY "Users can delete their own posts"
ON posts FOR DELETE
USING (auth.uid() = user_id);
Understanding USING vs WITH CHECK
- USING: Filters which rows can be seen/affected (for SELECT, UPDATE, DELETE)
- WITH CHECK: Validates new data (for INSERT and UPDATE)
-- This policy allows updates but prevents changing user_id
CREATE POLICY "Users can update own posts, not ownership"
ON posts FOR UPDATE
USING (auth.uid() = user_id) -- Can only see own posts
WITH CHECK (auth.uid() = user_id); -- Can't change user_id to someone else
Common RLS Patterns
Pattern 1: User-Owned Data
-- For a table with a user_id column
CREATE POLICY "Users access own data"
ON user_settings FOR ALL
USING (auth.uid() = user_id)
WITH CHECK (auth.uid() = user_id);
Pattern 2: Organization/Team Access
-- Check if user belongs to the organization
CREATE POLICY "Team members access team data"
ON projects FOR ALL
USING (
EXISTS (
SELECT 1 FROM team_members
WHERE team_members.org_id = projects.org_id
AND team_members.user_id = auth.uid()
)
);
Pattern 3: Public Read, Authenticated Write
-- Anyone can read
CREATE POLICY "Public read" ON articles
FOR SELECT USING (true);
-- Only authenticated users can write
CREATE POLICY "Authenticated insert" ON articles
FOR INSERT WITH CHECK (auth.uid() IS NOT NULL);
-- Authors can edit their own
CREATE POLICY "Authors can update" ON articles
FOR UPDATE USING (auth.uid() = author_id);
Pattern 4: Role-Based Access
-- Create a function to check admin status
CREATE OR REPLACE FUNCTION is_admin()
RETURNS BOOLEAN AS $$
BEGIN
RETURN EXISTS (
SELECT 1 FROM user_roles
WHERE user_id = auth.uid()
AND role = 'admin'
);
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
-- Use in policy
CREATE POLICY "Admins can do anything" ON sensitive_data
FOR ALL USING (is_admin());
Testing Your RLS Policies
Always test policies before deploying. Supabase provides tools to help:
-- Temporarily act as a specific user
SET LOCAL ROLE authenticated;
SET LOCAL request.jwt.claims = '{"sub": "user-uuid-here"}';
-- Now run queries to test your policies
SELECT * FROM posts; -- Should only show allowed rows
-- Reset
RESET ROLE;
RLS Testing Checklist
Test as unauthenticated user (should see only public data)
Test as authenticated user (should see own data)
Test accessing another user's data (should be blocked)
Test INSERT with wrong user_id (should fail)
Test UPDATE to change ownership (should fail)
Test DELETE on another user's data (should fail)
Common Mistakes to Avoid
Mistake 1: Forgetting to Enable RLS
-- List all tables and their RLS status
SELECT tablename, rowsecurity
FROM pg_tables
WHERE schemaname = 'public';
-- If rowsecurity is 'f' (false), RLS is not enabled!
Mistake 2: Using Service Role Key in Frontend
// NEVER DO THIS
const supabase = createClient(
'https://xxx.supabase.co',
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...' // Service role key!
);
// Use anon key in frontend
const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY // Anon key is safe
);
Mistake 3: Overly Permissive Policies
-- DON'T DO THIS
CREATE POLICY "Allow everything" ON users
FOR ALL USING (true) WITH CHECK (true);
-- This defeats the purpose of RLS!
Mistake 4: Not Handling NULL user_id
-- Be explicit about NULL handling
CREATE POLICY "Users access own data"
ON profiles FOR ALL
USING (
auth.uid() IS NOT NULL -- Must be logged in
AND auth.uid() = user_id
);
Supabase Security Checklist
Before Going to Production
RLS enabled on ALL tables with data
Every table has appropriate policies
Service role key is NOT in frontend code
Service role key is NOT in public repository
Tested policies as different user types
No overly permissive policies (USING true)
Storage buckets have appropriate policies
Edge Functions use proper authentication
Database password is strong and not shared
Is the anon key really safe to expose?
Yes, the anon key is designed to be public. It only allows access that your RLS policies permit. Think of it like a locked door where everyone has a key, but the lock only opens for authorized people based on who they are. Your security comes from RLS policies, not from hiding the anon key.
What if I accidentally exposed my service role key?
Rotate it immediately in Supabase Dashboard under Settings > API. Then update your server-side code with the new key. Also audit your data for any unauthorized changes that might have occurred.
Do I need RLS if I only use server-side code?
If you only access Supabase from server-side code using the service role key, RLS isn't strictly necessary for security. However, it's still good practice because it provides defense in depth and protects against accidental frontend exposure.
How do RLS policies affect performance?
RLS policies run on every query, so complex policies can impact performance. Keep policies simple, use indexes on columns referenced in policies, and consider using security definer functions for complex logic that can be optimized.
Check Your Supabase Security
Scan your project for missing RLS policies and exposed keys.
Start Free Scan