← Back to dashboard
supabasedatabasefreshReader view (for NotebookLM)

Supabase 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 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 (hosted HTTP + OAuth)

The current official server is the hosted HTTP endpoint at https://mcp.supabase.com/mcp. Your MCP client logs in to Supabase over OAuth on first connect — no personal access token needed for local interactive use. (The self-hostable @supabase/mcp-server-supabase npm package still exists for CI and custom endpoints; see the CI note below.)

Bash
# Add via Claude Code CLI (streamable HTTP transport)
claude mcp add --scope project --transport http supabase "https://mcp.supabase.com/mcp"

.mcp.json Configuration

JSON
{
  "mcpServers": {
    "supabase": {
      "type": "http",
      "url": "https://mcp.supabase.com/mcp?project_ref=<project-ref>&read_only=true&features=database,docs"
    }
  }
}

Configuration options are passed as URL query params on the endpoint:

ParamPurpose
project_ref=<ref>Project scoping — restrict the server to a single project (drops account-level tools)
read_only=trueRead-only mode — excludes every mutating tool (no execute_sql writes, no apply_migration, no deploy_edge_function)
features=database,docs,...Restrict to specific feature groups (account, database, debugging, development, functions, branching, storage, docs)

Security (codeAmani default): never point the MCP server at a production project. Scope it (project_ref), run it read-only unless you are actively applying changes, and prefer a development branch. The server executes SQL as an elevated role, so an injected instruction in your data could otherwise mutate real rows. See the MCP security best practices.

CI / headless / self-host: use a personal access token instead of OAuth. Pass it as Authorization: Bearer ${SUPABASE_ACCESS_TOKEN} to the hosted endpoint, or run the @supabase/mcp-server-supabase npm package locally with --access-token. When running Supabase locally via the CLI, a limited MCP server is served at http://localhost:54321/mcp. Generate a PAT at: https://supabase.com/dashboard/account/tokens

Available MCP Tools

Grouped by feature (the features param toggles whole groups). Read-only mode hides the mutating tools.

GroupTools
Accountlist_projects, get_project, create_project, pause_project, restore_project, list_organizations, get_organization, get_cost, confirm_cost
Databaselist_tables, list_extensions, list_migrations, apply_migration, execute_sql
Debuggingquery_logs, get_advisors
Developmentget_project_url, get_publishable_keys, generate_typescript_types
Functionslist_edge_functions, get_edge_function, deploy_edge_function
Branchingcreate_branch, list_branches, delete_branch, merge_branch, reset_branch, rebase_branch
Storagelist_storage_buckets, get_storage_config, update_storage_config
Docssearch_docs

Naming has changed since older guides: logs are now query_logs (not get_logs), get_advisors surfaces security/performance lints, search_docs queries the Supabase docs, and get_publishable_keys returns the new publishable API keys (see below). There is no longer a standalone describe_table_schema tool — list_tables returns column types and constraints.


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 (CLI v2 — these subcommands are behind --experimental)
supabase storage ls --experimental --linked
supabase storage cp ./file.pdf ss:///my-bucket/file.pdf --experimental --linked

# 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

New API keys (2025 → current). Supabase has replaced the JWT-based anon / service_role keys with publishable (sb_publishable_..., browser-safe) and secret (sb_secret_..., server-only) keys. The legacy JWT keys still work but are scheduled for deprecation by end of 2026 — new projects should adopt the new keys now. The two schemes run side by side, so you can migrate incrementally. Generate the new keys in Dashboard → Project Settings → API Keys.

Bash
# Client-side (safe to expose in the frontend)
NEXT_PUBLIC_SUPABASE_URL=https://your-ref.supabase.co
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=sb_publishable_...   # NEW — browser-safe, RLS-respecting
# NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJ...                    # legacy JWT key (still valid; sunset end of 2026)

# Server-side only (never expose in the frontend)
SUPABASE_SECRET_KEY=sb_secret_...                          # NEW — bypasses RLS; replaces service_role
# SUPABASE_SERVICE_ROLE_KEY=eyJ...                         # legacy JWT key (still valid; sunset end of 2026)
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 (CI / headless only — interactive MCP uses OAuth)
SUPABASE_ACCESS_TOKEN=sbp_...

The publishable key is the browser client's key and still respects RLS (it is auth.uid() = NULL until a user signs in). The secret key carries the elevated, RLS-bypassing role — treat it exactly like the old service_role key: server-only, never committed, never shipped to the browser.


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 `list_tables` to get the schema (column types and constraints) 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 (new name: publishable key, sb_publishable_...) is a public, RLS-respecting key safe for the browser — auth.uid() is NULL until a user signs in. The service_role key (new name: secret key, sb_secret_...) 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) · Creating a server client


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 query_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