Claude Code and Supabase: Production Workflow for Auth, RLS, Storage, and Edge Functions
Build Supabase with Claude Code safely: Auth, RLS, Storage, Edge Functions, migrations, tests, and review checks.
Supabase is easy to demo and easy to misuse. If you ask Claude Code to “add login and a database” without boundaries, it can produce a working screen that still misses Row Level Security, Storage policies, migration discipline, and server-side session handling.
Supabase is a BaaS, or Backend as a Service: a hosted backend platform that combines Postgres, Auth, Storage, Edge Functions, and generated APIs. The useful part is not only speed. Because the data layer is Postgres, you can enforce authorization with SQL policies instead of trusting UI checks.
This guide uses Next.js App Router and TypeScript. It covers a practical Claude Code workflow: a requirements file, schema and RLS review, migration and type-generation commands, copy-paste implementation blocks, test commands, failure cases, and a final review checklist. For adjacent reading, use authentication implementation, database design, and database migration automation.
Start From Official Sources
Use the current Supabase docs as the baseline: Supabase Docs, Auth, Row Level Security, Edge Functions, and Storage.
For current Next.js projects, prefer @supabase/ssr for cookie-based Auth and use NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY for browser-safe access. Older projects may still use the legacy anon key, but new examples should point readers toward publishable keys and explicit RLS.
Architecture
The example is a project notes feature: signed-in users create notes, optionally upload a private attachment, and trigger an Edge Function for a notification-like task.
flowchart LR
User["Browser"] --> Next["Next.js App Router"]
Next --> SSR["@supabase/ssr client"]
SSR --> Auth["Supabase Auth"]
SSR --> DB["Postgres tables"]
SSR --> Storage["Storage bucket"]
Next --> Fn["Edge Function"]
Fn --> DB
DB --> RLS["RLS policies"]
Storage --> StorageRLS["storage.objects policies"]
The security boundary lives in Postgres and Storage policies. The UI can improve UX, but it must not be the only place that checks ownership.
Requirements File for Claude Code
Put the scope in the repository before asking for implementation.
# docs/supabase-notes-requirements.md
## Goal
Build a Supabase-backed project notes feature in Next.js App Router.
## Stack
- Next.js App Router
- TypeScript
- @supabase/supabase-js
- @supabase/ssr
- Supabase Auth, Postgres, Storage, Edge Functions
## Data model
- project_notes table
- Each note belongs to auth.users.id through owner_id
- Public notes are readable by anyone
- Private notes are readable only by the owner
- Owners can insert, update, and delete only their own notes
## Security rules
- Never expose a secret key or service role key in browser code
- Use NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY for browser and SSR clients
- Enable RLS on every public table
- Use explicit TO anon or TO authenticated in every policy
- Storage uploads must be restricted to a user-owned folder
## Claude Code workflow
1. Create SQL migration first.
2. Review RLS policies before writing UI.
3. Generate TypeScript database types.
4. Implement Supabase clients.
5. Implement server actions and upload helper.
6. Add test or manual verification commands.
7. Return a review checklist with file paths.
Ask Claude Code for the SQL migration first, not the UI. That keeps the highest-risk part small enough to review.
Setup
npm install @supabase/supabase-js @supabase/ssr zod
npm install --save-dev supabase vitest
npx supabase init
npx supabase start
Keep real values in .env.local, and share only variable names with Claude Code.
NEXT_PUBLIC_SUPABASE_URL=https://your-project-ref.supabase.co
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=sb_publishable_xxxxxxxxxxxxxxxxxxxx
Do not put secret keys or legacy service role keys into NEXT_PUBLIC_ variables. If a server-only task needs elevated access, isolate it and document why RLS bypass is required.
Migration and RLS
-- supabase/migrations/202606010001_create_project_notes.sql
create table if not exists public.project_notes (
id uuid primary key default gen_random_uuid(),
owner_id uuid not null references auth.users(id) on delete cascade,
title text not null check (char_length(title) between 1 and 120),
body text not null default '',
visibility text not null default 'private'
check (visibility in ('private', 'public')),
attachment_path text,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
create index if not exists project_notes_owner_created_idx
on public.project_notes (owner_id, created_at desc);
create or replace function public.set_updated_at()
returns trigger
language plpgsql
as $$
begin
new.updated_at = now();
return new;
end;
$$;
drop trigger if exists set_project_notes_updated_at on public.project_notes;
create trigger set_project_notes_updated_at
before update on public.project_notes
for each row
execute function public.set_updated_at();
alter table public.project_notes enable row level security;
create policy "Anyone can read public notes"
on public.project_notes
for select
to anon, authenticated
using (
visibility = 'public'
or (select auth.uid()) = owner_id
);
create policy "Owners can insert notes"
on public.project_notes
for insert
to authenticated
with check ((select auth.uid()) = owner_id);
create policy "Owners can update notes"
on public.project_notes
for update
to authenticated
using ((select auth.uid()) = owner_id)
with check ((select auth.uid()) = owner_id);
create policy "Owners can delete notes"
on public.project_notes
for delete
to authenticated
using ((select auth.uid()) = owner_id);
The important details are explicit roles and owner checks. auth.uid() is null for anonymous requests, so public reads and owner reads need clear logic.
Add a private Storage bucket with folder-based policies.
insert into storage.buckets (id, name, public, file_size_limit, allowed_mime_types)
values (
'note-attachments',
'note-attachments',
false,
5242880,
array['image/png', 'image/jpeg', 'application/pdf']
)
on conflict (id) do update
set public = excluded.public,
file_size_limit = excluded.file_size_limit,
allowed_mime_types = excluded.allowed_mime_types;
create policy "Users can read own note attachments"
on storage.objects
for select
to authenticated
using (
bucket_id = 'note-attachments'
and (select auth.uid())::text = (storage.foldername(name))[1]
);
create policy "Users can upload own note attachments"
on storage.objects
for insert
to authenticated
with check (
bucket_id = 'note-attachments'
and (select auth.uid())::text = (storage.foldername(name))[1]
);
create policy "Users can update own note attachments"
on storage.objects
for update
to authenticated
using (
bucket_id = 'note-attachments'
and (select auth.uid())::text = (storage.foldername(name))[1]
)
with check (
bucket_id = 'note-attachments'
and (select auth.uid())::text = (storage.foldername(name))[1]
);
create policy "Users can delete own note attachments"
on storage.objects
for delete
to authenticated
using (
bucket_id = 'note-attachments'
and (select auth.uid())::text = (storage.foldername(name))[1]
);
Apply locally and regenerate types.
npx supabase db reset
npx supabase gen types typescript --local > src/lib/database.types.ts
npm run typecheck
Supabase Clients
// src/lib/supabase/client.ts
import { createBrowserClient } from "@supabase/ssr";
import type { Database } from "@/lib/database.types";
export function createClient() {
return createBrowserClient<Database>(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!,
);
}
// src/lib/supabase/server.ts
import { createServerClient } from "@supabase/ssr";
import { cookies } from "next/headers";
import type { Database } from "@/lib/database.types";
export async function createClient() {
const cookieStore = await cookies();
return createServerClient<Database>(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!,
{
cookies: {
getAll() {
return cookieStore.getAll();
},
setAll(cookiesToSet) {
try {
cookiesToSet.forEach(({ name, value, options }) => {
cookieStore.set(name, value, options);
});
} catch {
// Server Components cannot set cookies directly.
}
},
},
},
);
}
Ask Claude Code to verify that browser code never imports a secret key and that server code uses the server client.
Auth and CRUD
// app/login/actions.ts
"use server";
import { redirect } from "next/navigation";
import { createClient } from "@/lib/supabase/server";
export async function signIn(formData: FormData) {
const email = String(formData.get("email") ?? "");
const password = String(formData.get("password") ?? "");
const supabase = await createClient();
const { error } = await supabase.auth.signInWithPassword({
email,
password,
});
if (error) {
return { error: error.message };
}
redirect("/dashboard");
}
export async function signOut() {
const supabase = await createClient();
await supabase.auth.signOut();
redirect("/login");
}
// src/features/notes/actions.ts
"use server";
import { revalidatePath } from "next/cache";
import { createClient } from "@/lib/supabase/server";
type CreateNoteInput = {
title: string;
body: string;
visibility?: "private" | "public";
attachmentPath?: string | null;
};
export async function listMyNotes() {
const supabase = await createClient();
const {
data: { user },
error: userError,
} = await supabase.auth.getUser();
if (userError || !user) {
throw new Error("Authentication required");
}
const { data, error } = await supabase
.from("project_notes")
.select("id,title,body,visibility,attachment_path,created_at,updated_at")
.order("created_at", { ascending: false });
if (error) throw error;
return data;
}
export async function createNote(input: CreateNoteInput) {
const supabase = await createClient();
const {
data: { user },
error: userError,
} = await supabase.auth.getUser();
if (userError || !user) {
throw new Error("Authentication required");
}
const { data, error } = await supabase
.from("project_notes")
.insert({
owner_id: user.id,
title: input.title,
body: input.body,
visibility: input.visibility ?? "private",
attachment_path: input.attachmentPath ?? null,
})
.select("id,title,visibility")
.single();
if (error) throw error;
revalidatePath("/dashboard");
return data;
}
owner_id is derived from the authenticated user. If Claude Code accepts it from a form, reject that diff.
Storage Upload
// src/features/notes/upload-note-attachment.ts
"use client";
import { createClient } from "@/lib/supabase/client";
export async function uploadNoteAttachment(file: File, userId: string) {
const supabase = createClient();
const ext = file.name.split(".").pop()?.toLowerCase() ?? "bin";
const path = `${userId}/${crypto.randomUUID()}.${ext}`;
const { error } = await supabase.storage
.from("note-attachments")
.upload(path, file, {
cacheControl: "3600",
upsert: false,
contentType: file.type,
});
if (error) throw error;
return path;
}
The bucket is private. Use signed URLs or server-mediated downloads when readers should access the file later.
Edge Function
npx supabase functions new notify-note-created
// supabase/functions/notify-note-created/index.ts
import { createClient } from "npm:@supabase/supabase-js@2";
Deno.serve(async (req) => {
if (req.method !== "POST") {
return new Response("Method not allowed", { status: 405 });
}
const authorization = req.headers.get("Authorization");
if (!authorization) {
return Response.json({ error: "Missing authorization" }, { status: 401 });
}
const supabase = createClient(
Deno.env.get("SUPABASE_URL")!,
Deno.env.get("SUPABASE_ANON_KEY")!,
{
global: {
headers: { Authorization: authorization },
},
},
);
const {
data: { user },
error: userError,
} = await supabase.auth.getUser();
if (userError || !user) {
return Response.json({ error: "Authentication required" }, { status: 401 });
}
const { noteId } = (await req.json()) as { noteId?: string };
if (!noteId) {
return Response.json({ error: "noteId is required" }, { status: 400 });
}
const { data: note, error } = await supabase
.from("project_notes")
.select("id,title,owner_id")
.eq("id", noteId)
.single();
if (error) {
return Response.json({ error: error.message }, { status: 404 });
}
return Response.json({
ok: true,
userId: user.id,
note,
});
});
npx supabase functions serve notify-note-created --env-file .env.local
npx supabase functions deploy notify-note-created
Test Commands
npx supabase db reset
npx supabase gen types typescript --local > src/lib/database.types.ts
npm run typecheck
npm test
npx supabase functions serve notify-note-created --env-file .env.local
Add project-specific SQL lint, E2E, or policy tests if your repository already has them.
Three Practical Use Cases
| Use case | Supabase features | Claude Code task | Human review focus |
|---|---|---|---|
| SaaS team notes | Auth, Postgres, RLS | Tables, server actions, list UI | Team boundary vs. owner boundary |
| Member-only resources | Auth, Storage, signed URLs | Upload UI, download route, audit log | Private bucket and file path policy |
| Event reservations | Postgres, Edge Functions | Reservation schema, notification function | Double booking, retries, cancellation |
Common Pitfalls
Enabling RLS without policies blocks reads and writes, while using using (true) carelessly exposes rows. Review public and authenticated behavior separately.
Putting a secret key into client code is the most serious mistake. Browser clients should use publishable keys, and server-only elevated keys should be isolated.
Letting the UI decide Storage paths creates policy drift. Generate paths like userId/random-file and make the policy match that shape.
Forgetting type generation makes Claude Code work against stale table definitions. Regenerate database.types.ts after migrations.
Edge Functions are still public HTTP endpoints. Validate Authorization and use an RLS-aware Supabase client unless you have a documented reason to bypass RLS.
Claude Code Review Prompt
Review only the Supabase integration.
Check these points:
- public tables have RLS enabled
- every policy has an explicit TO role
- auth.uid() is used only in RLS-safe expressions
- browser code uses only NEXT_PUBLIC_SUPABASE_URL and NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY
- no secret key or service role key appears in client code, logs, tests, or docs
- owner_id is derived from the authenticated user, not from form input
- Storage paths match storage.objects policies
- migration and generated database types are in sync
- Edge Functions validate Authorization and use RLS-aware clients
Return findings with file paths and line numbers.
Manual checks should include anonymous public reads, authenticated private reads, failed updates against another user’s note, failed uploads into another user’s folder, and unauthenticated Edge Function calls.
ClaudeCodeLab can review Supabase integrations, train teams on Claude Code implementation workflows, and help turn RLS, migrations, and CLAUDE.md rules into a repeatable operating model. For team adoption, use the Claude Code training and consultation page.
After trying this workflow, the biggest improvement came from asking Claude Code to review the RLS migration before any UI work. In Masa’s notes, UI-first attempts were more likely to pass owner_id from forms, while migration-first work produced smaller diffs and clearer release checks.
Free PDF: Claude Code Cheatsheet
Enter your email and download the one-page Claude Code cheatsheet for commands, review habits, and safe workflows.
We handle your data with care and never send spam.
Level up your Claude Code workflow
Start with the free PDF, use Gumroad guides when you need repeatable workflows, and book consultation when rollout or revenue paths need human judgment.
About the Author
Masa
Engineer focused on practical Claude Code workflows. Runs claudecode-lab.com, a 10-language technical media site.
Related Posts
Claude Code Obsidian to CLAUDE.md Workflow: Stop Re-explaining Context
Turn Obsidian working notes into concise CLAUDE.md operating notes that make Claude Code sessions easier to resume.
Claude Code Revenue CTA Routing: Send Articles to PDF, Gumroad, and Consultation
A Claude Code workflow for routing article readers to the free PDF, Gumroad products, or consultation by intent.
Claude Code Team Handoff Rules: Review Evidence, Permissions, Rollback, and Revenue Paths
A practical Claude Code handoff format for team review, proof, permission rules, rollback, free PDF, Gumroad, and consultation paths.
Related Products
50 Battle-Tested Claude Code Prompt Templates
Copy, paste, ship. 50 production-ready prompts.
Use proven prompts for code review, refactoring, testing, documentation, debugging, architecture, and incident response.
The Complete Claude Code Setup & Configuration Guide
From install to team-ready workflow.
A practical guide to installation, CLAUDE.md, hooks, MCP servers, permissions, IDE setup, and CI/CD workflows.