Dit is de truc waarmee de eigen Postgres MCP-server van Anthropic onderuitging. De server stopte elke query in een read-only transactie, maar controleerde nooit of je meer dan één statement meestuurde.
BEGIN TRANSACTION READ ONLY;
SELECT 1;
COMMIT;
DELETE FROM orders;De COMMIT sluit de read-only transactie af, en de DELETE draait in een nieuwe, met alle rechten die de verbinding heeft. Ik heb het vanochtend uitgeprobeerd op Postgres 16 in Docker. Als superuser was de rij weg. Als rol met alleen SELECT kreeg ik cannot execute DELETE in a read-only transaction terug, en nadat ik ook die read-only instelling had uitgezet, permission denied for table orders.
Daarmee is de hele post eigenlijk al verteld. De read-only schakelaar in je MCP-server is een filter. De rol in je database is een grens. Regel die grens als eerste.
De read-only modus van een MCP-server is een filter
Datadog Security Labs publiceerde de COMMIT-bypass in augustus 2025. De referentieserver was toen al zes weken deprecated, en het npm-package werd nog altijd 21.000 keer per week gedownload. Vanochtend keek ik op npm: in de week tot 28 oktober waren het er 179.402.
Het bleef niet bij dat ene package. In juli 2026 publiceerde Maximilian Hildebrand read-only bypasses in 14 SQL MCP-servers, waaronder de officiële van MariaDB en drie voor SQL Server. De bypasses vallen in een paar groepen uiteen:
- Denylists die een werkwoord missen. Geen enkele denylist die hij testte, bevatte
SET. Eén server voor SQL Server zocht naarDROPgevolgd door een spatie, dusDROP/**/liep er zo langs. - Alleen het eerste statement controleren.
SELECT 1;en daarna wat je maar wilt. WITHals verpakking. Zes servers lietenWITHtoe zonder te kijken wat erna kwam.
In september publiceerde AWS bulletin 2026-104 over de eigen Postgres MCP-server: een COPY ... TO PROGRAM-statement kon door de read-only validatie glippen en commando's uitvoeren op het besturingssysteem. Dat gold voor Postgres in eigen beheer, als de databaserol superuser was of pg_execute_server_program had. AWS geeft zelf precies het advies waar deze post op rust: verbind met een aparte rol die alleen de rechten heeft die hij nodig heeft.
Een filter moet elke manier herkennen waarop SQL kan schrijven. De database weet dat al, want die schrijft zelf.
Dat geldt ook voor dat van mij. Toen ik dezelfde families losliet op Argos, de MCP-server die ik voor databasetoegang bouwde, kwamen er elf gevallen doorheen. Daaronder set_config, waarmee een read-only sessie weer kan schrijven, en een zero-width space midden in een keyword. Die zijn inmiddels gedicht, maar het punt blijft staan: ik schreef dat filter met zorg, en er zaten toch gaten in.
De rol, stap voor stap
Het volledige recept, met rechten per kolom, staat in mijn gids voor veilige SQL-toegang voor Claude. Hieronder het minimum, plus wat ik erop heb losgelaten.
PostgreSQL.
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';Als claude_ro probeerde ik drie dingen. De COMMIT-truc, tegengehouden door de read-only instelling. SET default_transaction_read_only = off met daarna de DELETE, tegengehouden door de rechten. COPY orders TO PROGRAM 'id', de bypass uit het AWS-bulletin, tegengehouden omdat de rol geen pg_execute_server_program heeft. Na afloop stond er nog steeds één rij in de tabel.
SQL Server.
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;Vier pogingen, vier keer geweigerd: BEGIN TRAN; SELECT 1; COMMIT; DELETE, EXEC xp_cmdshell 'whoami', SELECT ... INTO een nieuwe tabel, en ALTER ROLE db_owner ADD MEMBER claude_ro. De DENY op de kolom hield ook stand bij SELECT *.
Over beide engines samen is dat 0/7: zeven pogingen om te schrijven of meer rechten te pakken, en geen enkele kwam langs de rol. Er zat geen MCP-server tussen, ik stuurde alles rechtstreeks naar de database. Daar gaat het juist om. Wat het filter van je server ook mist, deze laag vangt het op.
Azure zonder wachtwoord
Op Azure kan het wachtwoord helemaal weg. Azure SQL en Azure Database for PostgreSQL accepteren allebei een Microsoft Entra-token, en dat haal je op met az login. Geen secret in een configbestand, niets om te roteren.
Er zit wel een addertje onder het gras: van wie is dat token? Met az login voert de MCP-server de query's namens jou uit. Heb jij de server ingericht, dan is de kans groot dat je de Entra-admin bent, en Microsoft zegt het zonder omwegen: "Administrator accounts are members of the db_owner role in every user database, and each user database is entered as the dbo user."
De opzet zonder wachtwoord die het veiligst aanvoelt, kan de agent dus dbo geven. Dan staat alleen het filter van de MCP-server nog tussen de agent en een DELETE, en juist zo'n filter ging bij die 14 servers mis.
Zonder wachtwoord ben je het secret kwijt. De rechten blijven gewoon staan. De oplossing is dezelfde rol, gekoppeld aan een identiteit die niet de jouwe is.
Azure SQL. Maak in Entra een groep of een aparte identiteit voor de agent, en koppel die als lezer:
CREATE USER [DB Readonly] FROM EXTERNAL PROVIDER;
ALTER ROLE db_datareader ADD MEMBER [DB Readonly];Log daarna als die identiteit in met een apart Azure CLI-profiel, zodat je eigen az login van jou blijft:
export AZURE_CONFIG_DIR="$HOME/.azure-claude-ro"
az loginArgos haalt het token met authentication=azure-cli uit de Azure CLI. Start hem met die AZURE_CONFIG_DIR in zijn omgeving en hij verbindt als de lezer. Staan er meerdere databases in één config, dan regelt azure_config_dir=~/.azure-claude-ro hetzelfde voor alleen die ene database. Controleer wie je echt bent voordat je erop vertrouwt. Laat dit je eerste query via de server zijn:
SELECT USER_NAME() AS db_user,
IS_ROLEMEMBER('db_owner') AS is_owner,
IS_ROLEMEMBER('db_datawriter') AS is_writer;Krijg je dbo terug als db_user, stop dan en zet eerst de identiteit recht. Argos doet zo'n controle inmiddels zelf, elke keer dat hij verbindt met een select_only-database, op alle vier de engines, en waarschuwt als de rol kan schrijven. Zet je require_readonly_role=true, dan weigert hij de verbinding.
Azure Database for PostgreSQL. Zelfde idee, andere functie. De Entra-handleiding van Microsoft koppelt een groep met pgaadauth_create_principal, en daarna geef je die net als elke andere rol SELECT:
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";Het token dient als wachtwoord. Volgens Microsoft is het 5 tot 60 minuten geldig, dus de client moet voor elke verbinding een vers token ophalen. Argos doet dat met dezelfde instelling authentication=azure-cli als voor SQL Server, en dwingt een geverifieerde TLS-verbinding af, want een bearer token over een onbeveiligde lijn komt terecht bij wie er ook maar antwoordt.
Aansluiten op Claude Code
Registreer de server met het profiel van de read-only identiteit in zijn omgeving, zodat Claude Code jouw eigen credentials nooit te zien krijgt:
claude mcp add argos --scope user \
-e AZURE_CONFIG_DIR="$HOME/.azure-claude-ro" -- \
argos serve --config "$HOME/.config/argos/config.ini"Laat de read-only modus van de server zelf ook aanstaan. Het is een tweede vangnet, en een goed vangnet houdt een ontspoorde query, zoals een join over negen van je grootste tabellen, al tegen voordat de database er last van krijgt. Vertrouw er alleen nooit als enige op. Welke server je ook kiest, haal hem eerst door de checklist voor het controleren van een MCP-server voordat hij een connection string te zien krijgt.
Wat een ontbrekende grens kost, leerde ik met schade en schande, op de dag dat Claude mijn productiedatabase verwijderde. Daar kwam geen slimme bypass aan te pas, alleen een agent met meer rechten dan de taak vroeg. En juist dat sluit je uit met een handvol regels SQL.