Supabase Backend skill for Codex
Migrations, RLS and auth checks that hold up. Codex builds Supabase backends the way they stay safe: schema changes as CLI migrations with regenerated types, RLS on every exposed table with policies tested as two real users, server authorization from getClaims or getUser instead of the unverified session, storage paths keyed by user or org, and Edge Functions that act as the caller. Pushing to the linked project and deploying functions wait for your go-ahead.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- supabase-backend Installs to ~/.agents/skills/supabase-backend/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install supabase-backend The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project supabase-backend Installs to .agents/skills/supabase-backend/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/supabase-backend.zipunzip -o supabase-backend.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) supabase-backend PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
104-line POSIX script. No sudo, no telemetry. Read install.sh
Use it in Codex
Start a prompt with $supabase-backend, or pick it from /skills.
You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.
What Codex reads before loading it
Builds and reviews Supabase backends - local development with the Supabase CLI, SQL migrations and generated TypeScript types, Auth with supabase-js and @supabase/ssr cookie sessions, server-side checks with getClaims or getUser, RLS policies that use auth.uid() efficiently, Storage buckets and storage.objects policies, Edge Functions on Deno with secrets, publishable and secret API keys, and database functions with safe search_path. Use when starting or extending a Supabase project, wiring Supabase Auth into Next.js or another SSR framework, writing policies for tables or storage, deploying Edge Functions, or auditing a Supabase app for exposed data.
Use it when
- Starting or extending a Supabase project
- Wiring Supabase Auth into an SSR framework
- Writing table or storage policies
- Creating or deploying Supabase Edge Functions
Use something else when
- Deep RLS policy design and performance in plain Postgres: use postgres-row-level-security
- Branching, pooling and cold starts for hosted Postgres: use serverless-postgres
How to check the result
Two-user tests show each user sees only their own rows and files, supabase db reset rebuilds from migrations, and no public-schema table has RLS disabled.
The mistake it prevents
Trusting getSession() in server code, which reads the cookie without verifying the token.
Done well means: every schema change is a migration in supabase/migrations, every table
in an exposed schema has RLS enabled with policies tested as real users, server code
authorizes from a verified token (never an unverified cookie session), and the secret key
exists only in server environments.
Inspect first
- Read
AGENTS.md; check forsupabase/config.toml,supabase/migrations/,supabase/functions/andsupabase/seed.sql. - Find client creation:
@supabase/supabase-js(createClient) and, for SSR frameworks,@supabase/ssr(createServerClient,createBrowserClient). - Note which keys are in use: newer projects use publishable (
sb_publishable_...) and secret (sb_secret_...) keys; older ones use theanonandservice_roleJWT keys. Publishable/anon keys are safe in browsers only because RLS protects the data. - Grep for the secret or service role key in client bundles:
rg -n "service_role|sb_secret_|SUPABASE_SERVICE" --glob '!supabase/**'.
Local workflow
supabase start # local stack in Docker (needs Docker; first run pulls images)
supabase migration new add_projects # empty SQL file in supabase/migrations
supabase db reset # rebuild local DB from migrations + seed.sql
supabase db diff -f add_projects # alternative: diff schema changes made in Studio into a migration
supabase gen types typescript --local > src/lib/database.types.ts
supabase test db # pgTAP tests in supabase/tests
- Pulling images and
supabase link/db push/functions deployneed network; in a sandbox without it, ask the user to run them. supabase db pushapplies migrations to the linked remote project, andsupabase db reset --linkedwipes it: both require explicit user confirmation, naming the project ref.- Regenerate types after every migration and commit them.
Schema and RLS
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 120),
created_at timestamptz not null default now()
);
create index on public.projects (owner_id);
create index on public.projects (org_id);
alter table public.projects enable row level security;
create policy "members read org projects" on public.projects
for select to authenticated
using (org_id in (select private.user_org_ids()));
create policy "owners insert" on public.projects
for insert to authenticated
with check (owner_id = (select auth.uid()));
create policy "owners update" on public.projects
for update to authenticated
using (owner_id = (select auth.uid()))
with check (owner_id = (select auth.uid()));
create schema if not exists private;
create or replace function private.user_org_ids()
returns setof uuid
language sql stable security definer set search_path = ''
as $$ select org_id from public.org_members where user_id = (select auth.uid()) $$;
Rules:
- Enable RLS on every table in
public(or any schema exposed through the Data API). A table without RLS is readable and writable with the publishable key. - Wrap
auth.uid()andauth.jwt()in(select ...)so Postgres evaluates them once per query instead of per row. Index every column a policy filters on. - Specify the role (
to authenticated) and the command on each policy; avoidfor all. updateneeds bothusingandwith check, or users can move rows to another owner.- Membership lookups across tables go through
security definerfunctions in a schema not exposed by the API, withset search_path = ''and fully qualified names. - Never base authorization on
raw_user_meta_data(users can edit it); useraw_app_meta_dataclaims set server-side, or a membership table. - Views bypass RLS by default; create them
with (security_invoker = true)(Postgres 15+). - Deep RLS design and performance work:
$postgres-row-level-security.
Auth in SSR apps (Next.js example)
// 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: (list) => {
try { list.forEach(({ name, value, options }) => cookieStore.set(name, value, options)); }
catch { /* called from a Server Component; middleware refreshes the session */ }
},
},
});
}
- Use only
getAll/setAllcookie methods with current@supabase/ssr. - Refresh sessions in middleware (or the framework’s equivalent) on every request.
- Authorize on the server with
supabase.auth.getClaims()(verifies the JWT, fast with asymmetric signing keys) orsupabase.auth.getUser()(round trip to Auth, freshest user state). Never trustgetSession()on the server: it reads cookies without verification. - Use environment variable names the project already has; the publishable key may be named
..._ANON_KEYin older setups. - OAuth and magic links need redirect URLs allow-listed in the dashboard or
config.toml.
Storage
insert into storage.buckets (id, name, public) values ('avatars', 'avatars', false);
create policy "users manage own avatar 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);
- Store objects under
<user_id>/...or<org_id>/...so policies can check the path. - Private buckets plus signed URLs (
createSignedUrl(path, 3600)) for user content; public buckets only for truly public assets. - Set bucket file size limits and allowed MIME types.
Edge Functions
// supabase/functions/send-invite/index.ts
import { createClient } from "npm:@supabase/supabase-js@2";
Deno.serve(async (req) => {
const authHeader = req.headers.get("Authorization") ?? "";
const userClient = createClient(Deno.env.get("SUPABASE_URL")!, Deno.env.get("SUPABASE_ANON_KEY")!, {
global: { headers: { Authorization: authHeader } },
});
const { data: { user }, error } = await userClient.auth.getUser();
if (error || !user) return new Response("Unauthorized", { status: 401 });
// ... validate input, act as the user (RLS applies) ...
return Response.json({ ok: true });
});
supabase functions new send-invite
supabase functions serve # local
supabase secrets set RESEND_API_KEY=... # user runs this; never echo secrets in chat
supabase functions deploy send-invite # confirm with the user before deploying
- Act as the calling user (forward the
Authorizationheader) so RLS applies. Use the secret/service key only for admin tasks, after your own authorization check. - Check the injected environment variable names for your project’s key type; default names have changed across key systems.
- Handle CORS (
OPTIONSpreflight) for browser callers. - Webhooks: verify signatures; Edge Functions with JWT verification disabled are public.
Audit checklist
-
select tablename from pg_tables where schemaname = 'public' and not rowsecurity;returns nothing. - No policy uses
using (true)on tables with private data, for any role. - Secret/service key absent from client code and
NEXT_PUBLIC_*/VITE_*variables. - Security definer functions set
search_pathand live outside exposed schemas. - Storage buckets with user content are private.
- Supabase advisors (Dashboard, or
supabase db lintlocally) show no security warnings.
Verification
- pgTAP or integration tests sign in as two different users and assert each sees only its own rows and cannot insert into the other’s org.
supabase db resetrebuilds cleanly from migrations; generated types match.- Edge Functions tested locally with
supabase functions serveand a real user token.
Output
**Migrations:** supabase/migrations/<files>
**RLS:** <table: policies (role, command, rule)>
**Auth:** <server check used, middleware refresh>
**Storage:** <buckets, public/private, path policy>
**Functions:** <name, auth mode, secrets needed (names only)>
**Tests:** <two-user isolation tests, results> What the zip contains
SKILL.md plus the interface file below. Nothing runs on install.
agents/openai.yaml
Gives the Codex app a display name, a one-line description and a starter prompt for this skill.
interface:
display_name: "Supabase Backend"
short_description: "Migrations, RLS and auth checks that hold up"
default_prompt: "$supabase-backend Set up Supabase auth in our Next.js app router project with server-side checks" About this skill
Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.