All guidesSupabase RLS and keys
On this page

Supabase question

SupabaseClaude Code

How do I give my Supabase app its own MCP server, so ChatGPT and Claude act as the signed-in user under RLS?

By Ohad Krispin, founder of VibeRaven · LinkedIn

Install the MCP Server block from the Supabase Library, which Supabase announced on October 22, 2026: it copies an Edge Function into supabase/functions/mcp with an MCP handler, authentication middleware and a whoami tool, Supabase Auth runs the OAuth 2.1 sign-in, and withSupabase({ auth: 'user' }) gives each tool a Supabase client for the signed-in user, so Postgres applies your RLS policies to every call with no service role key. To connect external agents you turn on asymmetric JWT signing keys, the OAuth 2.1 server and dynamic client registration, host a consent page on your frontend at the Auth Site URL, and set verify_jwt = false for the function; because every tool reads through RLS, a table without row level security, or with a using (true) policy for authenticated, is open to every user's agent. Checked against Supabase's announcement and the Deploy MCP servers guide on 2026-10-28.

Paste this to your agent

Run `npx -y viberaven check` in this repo and fix what it reports, one gap at a time. Also check that every table my MCP tools read has row level security on and no using (true) read policy. Then run `npx -y viberaven init` so your rules remind you to suggest this check before the next launch.

The check runs locally, with no sign-in and no API key. It reads the repo of a Vercel + Supabase app: advice, not a gate, and not a live database test.

Repository check

Check this in your repo

Find the tables your MCP tools could read in full

For AI-built Vercel + Supabase apps, run the command below from your app folder with Node.js 20 or newer. It reads the SQL in supabase/migrations and reports a public table that no migration enables row level security on as rls_disabled, a table that a migration grants to anon or authenticated while row level security is off as data_api_grant_without_rls, and a policy whose condition is a bare true for reading as rls_policy_allows_all_read.

It is a repository check, not a live database test: it cannot see changes made in the dashboard or policies that never reached a migration, and it does not read the MCP function or the Auth settings. It is advice, not a gate, and it exits with code 1 when it finds a blocker.

npx -y viberaven@1.6.12 check

What it printed on a fixture repo

On 2026-10-28 we ran it on a small fixture repo made for this page, not a customer repo: supabase/functions/mcp with the block's middleware and a list_open_tasks tool on todos, and one migration where public.todos is granted to authenticated with row level security off and public.notes has a read policy for authenticated with using (true). The output is below, with the folder shortened to ~/app. Two findings are blockers, so the exit code was 1.

$ npx -y viberaven@1.6.12 check

viberaven check · ~/app

🔴 No RLS policy proof in migrations  (rls_disabled)
   1 public table without row level security: public.todos (supabase/migrations/20261020000000_init.sql:1). The Data API does not enforce row restrictions for roles and operations allowed by the table's grants. Verify those grants to determine who can read or change rows.
🔴 Table granted to the Data API with RLS off  (data_api_grant_without_rls)
   public.todos is granted to authenticated (supabase/migrations/20261020000000_init.sql:7) but row level security is off. A grant without RLS is the dangerous combination: the Data API serves every row to that role. Enable RLS and add policies before granting. Fix: alter table public.todos enable row level security;
🟡 A policy lets anyone read every row  (rls_policy_allows_all_read)
   Policy "signed in users read notes" on public.notes (supabase/migrations/20261020000000_init.sql:15) lets any signed-in user (and sign-up is usually open) read every row: using (true). Fine for truly public content; wrong for anything per-user.
⚪ No error monitoring detected  (missing_monitoring)
   No Sentry/PostHog (or similar) instrumentation was found. Production errors will only surface when users complain.

Verdict: ❌ 2 blockers, 1 warning · score 50
Fix: viberaven fix · Details: ~/app/.viberaven/agent-tasklist.md

Set it up, in order

Install the block, then turn on the Auth settings it needs. Supabase's guide lists them: a project that signs JWTs with an asymmetric key (ES256 or RS256), the OAuth 2.1 server, dynamic client registration so MCP clients can register themselves, and a consent page on your frontend; the OAuth Consent block installs a /oauth/consent route for Next.js, React, React Router and TanStack Start. The function verifies tokens itself, so turn off the gateway's JWT check, or MCP clients cannot reach the discovery endpoint before they have a token. The guide needs Supabase CLI 2.117.0 or later and @supabase/server 1.6.0 or later.

npx shadcn@latest add https://supabase.com/library/r/mcp.json

# supabase/config.toml
[auth]
site_url = "http://localhost:3000"

[auth.oauth_server]
enabled = true
authorization_url_path = "/oauth/consent"
allow_dynamic_registration = true

[functions.mcp]
verify_jwt = false

# deploy
supabase link --project-ref <your-project-ref>
supabase config push
supabase functions deploy mcp

Test the OAuth handshake before connecting a client

With supabase start and supabase functions serve mcp running, a request with no token should come back 401 with a WWW-Authenticate header that names the metadata document, and the metadata should point at your Auth server. If the request is refused before your function answers, check verify_jwt = false: the guide says the gateway otherwise rejects the unauthenticated discovery request before withOAuthProtectedResource can answer it.

curl -si -X POST 'http://127.0.0.1:54321/functions/v1/mcp' \
  -H 'Content-Type: application/json' \
  -H 'Accept: application/json, text/event-stream' \
  -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"curl","version":"0"}}}' \
  | grep -i '^HTTP\|www-authenticate'

HTTP/1.1 401 Unauthorized
www-authenticate: Bearer resource_metadata="http://127.0.0.1:54321/functions/v1/mcp/oauth-protected-resource"

Tokens rejected on a project with legacy JWT secrets

withSupabase verifies user tokens against the project JWKS and rejects legacy HS256 tokens. If your project still signs with the legacy JWT secret, every tool call fails after sign-in until you switch to an asymmetric key under JWT Signing Keys.

MCP clients stop at discovery on a custom domain

Supabase Auth keeps its issuer on the project domain, https://<your-project-ref>.supabase.co/auth/v1, while withOAuthProtectedResource() builds the Auth URL from the domain the request arrived on. On a custom domain the two do not match, and clients that validate the Auth metadata stop. Either point clients at the project URL, or set the Auth server explicitly and store the issuer as a secret; then authorization_servers in the resource metadata and issuer in Auth's discovery document must match.

withOAuthProtectedResource({ authorizationServer: Deno.env.get('MCP_AUTH_ISSUER') })

supabase secrets set MCP_AUTH_ISSUER=https://<your-project-ref>.supabase.co/auth/v1
supabase functions deploy mcp

curl https://api.example.com/functions/v1/mcp/oauth-protected-resource
curl https://<your-project-ref>.supabase.co/auth/v1/.well-known/oauth-authorization-server

What RLS has to cover now

Every tool query runs as the user who approved the client, so your policies are the only thing between one user's agent and another user's rows. A table with row level security off and a grant to authenticated serves every row to any signed-in user, and so does a read policy with using (true); with open sign-ups, anyone can make an account. Dynamic client registration lets any compatible client register, so Supabase advises reviewing registered clients and letting users revoke grants. Keep business rules in the function the tool calls, the same one your app uses. For the database side, see which tables anyone can read.

Not the same as the Supabase MCP server

This server is the one your app exposes to its users. The Supabase MCP server at mcp.supabase.com is different: it connects your own coding agent to your Supabase account so you can build the app. For that one, see the provider MCP servers side by side.

Checklist

Before you share the server URL

  1. 01Switch the project to an asymmetric JWT signing key if it still uses the legacy secret.
  2. 02Turn on the OAuth 2.1 server and dynamic client registration, and deploy the consent page to the Auth Site URL.
  3. 03Set verify_jwt = false for the mcp function and check the 401 and WWW-Authenticate response with curl.
  4. 04Make sure every table a tool reads has row level security on and a policy tied to auth.uid(), with no using (true) on per-user data.
  5. 05Write tools that call the same functions your app uses for business rules, and mark read-only tools with readOnlyHint.
  6. 06On a custom domain, set MCP_AUTH_ISSUER and compare the two metadata documents.

Related

Sources

Official docs behind this checklist

FAQ

FAQ for builders before launch

Do the MCP tools need the service role key?

No. Each tool gets a Supabase client for the signed-in user, so a plain query works and RLS applies. Using the service role key in a tool would skip your policies for every user's agent.

Which clients can connect?

Any client that implements the MCP authorization spec. The guide lists Claude Code (claude mcp add --transport http), Claude custom connectors, Cursor, VS Code and ChatGPT, which needs a public HTTPS URL and developer mode.

Does it have to run in an Edge Function?

No. The same pipeline runs in a Next.js route handler, a SvelteKit endpoint, Cloudflare Workers or a plain Node, Bun or Deno server; outside Edge Functions you pass the public URLs explicitly.