Supabase Backend skill for Claude Code
Treat the database as the security boundary it is. Claude builds Supabase projects with the CLI and versioned migrations, enables RLS on every exposed table with per-operation policies that are indexed and tested as two different users, and keeps the secret key off clients. Server auth uses @supabase/ssr with verified users, storage paths are scoped by user folder, and types are regenerated after each schema change.
- name
supabase-backend - size204 lines · ≈2,150 tokens
- files1
- licenseMIT
- updated
What Claude sees before loading it
This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.
description: Builds on Supabase safely - local development with the Supabase CLI, SQL migrations and db diff, Row Level Security on every exposed table, auth with @supabase/ssr in server frameworks, API keys and when the secret key is allowed, storage buckets and object policies, Edge Functions on Deno, generated TypeScript types, database functions and security advisors. Use when starting or reviewing a Supabase project, writing migrations or RLS policies for Supabase tables, wiring Supabase Auth into Next.js or SvelteKit, uploading files to Supabase Storage, or deploying Edge Functions. Try asking
- Add a projects table to Supabase with proper RLS for owners and org members
- Wire Supabase Auth into our Next.js App Router app
- Audit our Supabase project for tables anyone can read
Use it when
- Starting or reviewing a Supabase project
- Writing migrations and RLS policies for Supabase tables
- Integrating Supabase Auth in a server-rendered framework
- Setting up Storage buckets or deploying Edge Functions
Skip it when
- You need deep Postgres RLS design outside Supabase — use row-level-security
- The backend is Firebase and Firestore — use firebase-backend
How to check the result
supabase db reset rebuilds cleanly, policy tests show one user cannot read or modify another's rows or files, advisors report no tables without RLS, and regenerated types compile.
The common mistake it prevents
Creating tables in the public schema without enabling RLS, which lets anyone holding the publishable key read and write them.
Supabase exposes your Postgres schema directly to clients, so the database is the security boundary. A sound project has RLS enabled on every table in exposed schemas, policies that are indexed and tested, secret keys used only on servers, schema changes in versioned migrations, and types regenerated after every migration.
Workflow
- [ ] 1. Inspect: supabase/ folder, config.toml, migrations, which schemas are exposed, client key usage
- [ ] 2. Run locally: supabase start (Docker); link the remote project
- [ ] 3. Schema change as a migration; RLS and policies in the same migration
- [ ] 4. Test policies as anon, as user A, as user B
- [ ] 5. Regenerate types; update client code
- [ ] 6. Run advisors (security and performance); push migrations
- [ ] 7. Edge Functions and storage policies as needed
CLI basics
supabase init
supabase start # local Postgres, Auth, Storage, Studio
supabase link --project-ref <ref>
supabase migration new add_projects # creates supabase/migrations/<ts>_add_projects.sql
supabase db reset # rebuild local DB from migrations + seed.sql
supabase db diff -f add_projects # generate a migration from changes made in local Studio
supabase db push # apply pending migrations to the linked project
supabase gen types typescript --local > src/lib/database.types.ts
supabase db lint # schema linter (plpgsql issues)
Never edit the production schema in the dashboard without capturing it as a migration
(supabase db pull then commit); environments drift otherwise. db push against
production requires user confirmation.
RLS: the core of Supabase security
create table public.projects (
id uuid primary key default gen_random_uuid(),
owner_id uuid not null references auth.users (id) on delete cascade default auth.uid(),
org_id uuid not null references public.orgs (id),
name text not null check (char_length(name) between 1 and 200),
created_at timestamptz not null default now()
);
alter table public.projects enable row level security;
create index projects_owner_id_idx on public.projects (owner_id);
create index projects_org_id_idx on public.projects (org_id);
create policy "owners read their projects" on public.projects
for select to authenticated
using ((select auth.uid()) = owner_id);
create policy "owners insert projects for themselves" on public.projects
for insert to authenticated
with check ((select auth.uid()) = owner_id);
create policy "owners update their projects" on public.projects
for update to authenticated
using ((select auth.uid()) = owner_id)
with check ((select auth.uid()) = owner_id);
create policy "owners delete their projects" on public.projects
for delete to authenticated
using ((select auth.uid()) = owner_id);
Rules:
enable row level securityon every table inpublic(and any schema exposed via the Data API). RLS enabled with no policies means no access, which is a safe default.- One policy per operation (
select,insert,update,delete) with an explicit role (to authenticated); avoidfor allexcept for simple cases. usingfilters existing rows;with checkvalidates new row values. Updates need both, or users can move rows to another owner.- Wrap
auth.uid()andauth.jwt()in(select ...)so Postgres evaluates them once per query instead of per row, and index every column used in policies. - Team/org membership: check through a
security definerfunction in a non-exposed schema (e.g.private.is_org_member(org_id)), markedstable, withset search_path = '', to avoid recursive policy evaluation on the membership table. - Never base authorization on
raw_user_meta_data(users can edit it); useraw_app_meta_dataor your own tables, or custom claims via an auth hook. - Views bypass RLS by default; create them
with (security_invoker = true)(Postgres 15+) or keep them out of exposed schemas. security definerfunctions in exposed schemas run with owner rights for anyone who can call them; revokeexecutefromanon/authenticatedunless intended.
Deeper multi-tenant patterns and RLS performance analysis: see the row-level-security skill.
Testing policies
-- in psql or the SQL editor against local
begin;
set local role authenticated;
select set_config('request.jwt.claims', json_build_object('sub', '<user-a-uuid>', 'role', 'authenticated')::text, true);
select count(*) from public.projects; -- only user A's rows
insert into public.projects (name, org_id, owner_id) values ('x', '<org>', '<user-b-uuid>'); -- must fail
rollback;
Automate with pgTAP (supabase test db runs tests in supabase/tests/) or integration
tests that sign in two users with the JS client and assert cross-access fails.
API keys
| Key | Where | Bypasses RLS |
|---|---|---|
Publishable key (sb_publishable_..., formerly anon) | Browsers, mobile apps | No |
Secret key (sb_secret_..., formerly service_role) | Servers, Edge Functions, CI only | Yes |
Never ship the secret key to clients or NEXT_PUBLIC_* variables. Server code acting
on behalf of a user should use the user’s session (RLS applies), not the secret key.
Auth in server frameworks
// Next.js App Router: lib/supabase/server.ts
import { createServerClient } from "@supabase/ssr";
import { cookies } from "next/headers";
export async function createClient() {
const cookieStore = await cookies();
return createServerClient(process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!, {
cookies: {
getAll: () => cookieStore.getAll(),
setAll: (toSet) => { try { toSet.forEach(({ name, value, options }) => cookieStore.set(name, value, options)); } catch {} },
},
});
}
- Refresh sessions in middleware with the same
getAll/setAllpattern. - On the server, trust
supabase.auth.getUser()(verifies with Auth) orgetClaims()(verifies the JWT signature with asymmetric signing keys), notgetSession()alone, which reads unverified cookie data. - Configure redirect URLs and email templates per environment; enable leaked-password protection and MFA where appropriate.
Storage
insert into storage.buckets (id, name, public) values ('avatars', 'avatars', false);
create policy "users manage files in their folder" on storage.objects
for all to authenticated
using (bucket_id = 'avatars' and (storage.foldername(name))[1] = (select auth.uid())::text)
with check (bucket_id = 'avatars' and (storage.foldername(name))[1] = (select auth.uid())::text);
Upload to avatars/<user-id>/<file>; serve private files with createSignedUrl(path, 60).
Set bucket file size limits and allowed MIME types in bucket settings.
Edge Functions
// supabase/functions/stripe-webhook/index.ts
Deno.serve(async (req) => {
const body = await req.text(); // raw body for signature verification
// verify signature, then use a secret-key client for privileged writes
return new Response("ok");
});
supabase functions new stripe-webhook
supabase functions serve # local, with .env via --env-file
supabase secrets set STRIPE_WEBHOOK_SECRET=whsec_...
supabase functions deploy stripe-webhook --no-verify-jwt # only for webhooks that sign requests themselves
Functions verify the caller’s JWT by default; disable that only when the function authenticates requests another way.
Gotchas
- Tables created via SQL in
publicwithout RLS are readable and writable by anyone with the publishable key. The security advisor flags this; act on it. on delete cascadefromauth.usersdeletes user data when an account is removed; decide deliberately.- PostgREST returns at most the configured max rows (default 1000); paginate with
range(). - Realtime respects RLS for Postgres Changes only on tables added to the publication with RLS enabled; test with a second user.
- Connection strings: use the pooler (transaction mode) for serverless clients and session mode or direct connections for migrations.
Verification
supabase db resetrebuilds locally from migrations without errors.- Policy tests pass: anon sees nothing private, user A cannot read or write user B’s rows or storage objects.
- Security and performance advisors (dashboard Advisors, or
supabase db lint) show no tables without RLS and no unindexed policy columns. - Regenerated types compile with the app (
tsc --noEmit).
About this skill
Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.