← Back to dashboard
supabasedatabasefresh

Supabase + Claude Code Integration Guide

What is Row-Level Security?

The real model

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.

RLS Policy Playground3 of 4 rows visible
Connecting as
Policy preset
SELECT * FROM postsresult for this role
authortitlepublic
aliceWelcome to my notestrue
aliceDraft: AI ideasfalse
bobM-Pesa flow writeuptrue
bobPersonal: bank detailsfalse
Active policy
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.

Text
███████╗██╗   ██╗██████╗  █████╗ ██████╗  █████╗ ███████╗███████╗
██╔════╝██║   ██║██╔══██╗██╔══██╗██╔══██╗██╔══██╗██╔════╝██╔════╝
███████╗██║   ██║██████╔╝███████║██████╔╝███████║███████╗█████╗
╚════██║██║   ██║██╔═══╝ ██╔══██║██╔══██╗██╔══██║╚════██║██╔══╝
███████║╚██████╔╝██║     ██║  ██║██████╔╝██║  ██║███████║███████╗
╚══════╝ ╚═════╝ ╚═╝     ╚═╝  ╚═╝╚═════╝ ╚═╝  ╚═╝╚══════╝╚══════╝

Supabase + Claude Code Integration Guide

Focus: Managing Postgres databases, Edge Functions, Auth, and Storage from Claude Code using the official Supabase MCP server and supabase CLI.

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


MCP Server Setup

Official Supabase MCP Server

The official server is @supabase/mcp-server-supabase. It authenticates using your Supabase personal access token (PAT).

Bash
# Add via Claude Code CLI
claude mcp add supabase -- npx -y @supabase/mcp-server-supabase \
  --access-token ${SUPABASE_ACCESS_TOKEN}

.mcp.json Configuration

JSON
{
  "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

ToolDescription
list_projectsList all Supabase projects
get_projectGet project details, region, status
execute_sqlRun any SQL query on your database
list_tablesList all tables with schemas
describe_table_schemaGet column types and constraints
apply_migrationApply a SQL migration
list_migrationsList applied migrations
create_branchCreate a database branch for dev/testing
list_branchesList all branches
merge_branchMerge a branch back to production
deploy_edge_functionDeploy a Deno Edge Function
list_edge_functionsList deployed functions
get_logsFetch logs for a service
get_project_urlGet your project's API URL
get_publishable_keysGet anon and service_role keys
generate_typescript_typesGenerate types from your schema

CLI Integration

Installation

Bash
# 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 supabase

Authentication

Bash
supabase login
# Or set token:
export SUPABASE_ACCESS_TOKEN=sbp_...

Key Commands

Bash
# 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-ref

Client SDK Integration

JavaScript / TypeScript

Bash
npm install @supabase/supabase-js
TypeScript
import { 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

Bash
# 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:

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:

Markdown
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 constraints

Usage: /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:

SQL
-- 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.

TypeScript
// 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_KEY to 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 CaseApproach
Schema changessupabase migration new + db push
Type generationsupabase gen types typescript --linked
Edge Function deploysupabase functions deploy or MCP
Debug slow queriesMCP execute_sql with EXPLAIN ANALYZE
Branch for feature devMCP create_branch + merge_branch
Read production logsMCP get_logs

Troubleshooting

IssueFix
supabase start failsEnsure Docker Desktop is running
Migration conflictRun supabase db pull to sync first
RLS blocking queriesUse service_role key for admin scripts
Types out of syncRe-run supabase gen types typescript --linked
Connection refusedCheck supabase status — local stack may be stopped
PAT expiredRegenerate at supabase.com/dashboard/account/tokens