How to give an AI agent safe Postgres access: the role, RLS and statement limits

Three layers, each tested on PostgreSQL 18.6 with the output below: what the agent may change, which rows, and for how long. Then the gap all three share when the agent writes its own SQL.

14 October 2026 · 6 min read

A read-only role is the right first step for giving an AI agent database access, and for an agent that only reads it’s most of the job. This post is about the agent that has to write: a support agent that can cancel one customer’s paid order, say. We set it up in three layers, test each one, and then show the gap they share.

Everything here was run on PostgreSQL 18.6 against a small demo store database (500 orders, 120 customers). The SQL is complete; the output is pasted, not retyped.

Three layers

  1. The role decides what the agent may do: which tables, which columns, which verbs.
  2. Row-level security decides which rows: this customer’s orders, not everyone’s.
  3. Limits decide how much and how long: statement time, lock waits, idle transactions, connections.

Each layer is enforced by Postgres, not by the agent’s instructions. That’s the point: an instruction in a prompt is a request, a privilege is a fact.

Layer 1: the role

CREATE ROLE agent_support LOGIN PASSWORD 'blog-agent-throwaway' CONNECTION LIMIT 3;
ALTER ROLE agent_support SET statement_timeout = '2s';
ALTER ROLE agent_support SET lock_timeout = '1s';
ALTER ROLE agent_support SET idle_in_transaction_session_timeout = '3s';
GRANT USAGE ON SCHEMA public TO agent_support;
GRANT SELECT (id, customer_id, status, total_cents) ON public.orders TO agent_support;
GRANT UPDATE (status) ON public.orders TO agent_support;

A new role starts with nothing; until privileges are granted, only an object’s owner (or a superuser) can do anything with it.2 So every line above is a decision. The agent can read four columns of orders and change one. Postgres can grant SELECT, INSERT, UPDATE and REFERENCES on single columns, while TRUNCATE is table-level only and isn’t granted here.4 No other table is visible.

Both refusals come back as errors the agent can read:

-- a column it can't write
   UPDATE public.orders SET total_cents = 0 WHERE customer_id = 7;
   ERROR:  permission denied for table orders

-- a column it can't read
   SELECT created_at FROM public.orders LIMIT 1;
   ERROR:  permission denied for table orders

Don’t rely on a tool’s read-only mode instead of this. Datadog Security Labs showed that the archived reference Postgres MCP server’s read-only mode ran each query in a read-only transaction that a stacked COMMIT; ends, after which later statements run with the role’s full rights.8 With a role like this one, “full rights” are four columns and one update.

Layer 2: row-level security per customer

The support agent works on one customer’s conversation at a time. Row-level security can tie its view to that customer:

ALTER TABLE public.orders ENABLE ROW LEVEL SECURITY;
CREATE POLICY support_one_customer ON public.orders FOR ALL TO agent_support
  USING (customer_id = current_setting('app.customer_id', true)::int)
  WITH CHECK (customer_id = current_setting('app.customer_id', true)::int AND status IN ('paid', 'cancelled'));

Once row security is on, access with no matching policy is denied, and table owners bypass it unless FORCE ROW LEVEL SECURITY is set, so the agent must never connect as the owner.3 The true in current_setting('app.customer_id', true) matters: it returns NULL instead of an error when the setting doesn’t exist.6 NULL matches no customer, so a session that hasn’t said who it’s working for sees nothing:

-- no customer set: RLS shows nothing
   SELECT count(*) FROM public.orders;
    count
   -------
        0

Set the customer and the agent sees exactly that customer’s orders. Try to touch another customer’s order and the update simply matches nothing:

-- customer 7 set
   SET app.customer_id = '7';
   SELECT id, status, total_cents FROM public.orders ORDER BY id;
    id  |  status   | total_cents
   -----+-----------+-------------
    100 | shipped   |       11622
    256 | shipped   |        2344
    407 | shipped   |       25123
    469 | shipped   |       32670
    484 | cancelled |        8202
   (5 rows)

-- another customer's order
   SET app.customer_id = '7';
   UPDATE public.orders SET status = 'cancelled' WHERE customer_id = 8;
   UPDATE 0

UPDATE 0 is not an error. Tell the agent, in its tool description, that zero rows is an answer and not a reason to retry another way.

Layer 3: limits

Limits protect the database from an agent that loops, scans or forgets to commit. All four are set on the role in layer 1.

Statement time. statement_timeout aborts any statement that runs longer than its limit.1

   SELECT pg_sleep(3);
   ERROR:  canceling statement due to statement timeout

Lock waits. lock_timeout aborts a statement that waits too long for a lock, so an agent queues behind a migration for one second, not for ever.1 With another session holding a lock on orders:

   UPDATE public.orders SET status = 'cancelled' WHERE id = (SELECT min(id) FROM public.orders);
   ERROR:  canceling statement due to lock timeout

Idle transactions. An agent that opens a transaction and then goes off to think (or call a slow model) holds its locks the whole time. idle_in_transaction_session_timeout ends that session.1

   BEGIN;
   SELECT 1 AS opened;
   SELECT 2 AS still_there;
   FATAL:  terminating connection due to idle-in-transaction timeout

Connections. CONNECTION LIMIT caps concurrent sessions for the role, so a retry loop from this role stops at three sessions instead of taking every connection the server has. Postgres enforces the limit approximately and never for superusers.7

   psql: error: connection to server at "127.0.0.1", port 55489 failed: FATAL:  too many connections for role "agent_support"

The gap: the session owns its settings

Here is the part most guides leave out. A setting attached with ALTER ROLE … SET becomes the role’s session default at login.5 A default, not a lock. If the agent can send its own SQL, it can change it:

-- the session turns the timeout off
   SET statement_timeout = 0;
   SELECT pg_sleep(3);
    pg_sleep
   ----------

   (1 row)

Worse, the same is true of the setting the row-level security policy trusts:

-- the session picks another customer
   SET app.customer_id = '8';
   SELECT count(*) FROM public.orders;
    count
   -------
        3

Nothing failed. The policy did exactly what it says: show the orders of the customer named in app.customer_id. The session named a different customer.5

So: the role (layer 1) holds no matter what SQL the agent sends. Row-level security keyed on a session variable, and every limit in layer 3, hold only if the agent can’t send SET.

Closing the gap

You have three options, in order of preference.

Don’t give the agent raw SQL. Give it tools with fixed queries, cancel_order(order_id) rather than execute_sql(sql). The tool server opens the transaction, runs SET LOCAL app.customer_id = … from the conversation it’s serving (not from the model), runs its one statement, and commits. The agent never gets a chance to change a setting.

If it must write SQL, put something between it and Postgres that refuses session changes. That means parsing each statement and refusing SET, RESET, set_config(…) and anything with more than one statement, then running what’s left inside a transaction with the settings applied by the proxy, not the session. In Deedglass, agent SQL runs as a least-privilege role in a transaction Deedglass controls, one statement per action, and every write commits together with a ledger row, or not at all.9 The connector’s limits live in its config, not in the agent’s session:

config:
  mode: sql
  allowed_schemas: [public]
  writable_tables: [public.orders, public.refunds]
  row_limit: 500
  max_rows_affected: 1000
  statement_timeout_ms: 5000
  lock_timeout_ms: 2000

Key row-level security to something the session can’t change. session_user is the role the connection logged in as; SET ROLE changes current_user but leaves session_user alone.10 If you have a handful of tenants, a login role per tenant with USING (tenant_role = session_user) holds even when the agent sends raw SQL, at the cost of managing those roles and their passwords.

The short version

  • A narrow role is the boundary. Column grants, no ownership, no superuser. It holds whatever the agent sends.
  • Row-level security narrows rows. Use current_setting(…, true) so an unset tenant sees nothing.
  • Set statement, lock, idle and connection limits on the role. They catch loops and forgotten transactions.
  • If the agent can send SET, the limits and any session-keyed policy are its to change. Use fixed tools, a proxy that refuses session changes, or policies keyed to session_user.

To see what your own agent’s role can change today, run the free scan, npx deedglass scan; the grade is explained here and nothing leaves your machine. To watch rules decide each call and a check report what committed, without installing anything, try the playground. For the vendor-free basics with a tested SQL file, start with the guide to giving Claude Code or Cursor write access to Postgres.

Sources

  1. 1PostgreSQL 18 documentation: Client connection defaults · postgresql.org · checked 14 October 2026
  2. 2PostgreSQL 18 documentation: Privileges · postgresql.org · checked 13 October 2026
  3. 3PostgreSQL 18 documentation: Row security policies · postgresql.org · checked 13 October 2026
  4. 4PostgreSQL 18 documentation: GRANT · postgresql.org · checked 13 October 2026
  5. 5PostgreSQL 18 documentation: ALTER ROLE · postgresql.org · checked 14 October 2026
  6. 6PostgreSQL 18 documentation: System administration functions · postgresql.org · checked 14 October 2026
  7. 7PostgreSQL 18 documentation: CREATE ROLE · postgresql.org · checked 14 October 2026
  8. 8Datadog Security Labs: SQL injection in the Postgres MCP server · securitylabs.datadoghq.com · checked 13 October 2026
  9. 9Deedglass docs: Put Deedglass in front of Postgres · docs.deedglass.com · checked 14 October 2026
  10. 10PostgreSQL 18 documentation: SET ROLE · postgresql.org · checked 14 October 2026

Published 14 October 2026, last checked 14 October 2026. If something here is out of date, write to hello@deedglass.com.

Try it on your own setup

npx deedglass scan

Grades only your own setup. Nothing is uploaded. The package opens with the public release.

Pricing