Supabase + Claude Code Integration Guide
What is Row-Level Security?
Per-row authorization enforced at the planner, keyed off the JWT.
Supabase runs every API request as either `anon` or `authenticated`, with the user's JWT mapped into `request.jwt.claims`. RLS policies are PG-native: `USING (...)` filters reads/updates/deletes; `WITH CHECK (...)` filters inserts/updates. You enable RLS per table (`ALTER TABLE x ENABLE ROW LEVEL SECURITY`) and write policies that reference `auth.uid()`, `auth.role()`, or anything in `auth.jwt()`. The `service_role` key has `BYPASSRLS` and is intended only for server-to-server use — leaking it defeats the entire model. Storage objects are gated by the same policy engine against the `storage.objects` table.
The five pillars
Postgres holds the data; the other four bolt-on services share the same auth and the same RLS layer. Tap a card for the long version.
See RLS in motion
Same four rows, four roles, three policies. Switch the role and watch the table change without the query changing — that's the whole idea. The SQL block underneath is the exact policy doing the filtering.
ALTER TABLE posts ENABLE ROW LEVEL SECURITY;
CREATE POLICY "public_or_own"
ON posts FOR SELECT
USING (
is_public = true
OR author_id = auth.uid()
);Flip to service_role and the policy stops mattering — that key has BYPASSRLS, which is why it must stay server-side. Flip to anon with own rows only and the table goes empty: there is no auth.uid() to match against.
The #1 production foot-gun: leaving RLS disabled on a public-facing table while assuming the API key restricts access. The anon key is shipped to every browser — without RLS, anyone can SELECT * any table the schema exposes.
███████╗██╗ ██╗██████╗ █████╗ ██████╗ █████╗ ███████╗███████╗
██╔════╝██║ ██║██╔══██╗██╔══██╗██╔══██╗██╔══██╗██╔════╝██╔════╝
███████╗██║ ██║██████╔╝███████║██████╔╝███████║███████╗█████╗
╚════██║██║ ██║██╔═══╝ ██╔══██║██╔══██╗██╔══██║╚════██║██╔══╝
███████║╚██████╔╝██║ ██║ ██║██████╔╝██║ ██║███████║███████╗
╚══════╝ ╚═════╝ ╚═╝ ╚═╝ ╚═╝╚═════╝ ╚═╝ ╚═╝╚══════╝╚══════╝Supabase + Claude Code Integration Guide
Focus: Managing Postgres databases, Edge Functions, Auth, and Storage from Claude Code using the official Supabase MCP server and
supabaseCLI.
Overview
Supabase is an open-source Firebase alternative built on Postgres. It provides a hosted database, authentication, real-time subscriptions, edge functions, storage, and a REST/GraphQL API. The official @supabase/mcp-server-supabase lets Claude Code execute SQL, manage branches, deploy edge functions, read logs, apply migrations, and generate TypeScript types — all through natural language.
Here is the big picture — everything centers on one Postgres database, which makes Supabase easy to reason about:
Official Documentation
| Resource | URL |
|---|---|
| Supabase Docs | https://supabase.com/docs |
| Supabase MCP Server | https://supabase.com/docs/guides/getting-started/mcp |
| CLI Reference | https://supabase.com/docs/reference/cli |
| JavaScript Client | https://supabase.com/docs/reference/javascript |
| Edge Functions | https://supabase.com/docs/guides/functions |
| Database Migrations | https://supabase.com/docs/guides/deployment/database-migrations |
MCP Server Setup
Official Supabase MCP Server
The official server is @supabase/mcp-server-supabase. It authenticates using your Supabase personal access token (PAT).
# Add via Claude Code CLI
claude mcp add supabase -- npx -y @supabase/mcp-server-supabase \
--access-token ${SUPABASE_ACCESS_TOKEN}.mcp.json Configuration
{
"mcpServers": {
"supabase": {
"command": "npx",
"args": [
"-y",
"@supabase/mcp-server-supabase",
"--access-token",
"${SUPABASE_ACCESS_TOKEN}"
]
}
}
}Get your personal access token at: https://supabase.com/dashboard/account/tokens
Available MCP Tools
| Tool | Description |
|---|---|
list_projects | List all Supabase projects |
get_project | Get project details, region, status |
execute_sql | Run any SQL query on your database |
list_tables | List all tables with schemas |
describe_table_schema | Get column types and constraints |
apply_migration | Apply a SQL migration |
list_migrations | List applied migrations |
create_branch | Create a database branch for dev/testing |
list_branches | List all branches |
merge_branch | Merge a branch back to production |
deploy_edge_function | Deploy a Deno Edge Function |
list_edge_functions | List deployed functions |
get_logs | Fetch logs for a service |
get_project_url | Get your project's API URL |
get_publishable_keys | Get anon and service_role keys |
generate_typescript_types | Generate types from your schema |
CLI Integration
Installation
# npm (global)
npm install -g supabase
# macOS (brew)
brew install supabase/tap/supabase
# Windows (Scoop)
scoop bucket add supabase https://github.com/supabase/scoop-bucket.git
scoop install supabaseAuthentication
supabase login
# Or set token:
export SUPABASE_ACCESS_TOKEN=sbp_...Key Commands
# Link to an existing project
supabase link --project-ref your-project-ref
# Start local Supabase stack (Docker required)
supabase start
# Stop local stack
supabase stop
# Check local status
supabase status
# Database migrations
supabase migration new add_users_table
supabase db push # push migrations to linked project
supabase db pull # pull remote schema to local
supabase db reset # reset local DB and re-apply migrations
supabase db diff # show diff between local and remote
# Generate TypeScript types from schema
supabase gen types typescript --linked > src/types/database.ts
# Edge Functions
supabase functions new my-function
supabase functions serve my-function # local dev
supabase functions deploy my-function --no-verify-jwt
# Storage
supabase storage list
supabase storage cp ./file.pdf ss:///my-bucket/file.pdf
# Logs
supabase logs --project-ref your-refClient SDK Integration
JavaScript / TypeScript
npm install @supabase/supabase-jsimport { createClient } from "@supabase/supabase-js";
import type { Database } from "./types/database"; // generated types
const supabase = createClient<Database>(
process.env.SUPABASE_URL!,
process.env.SUPABASE_ANON_KEY!
);
// Query with full type safety
const { data, error } = await supabase
.from("users")
.select("id, email, created_at")
.eq("active", true)
.order("created_at", { ascending: false })
.limit(10);
// Insert
const { error: insertError } = await supabase
.from("posts")
.insert({ title: "Hello", content: "World", user_id: userId });
// Real-time subscription
const channel = supabase
.channel("db-changes")
.on("postgres_changes", { event: "INSERT", schema: "public", table: "messages" }, (payload) => {
console.log("New message:", payload.new);
})
.subscribe();Environment Variables
# Client-side (safe to expose in frontend)
NEXT_PUBLIC_SUPABASE_URL=https://your-ref.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJ...
# Server-side only (never expose in frontend)
SUPABASE_SERVICE_ROLE_KEY=eyJ...
SUPABASE_DB_PASSWORD=...
# Direct Postgres connection string (for migrations/scripts)
DATABASE_URL=postgresql://postgres:[password]@db.your-ref.supabase.co:5432/postgres
DIRECT_URL=postgresql://postgres:[password]@db.your-ref.supabase.co:5432/postgres
# CLI/MCP authentication
SUPABASE_ACCESS_TOKEN=sbp_...Automation Workflows
Claude Code Hook: Auto-generate Types After Migration
.claude/settings.json:
{
"hooks": {
"PostToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "if echo \"$CLAUDE_TOOL_INPUT\" | grep -q 'supabase db push\\|migration'; then supabase gen types typescript --linked > src/types/database.ts && echo 'Types regenerated'; fi"
}
]
}
]
}
}Slash Command: Database Inspection
.claude/commands/db-inspect.md:
Inspect the Supabase database for table $ARGUMENTS.
1. Use the Supabase MCP tool `describe_table_schema` to get the schema for table $ARGUMENTS
2. Use `execute_sql` to run: SELECT COUNT(*) FROM $ARGUMENTS
3. Use `execute_sql` to get a sample of 5 rows: SELECT * FROM $ARGUMENTS LIMIT 5
4. Report: column names/types, row count, sample data, and any missing indexes or constraintsUsage: /project:db-inspect users
Migration-Safe Database Changes
The example below enables RLS so users only see their own posts. Here is how that check plays out on every query — once it is in place, your authorization holds even if app code slips:
-- supabase/migrations/20250512000000_add_posts.sql
CREATE TABLE IF NOT EXISTS posts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
title TEXT NOT NULL CHECK (char_length(title) <= 200),
content TEXT,
published_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Enable Row Level Security
ALTER TABLE posts ENABLE ROW LEVEL SECURITY;
-- Policy: users can only see their own posts
CREATE POLICY "users_own_posts" ON posts
FOR ALL USING (auth.uid() = user_id);
-- Index for performance
CREATE INDEX IF NOT EXISTS posts_user_id_idx ON posts (user_id);
CREATE INDEX IF NOT EXISTS posts_published_at_idx ON posts (published_at DESC);Apply: supabase db push or via MCP apply_migration.
Auth & sessions: how auth.uid() is populated
RLS policies like auth.uid() = user_id only work if a user JWT reaches Postgres. Here is the chain: a user signs in, Supabase Auth issues a JWT, and supabase-js sends it as the Authorization: Bearer header (or a session cookie in SSR). PostgREST decodes that JWT into the request's auth.uid() and auth.jwt(), which your policies then evaluate. The key you ship matters: the anon key is a public, RLS-respecting key safe for the browser — auth.uid() is NULL until a user signs in. The service_role key carries an elevated claim that bypasses RLS entirely, so it is server-only and treats every row as accessible.
On the server (Next.js App Router), use @supabase/ssr's createServerClient with the anon key plus cookie accessors so the user's session flows from cookies into queries — keeping RLS in force. Always verify identity with supabase.auth.getUser(), which contacts the Auth server to revalidate the JWT, never getSession(), which only reads cookies and returns an unverified user that a malicious client could spoof.
// lib/supabase-server.ts
import { createServerClient } from "@supabase/ssr";
import { cookies } from "next/headers";
export async function createSupabaseServerClient() {
const cookieStore = await cookies();
return createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, // anon key, NOT service_role
{
cookies: {
getAll: () => cookieStore.getAll(),
setAll: (cookiesToSet) => {
try {
cookiesToSet.forEach(({ name, value, options }) =>
cookieStore.set(name, value, options),
);
} catch {
// Called from a Server Component — cookie writes are handled by middleware.
}
},
},
},
);
}
// In a Server Component / Route Handler:
const supabase = await createSupabaseServerClient();
const {
data: { user },
error,
} = await supabase.auth.getUser(); // verifies the JWT with the Auth server
if (!user) {
// not authenticated — redirect or return 401
}
// Queries run as this user; auth.uid() now drives RLS automatically.
const { data: posts } = await supabase.from("posts").select("*");Security gotcha: Never expose
SUPABASE_SERVICE_ROLE_KEYto the client or use it in code that runs in the browser — it bypasses every RLS policy. Reserve it for trusted server-only admin scripts (cron jobs, webhooks). For user-facing server code, use the anon key +getUser()so RLS stays in effect.
Canonical docs: Server-Side Auth (Next.js) · createServerClient reference
Common Use Cases
| Use Case | Approach |
|---|---|
| Schema changes | supabase migration new + db push |
| Type generation | supabase gen types typescript --linked |
| Edge Function deploy | supabase functions deploy or MCP |
| Debug slow queries | MCP execute_sql with EXPLAIN ANALYZE |
| Branch for feature dev | MCP create_branch + merge_branch |
| Read production logs | MCP get_logs |
Troubleshooting
| Issue | Fix |
|---|---|
supabase start fails | Ensure Docker Desktop is running |
| Migration conflict | Run supabase db pull to sync first |
| RLS blocking queries | Use service_role key for admin scripts |
| Types out of sync | Re-run supabase gen types typescript --linked |
| Connection refused | Check supabase status — local stack may be stopped |
| PAT expired | Regenerate at supabase.com/dashboard/account/tokens |