| name | supabase-security-expert |
| description | Relational database auditing and Row-Level Security (RLS) best practices for Supabase. Use whenever the user is working with Supabase security, RLS policies, database auditing, PostgREST security, Supabase Auth, service role key protection, or securing Supabase APIs. Trigger on mentions of Supabase, RLS policies, anon key, service_role key, PostgREST, Supabase Auth, or database security audits. Also trigger when the user asks "is my Supabase secure" or wants to review their database policies.
|
Supabase Security Expert
Comprehensive security practices for Supabase: RLS, auth, API keys, and auditing.
The #1 Supabase Security Mistake
Never expose the service_role key on the client. It bypasses ALL RLS.
const supabase = createClient(url, process.env.SUPABASE_SERVICE_ROLE_KEY!)
const supabase = createClient(url, process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!)
import { createClient } from "@supabase/supabase-js"
const adminClient = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!,
{ auth: { persistSession: false } }
)
Key Management
NEXT_PUBLIC_SUPABASE_URL=https://xxx.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJ...
SUPABASE_SERVICE_ROLE_KEY=eyJ...
SUPABASE_JWT_SECRET=your-jwt-secret
RLS Policy Checklist
Enable RLS on Every Table
SELECT schemaname, tablename, rowsecurity
FROM pg_tables
WHERE schemaname = 'public' AND rowsecurity = FALSE;
ALTER TABLE your_table ENABLE ROW LEVEL SECURITY;
ALTER TABLE your_table FORCE ROW LEVEL SECURITY;
Policy Templates
CREATE POLICY "users_own_data" ON profiles
USING (auth.uid() = user_id)
WITH CHECK (auth.uid() = user_id);
CREATE POLICY "public_read" ON posts
FOR SELECT USING (published = true);
CREATE POLICY "author_write" ON posts
FOR ALL USING (auth.uid() = author_id)
WITH CHECK (auth.uid() = author_id);
CREATE POLICY "org_members_access" ON documents
USING (
org_id IN (
SELECT org_id FROM org_members WHERE user_id = auth.uid()
)
);
CREATE POLICY "admin_full_access" ON documents
USING (
EXISTS (
SELECT 1 FROM user_roles
WHERE user_id = auth.uid() AND role = 'admin'
)
);
Test RLS Policies
SET request.jwt.claims = '{"sub": "USER_UUID_HERE", "role": "authenticated"}';
SET ROLE authenticated;
SELECT * FROM documents;
RESET ROLE;
Auth Security
Server-Side Auth (Next.js)
import { createServerClient } from "@supabase/ssr"
import { cookies } from "next/headers"
export async function createSupabaseServer() {
const cookieStore = await cookies()
return createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll: () => cookieStore.getAll(),
setAll: (cs) => cs.forEach(({ name, value, options }) =>
cookieStore.set(name, value, options)
),
},
}
)
}
export async function getUser() {
const supabase = await createSupabaseServer()
const { data: { user }, error } = await supabase.auth.getUser()
if (error || !user) redirect("/login")
return user
}
Auth Hooks — Sync to Custom Table
CREATE OR REPLACE FUNCTION public.handle_new_user()
RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$
BEGIN
INSERT INTO public.profiles (id, email, name)
VALUES (
new.id,
new.email,
COALESCE(new.raw_user_meta_data->>'name', split_part(new.email, '@', 1))
);
RETURN new;
END;
$$;
CREATE OR REPLACE TRIGGER on_auth_user_created
AFTER INSERT ON auth.users
FOR EACH ROW EXECUTE FUNCTION public.handle_new_user();
API Security Audit
Check for Exposed Sensitive Data
SELECT tablename FROM pg_tables
WHERE schemaname = 'public' AND rowsecurity = FALSE;
SELECT tablename FROM pg_tables
WHERE schemaname = 'public' AND rowsecurity = TRUE
AND tablename NOT IN (
SELECT DISTINCT tablename FROM pg_policies WHERE schemaname = 'public'
);
SELECT tablename, policyname, cmd, qual, with_check
FROM pg_policies
WHERE schemaname = 'public'
ORDER BY tablename, cmd;
PostgREST / API Security
REVOKE EXECUTE ON FUNCTION your_sensitive_function FROM anon, authenticated;
GRANT SELECT ON public.posts TO anon;
GRANT ALL ON public.profiles TO authenticated;
Storage Security
const { data, error } = await supabase.storage
.from("avatars")
.upload(`${user.id}/avatar.png`, file, { upsert: true })
CREATE POLICY "user_owns_folder" ON storage.objects
FOR ALL USING (
bucket_id = 'avatars'
AND (storage.foldername(name))[1] = auth.uid()::text
);
Security Audit Checklist
Database
Auth
Keys & Secrets
Storage
Key Rules
service_role = root access — treat it like a database root password
getUser() not getSession() — only getUser() validates the JWT with the server
- Test policies with
SET ROLE authenticated in SQL editor
SECURITY DEFINER + SET search_path = '' on all auth-related functions
- RLS is not enough alone — add application-level checks for critical operations
- Supabase Vault for secrets — don't store API keys in plain text columns
- Audit log for sensitive ops — who deleted what and when
- Bucket = private by default — opt-in to public, never the reverse
- Confirm emails — prevent enumeration attacks with consistent responses
- Monitor for anomalies — Supabase dashboard shows API request logs