Supabase Security Best Practices for Production Apps
Learn how to secure your Supabase application with Row Level Security, proper authentication, API key management, and more. Prevent data breaches with this comprehensive security guide.
Key Takeaways
- Enable RLS on every table containing user data before production deployment
- Use
(select auth.uid())instead ofauth.uid()in RLS policies for better query optimization- Never expose the service role key in client-side code; only the anon key is safe for browsers
- Index columns used in RLS policies to avoid 100x+ performance degradation on large tables
- Test RLS policies with different user contexts before deploying
- Document your security configuration for compliance audits (SOC 2 CC6, ISO 27001 5.15-5.18)
Supabase has become one of the most popular backend-as-a-service platforms for startups and growing companies. Its combination of PostgreSQL, real-time subscriptions, authentication, and storage makes it an attractive choice for rapid development. But that speed can come at a cost if security isn't built in from the start.
In January 2026, Moltbook exposed 1.5 million API keys because their Supabase database had Row Level Security disabled. Anyone with basic technical knowledge could access email addresses, authentication tokens, and API keys for every user on the platform. The fix took two SQL statements, but the damage was already done.
This guide covers everything you need to know to secure your Supabase application properly, from Row Level Security fundamentals to compliance considerations.
Row Level Security (RLS) Fundamentals
Row Level Security is the most critical security feature in Supabase. Without it, your database is essentially public. The Supabase client library includes your project URL and anon key in client-side code by design, and RLS is what prevents that key from granting access to data it shouldn't.
Why RLS is Non-Negotiable
When you create a table using SQL, migrations, or an ORM, RLS is disabled by default. Tables created through the Supabase Dashboard have RLS enabled automatically, but since most production applications use migrations, you should always explicitly enable RLS in your migration files. Without RLS, any authenticated request using the anon key can read and write any row in that table through the auto-generated REST API. This is intentional for development convenience, but it's catastrophic in production.
The Moltbook breach happened because RLS was never enabled on their agents table. Two SQL statements would have prevented the entire incident.
Enabling RLS on Tables
Enable RLS on every table that contains user data before your first production deployment:
-- Enable RLS on a table
ALTER TABLE profiles ENABLE ROW LEVEL SECURITY;
-- Verify RLS is enabled
SELECT tablename, rowsecurity
FROM pg_tables
WHERE schemaname = 'public';
Enabling RLS without creating any policies will deny all access by default. This is the secure starting point, and you then explicitly grant the access patterns you need.
Common RLS Policy Patterns
Users Can Only Access Their Own Data
The most common pattern: users can only read and modify rows they own.
-- Users can only see their own profile
CREATE POLICY "Users can view own profile"
ON profiles
FOR SELECT
TO authenticated
USING ((select auth.uid()) = user_id);
-- Users can only update their own profile
CREATE POLICY "Users can update own profile"
ON profiles
FOR UPDATE
TO authenticated
USING ((select auth.uid()) = user_id)
WITH CHECK ((select auth.uid()) = user_id);
-- Users can insert their own profile
CREATE POLICY "Users can insert own profile"
ON profiles
FOR INSERT
TO authenticated
WITH CHECK ((select auth.uid()) = user_id);
-- Users can delete their own profile
CREATE POLICY "Users can delete own profile"
ON profiles
FOR DELETE
TO authenticated
USING ((select auth.uid()) = user_id);
Note the use of (select auth.uid()) instead of just auth.uid(). Wrapping the function call in a subquery allows PostgreSQL to optimize the query plan more effectively, which can provide significant performance improvements on large tables.
The USING clause filters which existing rows the policy applies to (for SELECT, UPDATE, DELETE). The WITH CHECK clause validates new or modified rows (for INSERT, UPDATE).
Role-Based Access Control
For applications with different user roles, you can create policies that check role membership:
-- Create a function to check if user is admin
-- Place this in a separate schema (not public) for security
CREATE OR REPLACE FUNCTION private.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
SET search_path = public;
-- Grant execute to authenticated role only
GRANT EXECUTE ON FUNCTION private.is_admin() TO authenticated;
-- Admins can view all users
CREATE POLICY "Admins can view all users"
ON profiles
FOR SELECT
TO authenticated
USING (private.is_admin());
-- Regular users can only view their own profile
CREATE POLICY "Users can view own profile"
ON profiles
FOR SELECT
TO authenticated
USING ((select auth.uid()) = user_id);
Important Security Considerations for SECURITY DEFINER Functions:
- Never place in exposed schemas: Functions in the
publicschema can be called via the API, potentially leaking information. Use a separate schema likeprivate. - Always set search_path: Without this, attackers could exploit search path injection vulnerabilities.
- Restrict execution: Only grant execute to roles that need it.
Multiple policies on the same table are combined with OR logic for the same operation. If any policy grants access, the operation is allowed.
Public Read, Authenticated Write
For content that should be publicly readable but only modifiable by authenticated users:
-- Anyone can read published posts
CREATE POLICY "Public can read published posts"
ON posts
FOR SELECT
TO anon, authenticated
USING (published = true);
-- Authenticated users can read their own drafts
CREATE POLICY "Authors can read own drafts"
ON posts
FOR SELECT
TO authenticated
USING ((select auth.uid()) = author_id);
-- Only authors can insert posts
CREATE POLICY "Authors can insert posts"
ON posts
FOR INSERT
TO authenticated
WITH CHECK ((select auth.uid()) = author_id);
-- Only authors can update their own posts
CREATE POLICY "Authors can update own posts"
ON posts
FOR UPDATE
TO authenticated
USING ((select auth.uid()) = author_id)
WITH CHECK ((select auth.uid()) = author_id);
Organization-Based Access
For multi-tenant applications where users belong to organizations:
-- Users can access data from their organization
CREATE POLICY "Users can view organization data"
ON documents
FOR SELECT
TO authenticated
USING (
organization_id IN (
SELECT organization_id
FROM organization_members
WHERE user_id = (select auth.uid())
)
);
For performance with complex membership queries, consider using a helper function or caching organization membership in the JWT claims.
RLS Performance: Indexing Matters
RLS policies that filter by columns like user_id will perform poorly on large tables without proper indexes. Always index columns used in your policies:
-- CRITICAL: Index columns used in RLS policies
CREATE INDEX idx_profiles_user_id ON profiles(user_id);
CREATE INDEX idx_posts_author_id ON posts(author_id);
CREATE INDEX idx_documents_organization_id ON documents(organization_id);
For tables with millions of rows, missing indexes on RLS policy columns can cause 100x+ performance degradation. The (select auth.uid()) pattern shown in the examples above also helps PostgreSQL optimize query plans more effectively than bare auth.uid() calls.
Testing RLS Policies
Never deploy RLS policies without testing them. Supabase provides tools to test policies as different users:
-- Test as a specific authenticated user
SET LOCAL ROLE authenticated;
SET LOCAL request.jwt.claims = '{"sub": "user-uuid-here", "role": "authenticated"}';
-- Run your query
SELECT * FROM profiles;
-- Reset
RESET ROLE;
RESET request.jwt.claims;
You can also use the Supabase Dashboard's SQL Editor with the "Run as" feature to test queries as different users. Note that testing custom claims in the SQL editor has limitations because auth hooks don't trigger. For comprehensive RLS testing, consider using pgTAP for automated database testing.
Write automated tests that verify:
- Users can access their own data
- Users cannot access other users' data
- Unauthenticated requests are properly blocked
- Edge cases (null values, deleted users) are handled
// Example test with Supabase client
describe('RLS Policies', () => {
it('user cannot access another user profile', async () => {
// Sign in as user A
await supabase.auth.signInWithPassword({
email: 'userA@example.com',
password: 'password'
});
// Try to access user B's profile
const { data, error } = await supabase
.from('profiles')
.select('*')
.eq('user_id', 'user-b-uuid');
expect(data).toHaveLength(0);
});
});
Authentication Best Practices
Supabase Auth provides a complete authentication system, but proper configuration is essential.
Secure Authentication Configuration
In your Supabase Dashboard under Authentication > Settings:
Site URL and Redirect URLs: Only allow redirects to your actual domains. Never use wildcards in production.
# Good
https://yourapp.com
https://yourapp.com/auth/callback
# Bad - allows redirect to any subdomain
https://*.yourapp.com
Email Confirmations: Enable email confirmation for production applications. This prevents account enumeration and ensures email ownership.
Password Requirements: Set minimum password length (at least 8 characters, preferably 12+) and consider requiring complexity.
Session Management
Supabase uses JWTs for session management. Configure appropriate token lifetimes:
// supabase client configuration
const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY,
{
auth: {
autoRefreshToken: true,
persistSession: true,
detectSessionInUrl: true
}
}
);
In your Supabase Dashboard, configure:
- JWT expiry: Default is 3600 seconds (1 hour). Shorter is more secure but requires more frequent refreshes.
- Refresh token rotation: Enable to invalidate old refresh tokens when a new one is issued.
Token Handling on the Client
Never store tokens in localStorage for sensitive applications. Use httpOnly cookies when possible:
// For Next.js applications, use the SSR package
import { createServerClient } from '@supabase/ssr';
export function createClient() {
return createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
get(name: string) {
return cookies().get(name)?.value;
},
set(name: string, value: string, options: CookieOptions) {
cookies().set({ name, value, ...options });
},
remove(name: string, options: CookieOptions) {
cookies().set({ name, value: '', ...options });
},
},
}
);
}
Multi-Factor Authentication
Enable MFA for applications handling sensitive data:
// Enroll user in TOTP MFA
const { data, error } = await supabase.auth.mfa.enroll({
factorType: 'totp',
friendlyName: 'Authenticator App'
});
// data.totp.qr_code contains the QR code to display
// data.totp.secret contains the secret for manual entry
// Verify MFA during sign-in
const { data: challengeData } = await supabase.auth.mfa.challenge({
factorId: factorId
});
const { data: verifyData } = await supabase.auth.mfa.verify({
factorId: factorId,
challengeId: challengeData.id,
code: userProvidedCode
});
Create RLS policies that require MFA for sensitive operations by checking the Authenticator Assurance Level (AAL) claim:
-- Only allow access if MFA is verified (AAL2)
CREATE POLICY "MFA required for sensitive data"
ON sensitive_documents
FOR ALL
TO authenticated
USING (
(select auth.jwt() ->> 'aal') = 'aal2'
);
The aal claim indicates the authentication assurance level: aal1 means single-factor authentication, while aal2 indicates the user has completed multi-factor authentication.
API Key Management
Supabase provides two types of API keys, and understanding the difference is critical.
Anon Key vs Service Role Key
Anon Key (also called the public key):
- Safe to expose in client-side code
- Subject to RLS policies
- Should be the only key used in browsers and mobile apps
Service Role Key:
- Bypasses all RLS policies
- Has full database access
- Must NEVER be exposed in client-side code
// Client-side: Always use anon key
const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY // Safe to expose
);
// Server-side only: Service role key for admin operations
const supabaseAdmin = createClient(
process.env.SUPABASE_URL,
process.env.SUPABASE_SERVICE_ROLE_KEY // Never expose this
);
When to Use Each Key
Use the anon key for:
- All client-side operations
- User-facing API endpoints
- Any code that runs in the browser
Use the service role key for:
- Server-side admin operations
- Webhooks that need to bypass RLS
- Database migrations and seeding
- Background jobs running in secure server environments
Environment Variable Management
Never commit API keys to version control. For a deeper dive into secrets management, see our guide on secrets management best practices.
# .env.local (not committed)
NEXT_PUBLIC_SUPABASE_URL=https://yourproject.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
SUPABASE_SERVICE_ROLE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
# .gitignore
.env.local
.env*.local
For production deployments, use your platform's secret management:
- Vercel: Environment Variables in project settings
- AWS: Secrets Manager or Parameter Store (see also common AWS security misconfigurations)
- Google Cloud: Secret Manager
Rotate your service role key periodically and immediately if you suspect exposure.
Database Security
Beyond RLS, proper database design significantly impacts security.
Schema Design for Security
Keep sensitive data in separate tables with stricter policies:
-- Public profile information
CREATE TABLE profiles (
id UUID PRIMARY KEY REFERENCES auth.users(id),
username TEXT UNIQUE,
avatar_url TEXT,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Sensitive user data in separate table
CREATE TABLE user_private (
id UUID PRIMARY KEY REFERENCES auth.users(id),
email_verified BOOLEAN DEFAULT FALSE,
phone_number TEXT,
billing_address JSONB
);
-- Different RLS policies for each
ALTER TABLE profiles ENABLE ROW LEVEL SECURITY;
ALTER TABLE user_private ENABLE ROW LEVEL SECURITY;
-- Profiles might be publicly readable
CREATE POLICY "Profiles are publicly readable"
ON profiles FOR SELECT USING (true);
-- Private data is strictly user-only
CREATE POLICY "Users can only access own private data"
ON user_private FOR ALL USING (auth.uid() = id);
Foreign Key Constraints
Use foreign keys to maintain data integrity and prevent orphaned records:
CREATE TABLE posts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
author_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
title TEXT NOT NULL,
content TEXT,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE comments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
post_id UUID NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
author_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
content TEXT NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW()
);
The ON DELETE CASCADE ensures that when a user is deleted, their posts and comments are also removed, preventing data leakage from orphaned records.
Input Validation
While Supabase uses parameterized queries that prevent SQL injection, you should still validate input at the application layer:
import { z } from 'zod';
const createPostSchema = z.object({
title: z.string().min(1).max(200),
content: z.string().max(10000),
published: z.boolean().default(false)
});
async function createPost(input: unknown) {
const validated = createPostSchema.parse(input);
const { data, error } = await supabase
.from('posts')
.insert(validated)
.select()
.single();
return { data, error };
}
Validate on the server side even if you validate on the client. Client-side validation is for user experience; server-side validation is for security.
Views and RLS
By default, views bypass RLS because they're created with security_definer semantics. If you create views on tables with RLS, the view will see all rows regardless of the current user. For PostgreSQL 15+, use security_invoker to make views respect RLS:
-- Make views respect RLS (PostgreSQL 15+)
CREATE VIEW user_posts WITH (security_invoker = true) AS
SELECT id, title, created_at
FROM posts
WHERE published = true;
Without security_invoker = true, this view would expose all posts to any user who can access the view, bypassing your RLS policies on the posts table.
Realtime Subscriptions and RLS
Supabase Realtime subscriptions also respect RLS policies. When a user subscribes to changes on a table, they will only receive events for rows they have permission to access. This is important for applications using real-time features: your RLS policies automatically protect your Realtime channels.
// This subscription will only receive changes for rows the user can access
const channel = supabase
.channel('posts-changes')
.on(
'postgres_changes',
{ event: '*', schema: 'public', table: 'posts' },
(payload) => {
// Only receives changes for rows passing RLS policies
console.log('Change received:', payload);
}
)
.subscribe();
Edge Functions Security
Supabase Edge Functions run on Deno Deploy and provide serverless compute. Secure them properly.
Securing Edge Functions
Verify the JWT in every function that requires authentication:
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2';
Deno.serve(async (req) => {
// Get the authorization header
const authHeader = req.headers.get('Authorization');
if (!authHeader) {
return new Response(
JSON.stringify({ error: 'Missing authorization header' }),
{ status: 401 }
);
}
// Create client with the user's JWT
const supabase = createClient(
Deno.env.get('SUPABASE_URL')!,
Deno.env.get('SUPABASE_ANON_KEY')!,
{
global: {
headers: { Authorization: authHeader }
}
}
);
// Verify the user
const { data: { user }, error } = await supabase.auth.getUser();
if (error || !user) {
return new Response(
JSON.stringify({ error: 'Invalid token' }),
{ status: 401 }
);
}
// Proceed with authenticated request
// The supabase client will respect RLS policies for this user
});
CORS Configuration
Configure CORS headers appropriately:
const corsHeaders = {
'Access-Control-Allow-Origin': 'https://yourapp.com', // Specific origin, not *
'Access-Control-Allow-Methods': 'POST, OPTIONS',
'Access-Control-Allow-Headers': 'authorization, content-type',
};
Deno.serve(async (req) => {
// Handle preflight
if (req.method === 'OPTIONS') {
return new Response(null, { headers: corsHeaders });
}
// Your function logic
return new Response(
JSON.stringify({ message: 'Success' }),
{ headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
);
});
Rate Limiting
Implement rate limiting to prevent abuse:
// WARNING: This in-memory approach does NOT work reliably in Supabase Edge Functions
// because each invocation may run on a different isolate with separate memory.
// For production, use Redis, Supabase Database, or Upstash for distributed rate limiting.
// This example is for illustration purposes only.
const rateLimitMap = new Map<string, { count: number; resetTime: number }>();
function checkRateLimit(identifier: string, limit: number, windowMs: number): boolean {
const now = Date.now();
const record = rateLimitMap.get(identifier);
if (!record || now > record.resetTime) {
rateLimitMap.set(identifier, { count: 1, resetTime: now + windowMs });
return true;
}
if (record.count >= limit) {
return false;
}
record.count++;
return true;
}
Deno.serve(async (req) => {
const clientIP = req.headers.get('x-forwarded-for') || 'unknown';
if (!checkRateLimit(clientIP, 100, 60000)) { // 100 requests per minute
return new Response(
JSON.stringify({ error: 'Rate limit exceeded' }),
{ status: 429 }
);
}
// Continue with request handling
});
Secret Management
Store secrets in Supabase's secret management, not in code:
# Set a secret
supabase secrets set STRIPE_SECRET_KEY=sk_live_...
# Use in Edge Function
const stripeKey = Deno.env.get('STRIPE_SECRET_KEY');
Never log secrets or include them in error responses.
Storage Security
Supabase Storage uses the same RLS patterns as the database.
Bucket Policies
Create buckets with appropriate access levels:
-- Create a private bucket (no public access)
INSERT INTO storage.buckets (id, name, public)
VALUES ('private-documents', 'private-documents', false);
-- Create a public bucket (for assets like avatars)
INSERT INTO storage.buckets (id, name, public)
VALUES ('avatars', 'avatars', true);
File Access Policies
Define who can access files in each bucket:
-- Users can only access their own files in private bucket
-- Note: PostgreSQL arrays are 1-indexed, so [1] gets the first folder segment
CREATE POLICY "Users can access own files"
ON storage.objects
FOR SELECT
TO authenticated
USING (
bucket_id = 'private-documents'
AND (select auth.uid())::text = (storage.foldername(name))[1]
);
-- Users can upload to their own folder
CREATE POLICY "Users can upload to own folder"
ON storage.objects
FOR INSERT
TO authenticated
WITH CHECK (
bucket_id = 'private-documents'
AND (select auth.uid())::text = (storage.foldername(name))[1]
);
-- Authenticated users can upload avatars
CREATE POLICY "Authenticated users can upload avatars"
ON storage.objects
FOR INSERT
TO authenticated
WITH CHECK (bucket_id = 'avatars');
-- Anyone can view avatars
CREATE POLICY "Public avatar access"
ON storage.objects
FOR SELECT
TO anon, authenticated
USING (bucket_id = 'avatars');
Signed URLs for Private Files
For private files, generate signed URLs on the server:
// Server-side function to generate signed URL
async function getPrivateFileUrl(userId: string, filePath: string) {
// Verify user has access to this file
if (!filePath.startsWith(`${userId}/`)) {
throw new Error('Access denied');
}
const { data, error } = await supabaseAdmin
.storage
.from('private-documents')
.createSignedUrl(filePath, 3600); // 1 hour expiry
if (error) throw error;
return data.signedUrl;
}
Set appropriate expiry times for signed URLs. Shorter is more secure.
Monitoring and Auditing
Visibility into your Supabase application is essential for security.
Supabase Logs
Access logs through the Supabase Dashboard or API:
- API logs: All requests to your Supabase APIs
- Postgres logs: Database queries and errors
- Auth logs: Authentication events
- Edge Function logs: Function invocations and errors
Configure logging through the Supabase Dashboard under Project Settings > Database. Note that advanced logging configuration like custom log_statement settings requires contacting Supabase support, as ALTER SYSTEM commands require superuser privileges not available to users.
Query Monitoring
Use Supabase's built-in query performance monitoring to identify:
- Slow queries that might indicate missing indexes
- Unusual query patterns that could indicate abuse
- Failed queries that might indicate bugs or attacks
Alerting on Suspicious Activity
Set up alerts for security-relevant events:
// Example: Log and alert on failed authentication attempts
const { data, error } = await supabase.auth.signInWithPassword({
email,
password
});
if (error) {
// Log the failed attempt
await supabase.from('security_events').insert({
event_type: 'failed_login',
email: email,
ip_address: request.headers.get('x-forwarded-for'),
timestamp: new Date().toISOString()
});
// Check for brute force patterns
const { count } = await supabase
.from('security_events')
.select('*', { count: 'exact' })
.eq('event_type', 'failed_login')
.eq('email', email)
.gte('timestamp', new Date(Date.now() - 15 * 60 * 1000).toISOString());
if (count && count > 5) {
// Trigger alert or lock account
await notifySecurityTeam(`Multiple failed login attempts for ${email}`);
}
}
Integrate with external monitoring tools (Datadog, Sentry, PagerDuty) for comprehensive observability.
Compliance Considerations
If you're pursuing SOC 2, ISO 27001, or other compliance certifications, your Supabase configuration directly impacts your compliance posture.
How Supabase Security Maps to Compliance Requirements
SOC 2 CC6 (Logical and Physical Access Controls):
- RLS policies document your access control implementation
- Authentication configuration demonstrates identity verification
- API key separation shows principle of least privilege
- MFA satisfies multi-factor authentication requirements
SOC 2 CC8 (Change Management):
- Document your RLS policies and review them periodically
- Track changes to security configuration through migrations
- Maintain inventory of API keys and their purposes
ISO 27001 Controls 5.15-5.18 (Access Control):
- RLS provides role-based access at the database level (5.15, 5.18)
- MFA implementation satisfies strong authentication requirements (5.17)
- Storage policies enforce need-to-know access to files (5.15)
Note: For organizations with ISO 27001:2013 certifications, these map to Annex A.9 controls.
Documentation Requirements
Maintain documentation of:
- RLS Policies: What each policy does and why it exists
- API Key Inventory: What keys exist, what they're used for, who has access
- Authentication Configuration: Password policies, MFA requirements, session lifetimes
- Storage Policies: What data is stored, who can access it
Example policy documentation:
## RLS Policy: user_data_access
**Table**: user_profiles
**Created**: 2026-01-15
**Owner**: Security Team
**Purpose**: Ensures users can only access their own profile data
**Policy**:
- SELECT: user_id must match authenticated user
- UPDATE: user_id must match authenticated user
- INSERT: user_id must match authenticated user
- DELETE: Not allowed (soft delete only)
**Testing**: Covered by test suite in /tests/rls/user_profiles.test.ts
**Last Review**: 2026-02-01
Access Control Evidence
Auditors will want evidence that your access controls work. Provide:
- Screenshots of RLS configuration in Supabase Dashboard
- SQL exports of your RLS policies
- Test results showing policies block unauthorized access
- Logs showing authentication events
- Documentation of key rotation procedures
Security Checklist
Pre-Launch Checklist
Before deploying to production:
- RLS enabled on ALL tables containing user data
- RLS policies tested with different user contexts
- Service role key removed from all client-side code
- Environment variables properly configured (no keys in code)
- Email confirmation enabled for authentication
- Password minimum length set to 12+ characters
- Redirect URLs limited to your domains only
- Storage bucket policies configured
- Edge Function authentication implemented
- CORS configured with specific origins
- Rate limiting implemented on public endpoints
- Logging enabled for security events
- Database schema reviewed for sensitive data exposure
Ongoing Maintenance
Regular security tasks:
- Review RLS policies quarterly
- Audit API key usage and rotate if needed
- Review authentication logs for anomalies
- Update Supabase client libraries
- Test backup and recovery procedures
- Review and remove unused tables and columns
- Check for new Supabase security features
- Conduct penetration testing annually
Conclusion
Supabase provides powerful security features, but they require proper configuration. The Moltbook breach demonstrated what happens when these features are ignored: a simple misconfiguration exposed 1.5 million API keys to anyone who looked.
The security measures in this guide aren't optional extras. They're the baseline for any production application. RLS policies, proper key management, authentication configuration, and monitoring are fundamental requirements, not nice-to-haves.
Take the time to implement security correctly from the start. Two SQL statements to enable RLS and create a basic policy take minutes. Recovering from a data breach takes months, and some organizations never fully recover.
Frequently Asked Questions
Row Level Security is a PostgreSQL feature that restricts which rows users can access based on defined policies. In Supabase, RLS is essential because the client-side API key is exposed by design, and RLS policies are what prevent unauthorized access to data.
Yes, the anon key is designed to be exposed in client-side code. However, this is only safe when you have proper RLS policies configured on all tables. Without RLS, the anon key grants full read/write access to your database.
Use the Supabase Dashboard SQL Editor's "Run as" feature to test queries as different users. For comprehensive testing, write automated tests that verify users can access their own data and cannot access other users' data.
The anon key is subject to RLS policies and safe for client-side use. The service role key bypasses all RLS policies and should only be used in secure server-side environments for admin operations.
Yes, Realtime subscriptions respect RLS policies. Users will only receive events for rows they have permission to access according to your RLS policies.
Bastion helps startups and SMBs build secure applications and achieve compliance certifications like SOC 2 and ISO 27001. If you're building on Supabase and want to ensure your security controls meet compliance requirements, get started with Bastion.
Share this article
Related Articles
Moltbook Data Breach: AI Agent Security Lessons
In January 2026, Moltbook exposed 1.5 million API keys due to a Supabase misconfiguration. Learn what went wrong and how to prevent similar database security failures.
The Top AWS Security Misconfigurations we Find in Customer Environments
Unencrypted databases, exposed endpoints, IAM misuse: discover the AWS misconfigurations we fix most often during SOC 2 and ISO 27001 audits.
2026 Supply Chain Security Report: Lessons from a Year of Devastating Attacks
Software supply chain attacks doubled in 2025, with global losses reaching $60 billion. Analyze major attacks like Shai-Hulud, learn SOC 2 and ISO 27001 compliance requirements, and implement practical defenses.
Learn More About Compliance
Explore our guides for deeper insights into compliance frameworks.
What is an Information Security Management System (ISMS)?
An Information Security Management System (ISMS) is at the heart of ISO 27001 certification. Understanding what an ISMS is and how to build one is essential for successful certification. This guide explains everything you need to know.
ISO 27017 and ISO 27018: Cloud Security Standards
ISO 27017 and ISO 27018 extend ISO 27001 with specific guidance for cloud computing environments. Understanding these standards helps cloud service providers and their customers address cloud-specific security and privacy requirements.
Security Update Management: Staying Protected
Security update management (also known as patch management) is about keeping software current and protected against known vulnerabilities. When a vulnerability is discovered and publicised, attackers often develop exploits quickly. Timely patching is one of the most effective ways to protect your organisation.
Other platforms check the box
We secure the box
Get in touch and learn why hundreds of companies trust Bastion to manage their security and fast-track their compliance.
Get Started