Read-only database access for Claude: the role, the flag and Azure without passwords
6m read time

Read-only database access for Claude: the role, the flag and Azure without passwords

Every SQL MCP server promises a read-only mode, and fourteen of them turned out to have a way around it. I ran the bypasses against a read-only role on Postgres and SQL Server, then looked at what passwordless Azure sign-in does to the same setup.

evidence0/7 write attempts that got past the rolePostgreSQL 16.15, SQL Server 2025 RTM-CU105 sourcesOctober 30, 2026
own measurement
seven write and escalation attempts as a read-only role, Postgres and SQL Server in Docker
tested on
PostgreSQL 16.15, SQL Server 2025 RTM-CU10
checked
October 30, 2026

Here is the trick that broke Anthropic's own Postgres MCP server. The server wrapped every query in a read-only transaction. It never checked whether you sent more than one statement.

sql
BEGIN TRANSACTION READ ONLY;
SELECT 1;
COMMIT;
DELETE FROM orders;

The COMMIT closes the read-only transaction, and the DELETE runs in a fresh one with whatever rights the connection has. I ran it this morning against Postgres 16 in Docker. As a superuser, the row was gone. As a role that only holds SELECT, Postgres answered cannot execute DELETE in a read-only transaction, and when I switched that default off as well, permission denied for table orders.

That is the whole post in two runs. The read-only switch in your MCP server is a filter. The role in your database is a boundary. Set up the second one first.

Four bypass attempts slip through gaps in the MCP filter and all stop at the database role: 0/7

Read-only mode in an MCP server is a filter ​

Datadog Security Labs published the COMMIT bypass in August 2025. By then the reference server had been deprecated for six weeks, and the npm package was still pulling 21,000 downloads a week. I checked npm this morning: in the week to 28 October it was downloaded 179,402 times.

The hole was never limited to one package. In July 2026, Maximilian Hildebrand published read-only bypasses in 14 SQL MCP servers, including the official MariaDB one and three for SQL Server. The bypasses fall into a few families:

  • Denylists that miss a verb. None of the denylists he tested contained SET. One SQL Server server checked for DROP followed by a space, so DROP/**/ walked straight past it.
  • Validating only the first statement. SELECT 1; followed by anything.
  • WITH as a wrapper. Six servers allowed WITH without checking what came after it.

In September, AWS published bulletin 2026-104 for its own Postgres MCP server: a COPY ... TO PROGRAM statement could get through the read-only validation and run operating system commands, on self-managed Postgres where the database role held superuser or pg_execute_server_program. AWS's own advice is the one this post rests on: connect with a dedicated role that holds only the privileges it needs.

A filter has to recognise every way SQL can write. The database already knows, because it is the thing doing the writing.

That includes my own. When I ran the same bypass families against Argos, the MCP server I built for database access, eleven cases got through. Among them were set_config, which can switch a read-only session back to read-write, and a zero-width space inside a keyword. They are fixed now, and the point stands: I wrote that filter with care, and it still had holes.

The role, step by step ​

I covered the full recipe, including column-level grants, in the guide to safe SQL access for Claude. What follows is the minimum, plus what I threw at it.

PostgreSQL.

sql
CREATE ROLE claude_ro LOGIN PASSWORD 'change-me';
GRANT CONNECT ON DATABASE app TO claude_ro;
GRANT USAGE ON SCHEMA public TO claude_ro;
GRANT SELECT ON ALL TABLES IN SCHEMA public TO claude_ro;
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO claude_ro;
ALTER ROLE claude_ro SET default_transaction_read_only = on;
ALTER ROLE claude_ro SET statement_timeout = '15s';

As claude_ro I tried three things. The read-only default refused the COMMIT trick, and after SET default_transaction_read_only = off the permissions refused the DELETE. COPY orders TO PROGRAM 'id', the AWS bypass, failed because the role lacks pg_execute_server_program. The row count afterwards was still one.

SQL Server.

sql
CREATE LOGIN claude_ro WITH PASSWORD = 'Change-me-123!';
CREATE USER claude_ro FOR LOGIN claude_ro;
ALTER ROLE db_datareader ADD MEMBER claude_ro;
DENY SELECT ON dbo.Users (Email) TO claude_ro;

Four attempts, four refusals: BEGIN TRAN; SELECT 1; COMMIT; DELETE, EXEC xp_cmdshell 'whoami', SELECT ... INTO a new table, and ALTER ROLE db_owner ADD MEMBER claude_ro. The column DENY held for SELECT * too.

Across both engines that makes 0/7: seven attempts to write or escalate, none of them past the role. The MCP server never came into it, because I sent these straight to the database. That is the point. Whatever your server's filter misses, this layer catches.

Azure without passwords ​

On Azure the password can go entirely. Azure SQL and Azure Database for PostgreSQL both accept a Microsoft Entra token, and az login gets you one. No secret in a config file, nothing to rotate.

There is a catch, and it sits in who that token belongs to. With az login, the MCP server queries as you. If you are the person who set the server up, you are quite possibly its Entra admin, and Microsoft is explicit about what that means: "Administrator accounts are members of the db_owner role in every user database, and each user database is entered as the dbo user."

So the passwordless setup that feels the most secure can hand the agent dbo. Then the MCP server's filter is the only thing between it and a DELETE, the same kind of filter those 14 servers got wrong.

Passwordless removes the secret and leaves the privilege where it was. The fix is the same role, attached to an identity that is not you.

Azure SQL. Create a group or a dedicated identity in Entra for the agent, and map it as a reader:

sql
CREATE USER [DB Readonly] FROM EXTERNAL PROVIDER;
ALTER ROLE db_datareader ADD MEMBER [DB Readonly];

Then sign in as that identity in a separate Azure CLI profile, so your own az login stays yours:

bash
export AZURE_CONFIG_DIR="$HOME/.azure-claude-ro"
az login

Argos takes the token from the Azure CLI with authentication=azure-cli. Start it with that AZURE_CONFIG_DIR in its environment and it connects as the reader. With several databases in one config, azure_config_dir=~/.azure-claude-ro on a single database does the same for that database only. Before you trust it, check who you actually are, as your first query through the server:

sql
SELECT USER_NAME() AS db_user,
       IS_ROLEMEMBER('db_owner') AS is_owner,
       IS_ROLEMEMBER('db_datawriter') AS is_writer;

If db_user comes back as dbo, stop and fix the identity before you do anything else. Argos now runs this kind of check itself every time it connects to a select_only database, on all four engines, and warns when the role can write. Set require_readonly_role=true and it refuses the connection instead.

Azure Database for PostgreSQL. Same idea, different function. Microsoft's Entra guide maps a group with pgaadauth_create_principal, after which you grant it SELECT like any other role:

sql
SELECT * FROM pgaadauth_create_principal('DB Readonly', false, false);
GRANT USAGE ON SCHEMA public TO "DB Readonly";
GRANT SELECT ON ALL TABLES IN SCHEMA public TO "DB Readonly";

The token goes in as the password. Microsoft notes it is valid for 5 to 60 minutes, so the client has to fetch a fresh one for each connection. Argos does that with the same authentication=azure-cli setting it uses for SQL Server, and forces a verified TLS connection, because a bearer token on an unverified wire goes to whoever answers.

Wiring it into Claude Code ​

Register the server with the read-only identity's profile in its environment, so Claude Code never sees your own credentials:

bash
claude mcp add argos --scope user \
  -e AZURE_CONFIG_DIR="$HOME/.azure-claude-ro" -- \
  argos serve --config "$HOME/.config/argos/config.ini"

Keep the server's own read-only mode on as well. It is a second net, and a good one stops a runaway query, such as a nine-way join across your biggest tables, before the database has to carry it. Just don't let it be the only one. Whatever server you pick, run it through the checklist for vetting an MCP server before it sees a connection string.

I learnt the cost of a missing boundary the expensive way, the day Claude deleted my production database. All it took that day was an agent connected with more rights than the task called for, and that is the one thing you can rule out with a handful of lines of SQL.

(41 of 41)
1My Claude Code setup: status line, plugins and terminal2Superpowers: teaching Claude Code to think before it types3Claude Code hooks: deterministic control over AI workflows4The CLAUDE.md file: give your AI permanent memory5Stop asking your agent nicely6What's new in Claude Code: notes from the London talk7The best number in Opus 4.8 isn't a benchmark8Stale memory is worse than no memory9The agent is just a loop10Build an MCP server, then ask whether it should exist11Skill, subagent, hook, or slash command? Pick the right one12Log in to MCP servers from your shell13How to give Claude safe access to your SQL database14The day 'default' became 'Manual'15Claude Code skills: how to write one that works16How to write a proper Claude Code subagent17Claude Code permissions: the guide I wish the docs were18Sandboxing Claude Code: put your agent in a box that holds19Prompt injection defense for developers who ship agents20Best Claude model for coding: which one for which task21Refactoring legacy code with a coding agent: start with characterization tests22MCP server authentication: OAuth, scopes and rate limits23Opus 5 is here and your effort settings just expired24AI agent incident response: what to do when your coding agent goes wrong25Claude Code /doctor command: what it checks and fixes26Claude Code context management: when to /clear and when to /compact27Git worktrees for parallel coding agents: what they isolate and what they share28Claude Code plan mode: decide before the agent writes29Debugging with a coding agent: give it the search, keep the hypothesis30Claude Code checkpoints and /rewind: how to undo an agent's changes31Claude Code cross-session messaging: how to make your sessions talk to each other32Audit logging for AI agents: what Claude Code records and what deserves a human33Sharing Claude Code config across a team: what a repo can and cannot enforce34Your Claude Code session has no clock35Claude Code in a large codebase: scoping an agent to the part that matters36Recovering a Claude Code session your picker will not show you37Where Claude Code spends its time: a month of my own sessions, measured38Steering Claude Code mid-task: Esc, queued messages and /btw39Claude Code auto mode: why your Agent allow rule does nothing40Claude Code Superpowers: what subagent-driven development costs in tokens41Read-only database access for Claude: the role, the flag and Azure without passwords