supabase-security-expert — independently scanned and version-tracked by SaferSkills.
SaferSkills independently audited supabase-security-expert (Agent Skill) and scored it 100/100 (green). The audit ran 55 deterministic rules across Security, Supply Chain, Maintenance, Transparency, and Community; it found 0 high-severity and 0 lower-severity findings. The full rule-by-rule trace and per-finding evidence are below. Free, methodology-open.
Findings & checks · 0 flagged
Every scanned point with the score it earned and what moved between them.
First recorded scan — no prior version to compare against.
The primary manifest — the file an agent reads to learn what this artifact does.
Comprehensive security practices for Supabase: RLS, auth, API keys, and auditing.
Never expose the `service_role` key on the client. It bypasses ALL RLS.
// ❌ CRITICAL VULNERABILITY — service_role in browser
const supabase = createClient(url, process.env.SUPABASE_SERVICE_ROLE_KEY!)
// ✅ Correct — anon key on client, service_role only on server
// Client-side
const supabase = createClient(url, process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!)
// Server-side only (Server Actions, Route Handlers, API routes)
import { createClient } from "@supabase/supabase-js"
const adminClient = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!, // Never NEXT_PUBLIC_
{ auth: { persistSession: false } }
)# .env.local
NEXT_PUBLIC_SUPABASE_URL=https://xxx.supabase.co # ✅ public
NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJ... # ✅ public (RLS protects)
SUPABASE_SERVICE_ROLE_KEY=eyJ... # ❌ NEVER prefix NEXT_PUBLIC_
SUPABASE_JWT_SECRET=your-jwt-secret # ❌ server only-- Check which tables DON'T have RLS enabled
SELECT schemaname, tablename, rowsecurity
FROM pg_tables
WHERE schemaname = 'public' AND rowsecurity = FALSE;
-- Enable on all tables
ALTER TABLE your_table ENABLE ROW LEVEL SECURITY;
ALTER TABLE your_table FORCE ROW LEVEL SECURITY;-- 1. Users can only CRUD their own rows
CREATE POLICY "users_own_data" ON profiles
USING (auth.uid() = user_id)
WITH CHECK (auth.uid() = user_id);
-- 2. Public read, authenticated write
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);
-- 3. Organization-scoped access
CREATE POLICY "org_members_access" ON documents
USING (
org_id IN (
SELECT org_id FROM org_members WHERE user_id = auth.uid()
)
);
-- 4. Admin bypass (use sparingly)
CREATE POLICY "admin_full_access" ON documents
USING (
EXISTS (
SELECT 1 FROM user_roles
WHERE user_id = auth.uid() AND role = 'admin'
)
);-- Test as a specific user (in Supabase SQL editor)
SET request.jwt.claims = '{"sub": "USER_UUID_HERE", "role": "authenticated"}';
SET ROLE authenticated;
-- Now run your queries — RLS should apply
SELECT * FROM documents; -- should only return user's docs
-- Reset
RESET ROLE;// lib/supabase/server.ts
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)
),
},
}
)
}
// Usage in Server Component / Action
export async function getUser() {
const supabase = await createSupabaseServer()
const { data: { user }, error } = await supabase.auth.getUser()
// NOTE: use getUser() NOT getSession() — getUser() validates with server
if (error || !user) redirect("/login")
return user
}-- Supabase: auto-create profile on signup
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();-- Find tables with no RLS
SELECT tablename FROM pg_tables
WHERE schemaname = 'public' AND rowsecurity = FALSE;
-- Find tables with RLS but no policies (blocks all access — may be intentional)
SELECT tablename FROM pg_tables
WHERE schemaname = 'public' AND rowsecurity = TRUE
AND tablename NOT IN (
SELECT DISTINCT tablename FROM pg_policies WHERE schemaname = 'public'
);
-- List all policies
SELECT tablename, policyname, cmd, qual, with_check
FROM pg_policies
WHERE schemaname = 'public'
ORDER BY tablename, cmd;-- Revoke public access to sensitive functions
REVOKE EXECUTE ON FUNCTION your_sensitive_function FROM anon, authenticated;
-- Grant only what's needed
GRANT SELECT ON public.posts TO anon;
GRANT ALL ON public.profiles TO authenticated;
-- Never grant to public schema from anon unless intentional// Supabase Storage — bucket policies
// In Supabase dashboard → Storage → Policies
// Private bucket (default — good)
// Public bucket — only for truly public assets (avatars with obfuscated names)
// Upload with user prefix (enforce in RLS)
const { data, error } = await supabase.storage
.from("avatars")
.upload(`${user.id}/avatar.png`, file, { upsert: true })-- Storage RLS: users can only access their own folder
CREATE POLICY "user_owns_folder" ON storage.objects
FOR ALL USING (
bucket_id = 'avatars'
AND (storage.foldername(name))[1] = auth.uid()::text
);SECURITY DEFINER functions have SET search_path = ''SELECT * from sensitive tables in functionsgetUser() used on server — NOT getSession() (getSession doesn't validate)service_role key only in server env vars (no NEXT_PUBLIC_)JWT_SECRET rotated if ever exposed.env.local in .gitignoregetUser() validates the JWT with the server~30 seconds. Free. No account. Every finding cites a rule and a line of evidence.