Skip to content
Agentic Control Plane

The Supabase MCP server control model, explained

The Supabase MCP server is the top-ranked database server in this series: first-party (supabase-community/supabase-mcp), pushed by Supabase’s own docs into Cursor, Claude Code, and every IDE with an MCP config, available remote (https://mcp.supabase.com/mcp) and local (npx @supabase/mcp-server-supabase). It ships three levers — read-only, project scoping, feature groups — that are among the best native controls we’ve surveyed, and one of them enforces at a layer no other server in this series reaches. The docs are also unusually direct about the risks, including a dedicated prompt-injection section. Sources: the official docs, the repo README, and the shipped server source.

This page covers the server’s own controls. For the cross-server picture, see the MCP server controls comparison; for what a control layer adds on top, the coverage matrix.

What’s at stake on this surface

This server hands an agent a database, not a repository. The tool surface includes execute_sql (arbitrary SQL against your project), apply_migration (DDL), deploy_edge_function (code deploy to your edge runtime), create_project and pause/restore at the account level, plus branching and storage mutations. Unscoped and read-write, that’s every table in every project in the account, writable by whatever the model decides SQL should say next. Supabase’s own guidance opens with the two sentences that frame everything below: the server is “only designed for development and testing purposes,” and it runs “under the context of your developer permissions” — which is why the docs also say don’t hand it to your customers.

Auth: OAuth remote, PAT local

The remote server authenticates via OAuth with dynamic client registration — the client prompts a browser login, and the grant currently covers all available scopes. CI and the local server use a personal access token (--access-token or SUPABASE_ACCESS_TOKEN; Authorization: Bearer header against the remote). Unlike GitHub’s fine-grained PATs, a Supabase PAT is account-wide — there is no token-level way to scope it to one project or to read-only. That’s worth registering early: the credential floor here is thin, and the real narrowing happens in the two flags below, which live in the client config rather than at the credential.

Read-only: enforced at the database, not the tool list

--read-only on the local server, ?read_only=true on the remote. It does two things, and the second is what makes it the strongest single control in this series:

  1. Mutating tools are excluded from the surfaceapply_migration, deploy_edge_function, the storage and branching writes don’t appear in tools/list.
  2. SQL executes as a read-only Postgres user. The docs say it plainly: read-only mode “executes all queries as a read-only Postgres user,” and the source forwards the flag to Supabase’s management API on every query. This is enforcement at the database layer — a level below the tool schema.

Compare GitHub’s read-only, which is a strict filter on the tool surface and nothing more. Here, even if the tool layer is confused — a prompt-injected execute_sql call carrying an UPDATE, a jailbroken argument, a bug in the filter — the Postgres role refuses the write. Defense that holds when the layer above it fails is the property every control on these pages is graded against, and this is the one native control in the survey that has it. Supabase recommends read-only whenever the server touches real data; it should be your default.

Project scoping: the blast-radius flag

--project-ref / ?project_ref=<id> pins the server to one project and disables the account-level tools entirely (their project_id inputs disappear from the schemas). Without it, the server operates across every project the account can see — the docs note scoping “prevents LLMs from accessing data from other projects in your Supabase account.” Since the token can’t be narrowed, this flag is the only thing standing between one agent session and your whole account. One project of blast radius versus all of them is a single line of client config.

Feature groups: subtraction at launch

--features / ?features=database,docs selects from eight groups — docs, account, database, debugging, development, functions, branching, storage. Everything but storage is on by default; branching is experimental and paid-plan only; account is dropped automatically when project-scoped. Unselected groups don’t exist on the surface, and the docs recommend restricting them to reduce attack surface. Same shape as GitHub’s toolsets, same character: subtraction, decided once, at launch. You can remove deploy_edge_function from the surface; you cannot condition it.

Branching: a safety mechanism that isn’t a control

The branching group lets an agent test schema changes against a database branch before anything merges — genuinely good practice, and Supabase recommends it for exactly that. But note the category: it changes where mutations land, not whether the agent may make them, and the merge tools are on the same surface the agent holds. A sandbox the agent can promote out of is a workflow, not a boundary.

The missing pieces: audit, approvals, rate limits

The server writes no record of what an agent ran. There is no MCP-level trail of which tools fired or — the one that matters most on a database — what SQL actually executed, across a session or a fleet (the general MCP audit gap, in its sharpest form). There is no approval mechanism: no call can be held for a human by the server, so “manual approval,” which Supabase’s docs lean on, means whatever confirmation dialog the client chooses to show — a client-side courtesy, not a server-side control, and the first thing to vanish in yolo mode. There are no rate limits: the hundredth execute_sql in a minute is as unremarkable as the first. And there is no per-statement policy — read-only is session-global, so “SELECT anywhere, UPDATE only with approval, never DROP” is not expressible in the native model at any setting of the three levers.

The lethal trifecta incident: what the levers would and wouldn’t have stopped

The Cursor + Supabase exfiltration (our recreation) is the reference incident for this surface: untrusted text in a support ticket, an agent with SQL access, and query results flowing back to the attacker’s channel. The incident configuration had none of the three levers set — unscoped, read-write, service-role-level access.

Be precise about what the levers buy against it. Read-only plus project scoping would have blocked the write path outright — the Postgres role refuses the mutation regardless of what the injected instructions convinced the model to attempt. That’s real, and it’s more than most servers can say. But the read-and-exfiltrate path survives read-only entirely: SELECT the sensitive table, write the result where the attacker reads it. Read-only means the agent can’t corrupt your data; it does not mean the agent can’t leak it. Supabase knows this class of attack exists — the docs name prompt injection as the primary attack vector, describe this exact support-ticket scenario, and disclose that the server wraps SQL results with instructions discouraging the model from following embedded text, “not foolproof” by their own label. That candor is to their credit. It’s also an acknowledgment that the remaining defense is a request to the model, not a control on the call.

One first-hand observation for calibration: when we staged this incident ourselves to film a demo, the model refused. Sonnet 5 flagged the exfiltration domain, the instruction embedded in the ticket text, and the “do exactly this and nothing else” pressure phrasing, then stopped and asked where the ticket came from. Model-side refusal is real and measurably improving — and it’s probabilistic, which is the difference between a seatbelt that usually latches and a lock. The vendors’ result-wrapping and the models’ skepticism are worth having; neither is the layer you’d accept as the only one on a production database.

Where the native model ends

Grade this server generously — the read-only role is the strongest single control in the series, and scoping plus feature groups make the recommended posture genuinely good. The shape of what’s missing is the same shape as everywhere else, one layer up:

  • All three levers are launch flags in the client config — agent-readable, usually agent-writable, and nothing stops a session launching unscoped read-write “just this once.” The Postgres role holds during a session; nothing holds the flag that sets it.
  • Nothing is per-statement. One read/write decision per session, no conditions, no holds.
  • Nothing is recorded. No trail of what SQL ran, under which posture, across which projects.
  • The exfiltration path is out of scope by construction. A read here plus a write anywhere else is invisible from inside this server — same as GitHub, same as every single-server view.

The practical posture: never point it at production (their words), always --project-ref, default --read-only, trim --features to what the task needs, leave client approval on — and put the per-statement policy, the approval that survives autonomy, and the record of what actually ran in a layer the agent can’t edit. Supabase’s docs get you to the levers; the levers get you to the seam.