Implementation Guide

    Passing End-User Identity to Postgres Row-Level Security Through the Postgres MCP Server

    To make Postgres row-level security apply per user through an MCP server, the server must validate the end user's token itself, derive identity only from validated claims, and set that identity inside each database transaction using transaction-local settings or SET LOCAL ROLE. RLS policies then read the identity. The database role the server connects with must not be a superuser or have BYPASSRLS, and identity must never persist on a pooled connection after the transaction ends.

    Failure modes to test before release

    • Pooled-connection identity bleed: with a pooler in transaction mode, a session-level SET can persist and be inherited by the next client. Use only transaction-local settings and confirm the value is empty after COMMIT.
    • Privileged roles: confirm the connecting role and every role it can assume are not superusers and do not have BYPASSRLS.
    • Table ownership: confirm FORCE ROW LEVEL SECURITY is enabled on tables owned by the role used in the path.
    • Identity spoofing: confirm no tool argument, prompt content or request header reaches set_config, and that unsigned identity values are rejected.
    • Unset identity: confirm a request with no validated identity returns zero rows rather than an error that exposes data or a default that matches broadly.
    • Token hygiene: confirm expired tokens, wrong audiences and tokens signed by unexpected keys are rejected, and that failures raise alerts.

    Why a shared service role defeats row-level security

    Most Postgres MCP deployments connect with one service credential. Every query then runs as that role, so RLS policies evaluate the service account instead of the person on whose behalf the agent is acting. Teams usually respond by over-granting the service role, which removes per-user restrictions, or by building custom filtering in the MCP server, which duplicates logic the database already enforces.

    NIST guidance on authorization separates service authorization from end-user-to-resource authorization, and separates service (peer) authentication from end-user (request) authentication. A shared role satisfies only the service half. NIST's attribute-based access control guidance also describes the classic RLS precedent: the application tier authenticates the user, conveys identity or role to the database, and the database applies row predicates. In this design the MCP server is that application tier.

    The trust boundary matters. The agent and MCP client are not trusted to assert who the user is. The MCP server is the identity-carrying hop, and Postgres is the final authority on which rows are visible.

    Three patterns for carrying identity into Postgres

    Each pattern moves a validated identity into the database session. They differ in what Postgres sees, how well they scale, and how much the database itself can verify.

    • Per-user database roles with SET ROLE: The server connects as a low-privilege login role and switches to a role mapped to the user. Policies use current_user and ordinary grants apply. Strong native semantics, but role lifecycle must be managed for every user, which becomes heavy at scale.
    • Transaction-scoped session settings: The server calls set_config with the local flag set to true, and policies read the value with current_setting. Works with a small number of roles and scales to many users. The database trusts whatever value the session sets, so server-side validation and tool design carry the weight.
    • Token-based claims validation: The validated claims, or a signed form of them, reach the database and policies or functions consume them. Offers the strongest assurance when the database can verify a signature, but it adds key management and implementation complexity. Tokens should be short-lived and keys scoped narrowly.

    Implementing transaction-scoped identity

    For most platform teams the second pattern, optionally combined with a fixed low-privilege role, is the practical starting point. The sequence for every tool call is the same.

    First, validate the token in the MCP server before any database call. Confirm signature, issuer, audience and validity period. NIST guidance says the scope, validity, source and integrity of tokens must be confirmed by the resource-side component before access is granted, recommends access tokens live no more than one hour, and recommends a security alert when the audience is missing or incorrect. Take the user identifier from the validated subject claim, never from a tool argument the agent can choose.

    Second, open a transaction and set identity inside it. A minimal sketch:

    BEGIN READ ONLY; SELECT set_config('app.user_id', '<validated subject>', true); SET LOCAL ROLE mcp_reader; -- run the parameterized query COMMIT;

    The third argument, true, makes the setting local to the transaction, so it is discarded at COMMIT or ROLLBACK. SET LOCAL ROLE behaves the same way. This scoping is what keeps identity from surviving on the connection.

    Third, connect with a login role that has no superuser attribute and no BYPASSRLS, and that can switch only into the role the MCP server needs. Use a read-only transaction for read tools, and pass the identity value as a bound parameter, never by string concatenation.

    Finally, limit what the agent can ask for. If a tool accepts arbitrary SQL, the agent can attempt to call set_config itself or reset the role, which undermines both session-setting and role-switching patterns. Expose fixed, parameterized queries wherever possible.

    Writing RLS policies that consume the identity

    Policies should fail closed. Reading a custom setting with the missing-ok flag returns NULL when it was never set, and can return an empty string after a transaction-local value has been discarded on the same connection. Wrapping the read in NULLIF handles both cases, so a request with no identity matches no rows.

    ALTER TABLE orders ENABLE ROW LEVEL SECURITY; ALTER TABLE orders FORCE ROW LEVEL SECURITY;

    CREATE POLICY orders_by_owner ON orders FOR SELECT USING (owner_id = NULLIF(current_setting('app.user_id', true), '')::uuid);

    Table owners normally bypass RLS on their own tables. FORCE ROW LEVEL SECURITY applies policies to the owner as well, which closes a common gap when the MCP path touches tables created by a migration role. Superusers and roles with BYPASSRLS skip policies entirely, so none of them should appear in the MCP server's connection path, including through role membership.

    Write separate policies for SELECT, INSERT, UPDATE and DELETE where the rules differ, and use WITH CHECK on writes so a user cannot create or modify rows outside their own scope. Keep the policy expressions simple and indexed, since they run on every query.

    Where database enforcement needs a complement

    RLS decides which rows a user may see. It does not decide whether an agent should be calling a particular tool, what a response may contain once rows are returned, or what should be recorded about the decision. NIST's API authorization guidance recommends enforcing resource-level authorization on all requests as a cross-cutting policy, including at the platform level, alongside field-level filtering of responses per user. That points to two layers: the database enforces row access, and the request path enforces policy on tool calls.

    Trussed operates in that second layer. It enforces policy in the execution path on MCP server tool calls, with allow, redact and block outcomes, and produces audit-ready evidence of what was permitted. It complements Postgres RLS and does not replace it. The database still has to be correct on its own, because a request that passes tool-level policy should still return only the rows that user is entitled to see.

    A sensible next step is to pick one read-only tool, implement the transaction pattern above, run the failure-mode tests against your actual pooler configuration, and then extend to write tools once the tests pass.

    Identity path from agent to row

    Agent request

    Carries the end user's access token. Treated as untrusted for identity assertions.

    MCP server

    Validates signature, issuer, audience and expiry, then extracts the subject.

    Postgres transaction

    Identity is set with transaction scope, then queries run under a least-privilege role.

    RLS policy

    Policies evaluate the propagated identity and filter rows. The database remains the final authority.

    Add runtime policy enforcement to MCP tool calls

    Talk through how enforcement on tool calls sits alongside your Postgres row-level security design.

    Talk to an Expert