Implementing Postgres Row-Level Security to Constrain AI Agent Data Access Behind an MCP Server
To constrain AI agents behind an MCP server, pass the caller's identity into each Postgres transaction, either as a transaction-scoped role or a transaction-scoped setting. Enforce it with RLS policies on tables owned by a non-bypass role with FORCE ROW LEVEL SECURITY enabled. Connect through a role that cannot bypass RLS, keep identity state out of pooled sessions, and test with adversarial queries. RLS is a necessary but bounded layer. Tool-level authorization, output redaction, and human approval must be enforced separately in the execution path.
Verify that policies hold under adversarial queries
- An unbound session returns zero rows and rejects writes.
- Identity A cannot read, update, or delete identity B's rows, including through joins and subqueries.
- INSERT and UPDATE with another tenant's identifier are rejected by WITH CHECK.
- Two sequential transactions with different identities on one pooled connection do not share state.
- The login role cannot reach BYPASSRLS, owner, or superuser privileges, and views respect the caller's policies.
- Policy-heavy queries are checked with EXPLAIN on realistic data, with indexes on policy columns, since policy predicates add to every query plan.
Why a shared service account fails
Many MCP servers open one database connection pool under a single service account. Every tool call then runs with the same privileges, whatever the user or agent behind it is entitled to see. NIST's Cyber AI Profile draft notes that AI agents may have access to data sources or tools that a user typically would not have. A shared account turns that observation into an operating condition. A prompt-injected or misrouted query inherits the full reach of the account, including rows from other tenants or roles.
The fix is to move the decision into the data layer, where it does not depend on the agent or the MCP server behaving correctly. NIST research on database access control describes enforcement at the data level as removing the need to implement and manage access control in applications. In Postgres, row-level security (RLS) is the native mechanism for this. The architecture has four hops: agent, MCP server, connection pool, and database. Identity must be established at the MCP server, carried through the pool, and evaluated by Postgres on every statement.
NIST's agent identity work treats how identities and entitlements are granted to agents as an open area, so expect to revisit your pattern as standards develop.
Choose an identity propagation pattern
There are three common ways to bind the caller to the session. NIST's vocabulary helps frame the choice. Role-based access control mediates access through role constructs, while attribute-based access control evaluates attributes of the subject, object, operation, and sometimes environment against policy.
The first pattern maps each identity or entitlement group to a database role. The MCP server connects as a low-privilege login role and runs SET LOCAL ROLE for the duration of the transaction. Policies and grants then reference the active role. This is simple to audit but scales poorly when you have many users or tenants.
The second pattern keeps a small set of roles and passes attributes, such as tenant, user, and agent identifiers, as transaction-scoped settings. The MCP server calls set_config with the local flag set to true at the start of each transaction, and policies read the values with current_setting. This scales well, but policies are only as trustworthy as the code that sets the values, so only the MCP server's database login should be able to set them for real traffic.
The third pattern opens a connection per identity. It gives strong isolation but defeats pooling and is rarely practical for agent workloads.
For most teams, the second pattern, with a role for coarse capability and settings for row scope, is the workable middle. Whichever you pick, derive the identity from the authenticated MCP request, never from tool arguments the model can write.
Write the policies
Enable RLS on every table an agent can reach, then add policies per command. A basic tenant isolation policy compares a row column to the session attribute, for example tenant_id = current_setting('app.tenant_id', true)::uuid. Passing true as the second argument returns null when the setting is missing, so an unbound session matches no rows. Failing closed is the behavior you want.
Understand the two clauses. USING filters which existing rows a statement can see, update, or delete. WITH CHECK validates rows being inserted or updated, so an agent cannot write a row into another tenant's scope. Define both for write paths. Without a policy for a command, access is denied once RLS is enabled.
Then close the common bypasses:
- Superusers and BYPASSRLS roles: These roles skip RLS entirely. The MCP server's login role must be neither, and neither should any role it can switch to.
- Table owners: Owners are exempt from policies by default. Use ALTER TABLE ... FORCE ROW LEVEL SECURITY, and keep the owner role out of the application path.
- Views: A view runs with its owner's rights by default, which can sidestep policies on underlying tables. Configure views as security invoker or avoid exposing them.
- Functions: Avoid SECURITY DEFINER functions reachable by agents unless they re-apply the same identity checks.
- Grants: RLS narrows rows, not columns. Grant only the tables and columns the tool needs.
Configure pooling so identity cannot leak
Pooling is where identity propagation most often fails. In transaction-level pooling, consecutive transactions from different clients can share one server connection. If the MCP server sets identity with a session-level SET, the value can persist on that connection and apply to the next caller's queries. The result is silent cross-identity access that looks like normal traffic.
The rule is to scope all identity state to the transaction. Use SET LOCAL or set_config with the local flag, issue it inside an explicit transaction as the first statement, and run the tool's queries in that same transaction. Treat any code path that issues queries outside such a wrapper as a defect. Do the same for role switching: SET LOCAL ROLE reverts at transaction end, while a session-level SET ROLE does not.
Also avoid letting the model supply raw SQL that can issue its own SET, RESET, or SET ROLE statements. Prefer parameterized, tool-defined queries. If free-form SQL is unavoidable, run it under a role that lacks the ability to change identity settings, and confirm that behavior by testing on your pooler configuration rather than assuming it.
Where RLS ends and runtime enforcement begins
RLS decides which rows a session may touch. It does not decide whether a tool should be called at all, does not redact sensitive values inside permitted rows or in model output, and does not ask a human before a consequential action. Policy predicates can also leak through side channels such as error messages or timing, so keep error detail out of tool responses. NIST's agentic security material lists strict tool scopes among its mitigations, which is a separate constraint from data-layer control. A layered design is the supported approach.
Trussed enforces policy in the execution path across models, agents, tools, and MCP servers. For tool calls, it applies allow, redact, or block decisions at runtime, not reviewed after the fact, and can also rate limit or escalate for human approval. Enforcement is scoped to individual interactions and tool calls. Each decision produces audit-ready evidence, which complements the database's own record of what each identity could access. Trussed can be self-managed in the customer cloud, with configurable retention and data residency.
In practice, Postgres RLS answers which rows a caller may touch, and execution-path enforcement answers whether this tool call, with these arguments and this output, should proceed.
Identity path from agent to row
Agent
Issues a tool call on behalf of a user or under its own identity.
MCP server
Authenticates the caller and derives the identity to bind to the database session.
Connection pool
Reuses server connections, so session state must not outlive the transaction.
Postgres
Evaluates RLS policies against the bound identity for every statement.
Pair database controls with enforcement in the execution path
See how Trussed applies allow, redact, and block decisions to MCP tool calls and produces audit-ready evidence alongside your Postgres controls.
Explore Agent and Tool-Call EnforcementHow does your AI governance program compare?
See where your program has gaps in less than 2 minutes.