Skip to content

Project Setup and the Supabase CLI

A Supabase project starts as a hosted Postgres instance with two API keys of very different trust levels, and the Supabase CLI is what lets you link a local folder to it and run the entire stack on your own machine with Docker.

You create a new hosted project from the Supabase dashboard, or the shortcut database.new. Behind the scenes this provisions a dedicated Postgres instance, an API URL, and two API keys:

  • anon key — public, safe to ship in client-side code (a browser bundle, a mobile app). Requests using this key are subject to Row Level Security.
  • service_role key — secret, full-access, bypasses Row Level Security entirely. It is meant only for trusted server-side code.
// Server-side only — e.g. an API route, a cron job, an Edge Function.
// process.env.SUPABASE_SERVICE_ROLE_KEY must never be bundled into client code.
import { createClient } from '@supabase/supabase-js';
const supabaseAdmin = createClient(
process.env.SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!
);

The CLI is how you manage a project as code instead of only through the dashboard:

Terminal window
# Scaffold a local supabase/ config directory in your project
npx supabase init
# Authenticate the CLI with your Supabase account
supabase login
# Connect this local project folder to a specific hosted project
supabase link --project-ref <project-ref>

supabase link is what tells your local supabase/ folder which hosted project it corresponds to — every later db push or db diff targets that linked project.

Terminal window
supabase start

This spins up the entire Supabase stack locally via Docker — Postgres, Studio, Auth, Storage, Realtime, and the rest, all running as containers on your machine. That means you can develop and test your schema, your RLS policies, and your client code completely offline, with no risk to your hosted project, before ever running supabase db push against it.

Run it (or, if it is already running, supabase status) and the CLI prints every URL and key you need — this is real, trimmed output from this course’s own local stack:

$ supabase status --workdir tools/probe -o env
DB_URL="postgresql://postgres:[email protected]:55322/postgres"
API_URL="http://127.0.0.1:55321"
REST_URL="http://127.0.0.1:55321/rest/v1"
GRAPHQL_URL="http://127.0.0.1:55321/graphql/v1"
MCP_URL="http://127.0.0.1:55321/mcp"
STUDIO_URL="http://127.0.0.1:55323"
ANON_KEY="eyJhbGciOiJIUzI1NiIs...I0"
SERVICE_ROLE_KEY="eyJhbGciOiJIUzI1NiIs...IU"

Every one of these is a real, reachable local endpoint the moment supabase start finishes — DB_URL is a direct Postgres connection, API_URL is Kong’s front door from the previous lesson, and MCP_URL is the subject of the rest of this section.

The Supabase MCP server: supabase start exposes /mcp locally

Section titled “The Supabase MCP server: supabase start exposes /mcp locally”

MCP (Model Context Protocol) is the standard AI coding tools (Claude Code, Cursor, and others) use to call tools against an external system instead of only reading and writing files. Supabase ships an official MCP server so an AI agent can inspect your schema, run SQL, check advisors, and generate types directly — without you manually pasting schema dumps into a chat window. Since a recent CLI release, supabase start runs a local MCP server automatically, reachable at the MCP_URL printed above, with no extra setup and no separate npx @supabase/mcp-server-supabase process to manage.

Proof it is a real MCP server, not just an idle URL — a raw JSON-RPC initialize handshake, POSTed directly with curl:

$ curl -s -X POST http://127.0.0.1:55321/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":"fnd-probe","version":"0.1.0"}}}'
{"result":{"protocolVersion":"2025-06-18","capabilities":{"tools":{}},"serverInfo":{"name":"supabase","title":"Supabase","version":"0.11.0"},"instructions":"Here are guidelines for using Supabase tools effectively:\n\n- Before making schema changes, use `list_tables`...\n..."},"jsonrpc":"2.0","id":1}

A real MCP session, a real server name and version, and even server-authored guidance for the calling agent in instructions. Following up with a tools/list call shows exactly what this local server can do:

$ curl -s -X POST http://127.0.0.1:55321/mcp \
-H "Content-Type: application/json" -H "Accept: application/json, text/event-stream" \
-d '{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}'
search_docs, list_tables, list_extensions, list_migrations, apply_migration,
execute_sql, query_logs, get_advisors, get_project_url, get_publishable_keys,
generate_typescript_types

How an AI agent uses it — and the real danger

Section titled “How an AI agent uses it — and the real danger”

An AI coding tool connects the same way any MCP client does — for a local stack, pointing straight at the URL above needs no access token at all, since there is no hosted project or PAT in the loop:

{
"mcpServers": {
"supabase-local": {
"url": "http://127.0.0.1:55321/mcp"
}
}
}

Once connected, an agent can call execute_sql to run arbitrary SQL. This is not a hypothetical — proved directly against this local stack:

$ curl -s -X POST http://127.0.0.1:55321/mcp \
-H "Content-Type: application/json" -H "Accept: application/json, text/event-stream" \
-d '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"execute_sql","arguments":{"query":"select current_user, session_user;"}}}'
{"current_user":"postgres","session_user":"postgres"}

current_user is postgres — the database superuser, not merely service_role. The local MCP server connects to Postgres directly, with full administrative rights: it can read or write any table regardless of RLS, run arbitrary DDL, and inspect any schema, including auth and storage. The official MCP docs put this plainly: “Connecting an LLM to your Supabase projects carries security risks”, naming prompt injection as “the primary attack vector unique to LLMs” — untrusted data returned from a query (a user’s bio field, a support ticket body) could contain text an insufficiently careful agent interprets as a new instruction. The same docs recommend keeping the read_only=true parameter on and manual tool-call approval enabled for exactly this reason, and the self-hosting MCP guide is explicit that the endpoint should never be reachable from the open internet — access only over a VPN or SSH tunnel. Locally this is lower-stakes (it is your own disposable dev database), but the moment the same pattern is pointed at a real project, “give the AI agent the MCP URL” is equivalent to “give the AI agent an unrestricted psql session as the database owner.”

An AI assistant, asked to “wire up the Supabase MCP server for this project,” produced this config:

🐛 Spot the bug

src/mcp-config-check.ts
// An AI assistant's suggested MCP client config, pasted into a shared repo README
const mcpConfig = {
mcpServers: {
supabase: {
command: 'npx',
args: [
'-y',
'@supabase/mcp-server-supabase@latest',
'--access-token',
'sbp_<your-personal-access-token>',
],
},
},
};
export {};
Show the answer

The bug is not in the JSON shape — it is that a real, live personal access token was committed straight into a config example in a shared repository, exactly the way an AI assistant will happily paste one back if it was given one earlier in the conversation and asked to “write out the config.” A PAT scopes to your whole Supabase account by default (every project, unless --project-ref restricts it, per the tool-permission table in Supabase’s own docs), so a leaked one is not a “this one project” problem — it is an “attacker can list, modify, or delete every project on this account” problem. The fix has two parts: never let a literal token land in a file that reaches version control (reference an environment variable instead — "--access-token", process.env.SUPABASE_PAT in an MCP client that supports env expansion, or the client’s own secrets mechanism), and treat any token an AI assistant has already seen in a conversation as compromised — rotate it from the dashboard, the same way you would after any other accidental secret exposure.

The MCP server is not a new way for Supabase to run SQL — it is a translation layer that lets an AI agent call the exact same operations you could run by hand (psql, supabase db push, the Studio SQL editor), through a structured tool-call protocol instead of you copy-pasting. Every capability it exposes maps to something this course already teaches directly: execute_sql is psql -c, apply_migration is supabase migration new plus db push, get_advisors is Studio’s Advisors panel. Treating it as “just another way to run the commands from this course, except an LLM is holding the keyboard” is the right mental model — and the right lens for judging how much trust to extend it.

  • The MCP server lets an AI agent inspect and modify your schema without you hand-writing every SQL statement yourself — at the cost of handing it the same blast radius a human with full database access would have, proved above: execute_sql runs as the Postgres superuser locally, not a scoped role.
  • A hosted personal access token can be scoped to one project (--project-ref) and made read-only (--read-only) — at the cost of remembering to actually set those flags; the default, unscoped, read-write invocation is the more dangerous one and also the one every copy-pasted example defaults to.
  • Zero setup locally (no PAT, no npx wrapper) makes it trivially easy to try — at the cost of zero access control locally either: there is no scoping flag to reach for at all against MCP_URL, only “point an agent at your local database or don’t.”
  • “The MCP server only lets an AI agent read data, not change anything.” Disproved by the tool list itself: apply_migration and execute_sql both write. get_advisors, list_tables, and search_docs are the read-only ones — the danger above is specifically about the tools that are not.
  • “service_role is the most privileged thing an AI agent could get access to.” The proof above shows the local MCP server runs as postgres, the actual database superuser — a strictly higher privilege level than service_role, which is still just a Postgres role subject to normal grants (service_role simply is not subject to RLS).
  • “MCP access is safe as long as the AI model itself is trustworthy.” The documented risk is prompt injection from data the query returns, not from the model’s own intentions — a well-behaved model can still be manipulated by text embedded in a row it was asked to read.
flowchart LR
  subgraph local["Local machine (supabase start)"]
    lpg[("Postgres")]
    lstudio["Studio"]
    lauth["Auth"]
    lstorage["Storage"]
  end
  subgraph hosted["Hosted project"]
    hpg[("Postgres")]
    hapi["API URL + anon/service_role keys"]
  end
  local -- "supabase link --project-ref" --> hosted
  local -- "supabase db push" --> hosted
A local Docker-based stack linked to a hosted project
What is the key difference between the anon key and the service_role key?
Why must the service_role key never reach client-side code?
What does supabase start do?
What does supabase link --project-ref <ref> do?
A real curl against this local stack's MCP server ran execute_sql with select current_user; and got back postgres. What does that prove about the danger of exposing MCP_URL?