skills/supabase-security/SKILL.md
Supabase security best practices and patterns. Use when working with Supabase projects, creating tables, writing RLS policies, edge functions, or reviewing Supabase code. Invoke with '/supabase-security' or when asked about Supabase security.
npx skillsauth add opsmachine/om-agency supabase-securityInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Reference guide for secure Supabase development. Consult this when creating tables, writing policies, implementing edge functions, or reviewing code that uses Supabase.
Use this skill when:
-- ALWAYS do this for new tables
ALTER TABLE my_table ENABLE ROW LEVEL SECURITY;
-- Then add policies (see patterns below)
If RLS is not enabled, the table is PUBLIC to anyone with the anon key.
CREATE POLICY "Users can view own data"
ON my_table FOR SELECT
USING (auth.uid() = user_id);
CREATE POLICY "Users can insert own data"
ON my_table FOR INSERT
WITH CHECK (auth.uid() = user_id);
CREATE POLICY "Users can update own data"
ON my_table FOR UPDATE
USING (auth.uid() = user_id)
WITH CHECK (auth.uid() = user_id);
CREATE POLICY "Users can delete own data"
ON my_table FOR DELETE
USING (auth.uid() = user_id);
-- Users can see data belonging to their team
CREATE POLICY "Team members can view team data"
ON my_table FOR SELECT
USING (
team_id IN (
SELECT team_id FROM team_members
WHERE user_id = auth.uid()
)
);
CREATE POLICY "Only admins can access"
ON admin_table FOR ALL
USING (
EXISTS (
SELECT 1 FROM user_profiles
WHERE id = auth.uid() AND role_id = 'admin'
)
);
CREATE POLICY "Anyone can read"
ON public_content FOR SELECT
USING (true);
CREATE POLICY "Authenticated users can insert"
ON public_content FOR INSERT
WITH CHECK (auth.uid() IS NOT NULL);
-- Only see published items, or your own drafts
CREATE POLICY "See published or own drafts"
ON posts FOR SELECT
USING (
status = 'published'
OR author_id = auth.uid()
);
-- BAD: Overly permissive
CREATE POLICY "bad_policy" ON my_table FOR ALL USING (true);
-- BAD: Checking role in application, not database
-- (This can be bypassed!)
-- BAD: Forgetting WITH CHECK on INSERT/UPDATE
CREATE POLICY "incomplete" ON my_table FOR INSERT
USING (auth.uid() = user_id); -- Wrong! Need WITH CHECK
// This is fine - anon key + RLS protects data
const supabase = createClient(url, anonKey);
// ONLY in server/edge function context
// NEVER in client code
const supabaseAdmin = createClient(url, serviceRoleKey);
✅ Acceptable:
❌ Never:
// ALWAYS verify the JWT in edge functions
import { createClient } from '@supabase/supabase-js';
Deno.serve(async (req) => {
const authHeader = req.headers.get('Authorization');
if (!authHeader) {
return new Response('Unauthorized', { status: 401 });
}
const supabase = createClient(
Deno.env.get('SUPABASE_URL')!,
Deno.env.get('SUPABASE_ANON_KEY')!,
{
global: {
headers: { Authorization: authHeader },
},
}
);
// Verify the JWT by getting the user
const { data: { user }, error } = await supabase.auth.getUser();
if (error || !user) {
return new Response('Invalid token', { status: 401 });
}
// Now you have a verified user
// Continue with your logic...
});
// BAD: Trusting headers without verification
const userId = req.headers.get('x-user-id'); // Can be spoofed!
// BAD: Using service role without verification
const supabase = createClient(url, serviceRoleKey);
// Anyone can call this function!
// BAD: Not checking user permissions
const { data } = await supabase.from('admin_data').select('*');
// Should verify user is admin first!
// Pattern for admin-only operations
const { data: { user } } = await supabase.auth.getUser();
// Verify admin status from database (not from JWT claims alone)
const { data: profile } = await supabase
.from('user_profiles')
.select('role_id')
.eq('id', user.id)
.single();
if (profile?.role_id !== 'admin') {
return new Response('Forbidden', { status: 403 });
}
// ALWAYS validate on server/database, not just client
// Client validation is for UX, not security
// BAD: Only client validation
if (email.includes('@')) { /* submit */ }
// GOOD: Client validation + database constraint
// Database: CHECK (email ~* '^[^@]+@[^@]+$')
// BAD: Selecting all columns
const { data } = await supabase.from('users').select('*');
// May expose sensitive fields!
// GOOD: Select only needed columns
const { data } = await supabase
.from('users')
.select('id, name, avatar_url');
// Validate file type and size before upload
const allowedTypes = ['image/jpeg', 'image/png'];
const maxSize = 5 * 1024 * 1024; // 5MB
if (!allowedTypes.includes(file.type)) {
throw new Error('Invalid file type');
}
if (file.size > maxSize) {
throw new Error('File too large');
}
// Also enforce in storage bucket policies
-- Enforce data integrity at database level
CREATE TABLE orders (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES auth.users(id),
amount DECIMAL NOT NULL CHECK (amount > 0),
status TEXT NOT NULL CHECK (status IN ('pending', 'paid', 'cancelled')),
created_at TIMESTAMPTZ DEFAULT NOW()
);
// BAD: String interpolation
const { data } = await supabase
.from('users')
.select('*')
.filter('name', 'eq', userInput); // userInput could be malicious
// GOOD: Supabase client handles parameterization
// But validate/sanitize input anyway
const sanitized = userInput.replace(/[^a-zA-Z0-9 ]/g, '');
-- Use triggers for security invariants
CREATE OR REPLACE FUNCTION prevent_role_escalation()
RETURNS TRIGGER AS $$
BEGIN
IF OLD.role_id != NEW.role_id THEN
IF NOT is_admin(auth.uid()) THEN
RAISE EXCEPTION 'Only admins can change roles';
END IF;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
CREATE TRIGGER check_role_change
BEFORE UPDATE ON user_profiles
FOR EACH ROW EXECUTE FUNCTION prevent_role_escalation();
Use this when reviewing Supabase code:
USING (true) without good reasonauth.getUser()Risk: Full table access to anyone with anon key Fix: Enable RLS, add policies
Risk: Anyone can call admin functions
Fix: Always verify JWT with auth.getUser()
Risk: Complete database bypass Fix: Only use service role server-side
Risk: Users can access other users' data
Fix: Always scope to auth.uid() or verified membership
Risk: Claims can be stale or manipulated Fix: Verify permissions from database, not just JWT
# Check RLS status on all tables
supabase db lint
# View existing policies
psql -c "SELECT tablename, policyname, cmd, qual FROM pg_policies;"
# Test as specific user (in SQL editor)
SET request.jwt.claim.sub = 'user-uuid-here';
testing
Write failing tests for all planned acceptance criteria from the test plan. Use after /plan-tests, before implementation. Invoke with '/write-failing-test path/to/spec.md' or 'write failing test', 'red phase', 'start TDD'.
data-ai
Workflow manager that orchestrates the entire skill system. Runs automatically before any implementation work. Reads state from artifacts, determines the next skill, spawns sub-agents for execution, and manages human gates. Invoke with '/workflow-router' or it runs automatically per CLAUDE.md.
tools
Toolkit for interacting with and testing local web applications using Playwright. Supports verifying frontend functionality, debugging UI behavior, capturing browser screenshots, and viewing browser logs.
testing
Review a spec document for completeness before approval. Use after interview, before implementation, or when asked to 'review spec', 'check spec', or 'is this spec ready'. Read-only analysis that flags gaps.