[{"data":1,"prerenderedAt":965},["ShallowReactive",2],{"blog-row-level-security-ai-coding-agents":3,"blog-row-level-security-ai-coding-agents-favor-of":964},{"id":4,"title":5,"body":6,"cover_image":949,"date":950,"description":951,"extension":952,"meta":953,"navigation":247,"path":954,"seo":955,"stem":956,"tags":957,"type":962,"__hash__":963},"blog\u002Fblog\u002Frow-level-security-ai-coding-agents.md","Why Row-Level Security Is the Only Reliable Auth for AI-Built Apps",{"type":7,"value":8,"toc":929},"minimark",[9,13,20,23,26,107,118,121,124,127,130,135,138,159,166,173,180,183,188,204,208,211,215,222,224,228,231,328,335,338,347,350,370,380,382,386,392,395,407,410,453,459,462,464,468,486,489,493,496,522,529,564,571,575,586,702,705,732,735,737,741,749,752,770,773,775,779,782,786,802,806,813,820,846,856,860,874,876,880,906,925],[10,11,5],"h1",{"id":12},"why-row-level-security-is-the-only-reliable-auth-for-ai-built-apps",[14,15,16],"p",{},[17,18,19],"em",{},"How many times has an AI coding agent written a flawless feature, only to leave out the tenant filter in the query?",[14,21,22],{},"Ask an AI coding agent to add a new dashboard view or export endpoint. It reads your models, builds an expressive query, wires up the route, writes unit tests, and presents a clean diff. The tests pass and the feature works in development.",[14,24,25],{},"Then you look closely at the database query:",[27,28,33],"pre",{"className":29,"code":30,"language":31,"meta":32,"style":32},"language-ts shiki shiki-themes github-light github-dark","const reports = await db\n  .select()\n  .from(medicalReports)\n  .where(eq(medicalReports.status, \"FINALIZED\"));\n","ts","",[34,35,36,59,72,83],"code",{"__ignoreMap":32},[37,38,41,45,49,52,55],"span",{"class":39,"line":40},"line",1,[37,42,44],{"class":43},"szBVR","const",[37,46,48],{"class":47},"sj4cs"," reports",[37,50,51],{"class":43}," =",[37,53,54],{"class":43}," await",[37,56,58],{"class":57},"sVt8B"," db\n",[37,60,62,65,69],{"class":39,"line":61},2,[37,63,64],{"class":57},"  .",[37,66,68],{"class":67},"sScJk","select",[37,70,71],{"class":57},"()\n",[37,73,75,77,80],{"class":39,"line":74},3,[37,76,64],{"class":57},[37,78,79],{"class":67},"from",[37,81,82],{"class":57},"(medicalReports)\n",[37,84,86,88,91,94,97,100,104],{"class":39,"line":85},4,[37,87,64],{"class":57},[37,89,90],{"class":67},"where",[37,92,93],{"class":57},"(",[37,95,96],{"class":67},"eq",[37,98,99],{"class":57},"(medicalReports.status, ",[37,101,103],{"class":102},"sZZnC","\"FINALIZED\"",[37,105,106],{"class":57},"));\n",[14,108,109,110,113,114,117],{},"The agent remembered to filter by status. It forgot to filter by ",[34,111,112],{},"tenant_id"," or ",[34,115,116],{},"organization_id",".",[14,119,120],{},"In a single-tenant development database with ten seed rows, that query behaves properly. In a multi-tenant staging or production cluster, it leaks every customer's medical records to whichever user called the endpoint.",[14,122,123],{},"This is not a defect unique to language models; human developers make the same mistake under fatigue or tight deadlines. But coding agents make this error at machine speed across dozens of files. As a codebase expands from five tables to fifty, search recall degrades, and the probability of an agent missing a scoping filter in some obscure query approaches certainty.",[14,125,126],{},"The solution is not prompting the agent to \"remember security.\" The solution is moving the authorization boundary into the database engine itself using PostgreSQL Row-Level Security (RLS).",[128,129],"hr",{},[131,132,134],"h2",{"id":133},"the-structural-flaw-of-application-layer-authorization","The structural flaw of application-layer authorization",[14,136,137],{},"Most web applications enforce multi-tenancy and data ownership in the application layer:",[139,140,141,150,153],"ol",{},[142,143,144,145,113,148,117],"li",{},"A middleware verifies the session token and extracts the ",[34,146,147],{},"user_id",[34,149,112],{},[142,151,152],{},"The controller passes that context down through services, repositories, or resolvers.",[142,154,155,156,117],{},"Every individual database query explicitly includes ",[34,157,158],{},"WHERE tenant_id = context.tenant_id",[14,160,161,162,117],{},"This model is structurally fragile because it requires ",[163,164,165],"strong",{},"flawless discipline across every single read and write",[14,167,168],{},[169,170],"img",{"alt":171,"src":172},"Application-layer authorization requires manual checks across fifty files; database Row-Level Security enforces authorization in one place","\u002Fimages\u002Fblog\u002Frls-vs-app-auth-poster.png",[14,174,175,176,179],{},"If an application exposes 80 database queries across its routes, resolvers, background workers, and search endpoints, authorization must succeed 80 times out of 80. A single missed ",[34,177,178],{},"WHERE"," clause in query 47 compromises the entire security posture of the product.",[14,181,182],{},"When AI coding agents author software, application-layer authorization breaks down for three specific reasons:",[184,185,187],"h3",{"id":186},"_1-search-recall-fails-on-growing-codebases","1. Search recall fails on growing codebases",[14,189,190,191,194,195,194,197,194,200,203],{},"When an agent writes a new feature, it searches the repository for existing patterns. If your codebase uses slightly different parameter names across modules (",[34,192,193],{},"tenantId",", ",[34,196,116],{},[34,198,199],{},"orgId",[34,201,202],{},"account_id","), the model's context retrieval misses the authorization pattern in adjacent files. It generates a query based on the table columns it sees, without knowing that a custom wrapper scopes the query.",[184,205,207],{"id":206},"_2-happy-path-test-generation","2. Happy-path test generation",[14,209,210],{},"Agents write tests for the code they just created. If an agent writes a query that lacks tenant scoping, its accompanying test seeds records for one user, queries the endpoint, and asserts that the records returned match what it seeded. The test suite passes with full line coverage while harboring a critical cross-tenant data leak.",[184,212,214],{"id":213},"_3-imperative-complexity-vs-declarative-rules","3. Imperative complexity vs declarative rules",[14,216,217,218,221],{},"Application-layer authorization is imperative: developers write branching logic (",[34,219,220],{},"if (!user.canRead(doc)) throw new Forbidden()",") scattered across controllers. An agent asked to refactor or extend an endpoint easily deletes, bypasses, or moves an authorization guard above an asynchronous fetch.",[128,223],{},[131,225,227],{"id":226},"enforcing-the-boundary-at-the-data-layer","Enforcing the boundary at the data layer",[14,229,230],{},"PostgreSQL Row-Level Security reverses this model. Instead of relying on application code to remember security rules on every query, you define the rules once as declarative database policies on the table:",[27,232,236],{"className":233,"code":234,"language":235,"meta":32,"style":32},"language-sql shiki shiki-themes github-light github-dark","ALTER TABLE documents ENABLE ROW LEVEL SECURITY;\n\n-- Enforce tenant isolation for all authenticated operations\nCREATE POLICY tenant_isolation_policy ON documents\n  AS RESTRICTIVE\n  FOR ALL\n  TO authenticated\n  USING (tenant_id = current_setting('app.tenant_id', true));\n\n-- Enforce ownership for document modification\nCREATE POLICY document_owner_policy ON documents\n  AS PERMISSIVE\n  FOR ALL\n  TO authenticated\n  USING (owner_id = current_setting('request.jwt.claim.sub', true))\n  WITH CHECK (owner_id = current_setting('request.jwt.claim.sub', true));\n","sql",[34,237,238,243,249,254,259,265,271,277,283,288,294,300,306,311,316,322],{"__ignoreMap":32},[37,239,240],{"class":39,"line":40},[37,241,242],{},"ALTER TABLE documents ENABLE ROW LEVEL SECURITY;\n",[37,244,245],{"class":39,"line":61},[37,246,248],{"emptyLinePlaceholder":247},true,"\n",[37,250,251],{"class":39,"line":74},[37,252,253],{},"-- Enforce tenant isolation for all authenticated operations\n",[37,255,256],{"class":39,"line":85},[37,257,258],{},"CREATE POLICY tenant_isolation_policy ON documents\n",[37,260,262],{"class":39,"line":261},5,[37,263,264],{},"  AS RESTRICTIVE\n",[37,266,268],{"class":39,"line":267},6,[37,269,270],{},"  FOR ALL\n",[37,272,274],{"class":39,"line":273},7,[37,275,276],{},"  TO authenticated\n",[37,278,280],{"class":39,"line":279},8,[37,281,282],{},"  USING (tenant_id = current_setting('app.tenant_id', true));\n",[37,284,286],{"class":39,"line":285},9,[37,287,248],{"emptyLinePlaceholder":247},[37,289,291],{"class":39,"line":290},10,[37,292,293],{},"-- Enforce ownership for document modification\n",[37,295,297],{"class":39,"line":296},11,[37,298,299],{},"CREATE POLICY document_owner_policy ON documents\n",[37,301,303],{"class":39,"line":302},12,[37,304,305],{},"  AS PERMISSIVE\n",[37,307,309],{"class":39,"line":308},13,[37,310,270],{},[37,312,314],{"class":39,"line":313},14,[37,315,276],{},[37,317,319],{"class":39,"line":318},15,[37,320,321],{},"  USING (owner_id = current_setting('request.jwt.claim.sub', true))\n",[37,323,325],{"class":39,"line":324},16,[37,326,327],{},"  WITH CHECK (owner_id = current_setting('request.jwt.claim.sub', true));\n",[14,329,330,331,334],{},"Once you enable RLS, PostgreSQL's query rewriter evaluates these policies against every statement executed by the ",[34,332,333],{},"authenticated"," role.",[14,336,337],{},"If an AI coding agent writes an unfiltered query:",[27,339,341],{"className":233,"code":340,"language":235,"meta":32,"style":32},"SELECT id, name, department FROM documents;\n",[34,342,343],{"__ignoreMap":32},[37,344,345],{"class":39,"line":40},[37,346,340],{},[14,348,349],{},"PostgreSQL does not execute an unfiltered table scan. It transparently rewrites the execution tree to:",[27,351,353],{"className":233,"code":352,"language":235,"meta":32,"style":32},"SELECT id, name, department FROM documents\nWHERE tenant_id = current_setting('app.tenant_id', true)\n  AND owner_id = current_setting('request.jwt.claim.sub', true);\n",[34,354,355,360,365],{"__ignoreMap":32},[37,356,357],{"class":39,"line":40},[37,358,359],{},"SELECT id, name, department FROM documents\n",[37,361,362],{"class":39,"line":61},[37,363,364],{},"WHERE tenant_id = current_setting('app.tenant_id', true)\n",[37,366,367],{"class":39,"line":74},[37,368,369],{},"  AND owner_id = current_setting('request.jwt.claim.sub', true);\n",[14,371,372,373,376,377,379],{},"Even if the agent writes ",[34,374,375],{},"DELETE FROM documents;"," without a ",[34,378,178],{}," clause, the database deletes only the rows owned by the current session's subject in the current tenant. The agent cannot leak or destroy another customer's data because the database kernel prevents it.",[128,381],{},[131,383,385],{"id":384},"auditing-one-migration-vs-fifty-files","Auditing: one migration vs fifty files",[14,387,388,389,117],{},"The greatest benefit for teams pairing with AI coding agents is ",[163,390,391],{},"auditability",[14,393,394],{},"When an agent submits a pull request with application-layer authorization, the human reviewer must inspect every changed route, controller, ORM invocation, and SQL builder across twenty files, verifying that:",[396,397,398,401,404],"ul",{},[142,399,400],{},"The tenant context extracted in middleware reached the query.",[142,402,403],{},"The query builder joined the correct scoping table.",[142,405,406],{},"No spread operator or unfiltered search bypassed the tenant filter.",[14,408,409],{},"With Row-Level Security, the reviewer audits the database migration:",[27,411,413],{"className":233,"code":412,"language":235,"meta":32,"style":32},"CREATE POLICY team_access_policy ON medical_records\n  AS PERMISSIVE\n  FOR SELECT\n  TO authenticated\n  USING (\n    department = current_setting('request.jwt.claim.department', true)\n    AND current_setting('request.jwt.claim.role', true) = 'physician'\n  );\n",[34,414,415,420,424,429,433,438,443,448],{"__ignoreMap":32},[37,416,417],{"class":39,"line":40},[37,418,419],{},"CREATE POLICY team_access_policy ON medical_records\n",[37,421,422],{"class":39,"line":61},[37,423,305],{},[37,425,426],{"class":39,"line":74},[37,427,428],{},"  FOR SELECT\n",[37,430,431],{"class":39,"line":85},[37,432,276],{},[37,434,435],{"class":39,"line":261},[37,436,437],{},"  USING (\n",[37,439,440],{"class":39,"line":267},[37,441,442],{},"    department = current_setting('request.jwt.claim.department', true)\n",[37,444,445],{"class":39,"line":273},[37,446,447],{},"    AND current_setting('request.jwt.claim.role', true) = 'physician'\n",[37,449,450],{"class":39,"line":279},[37,451,452],{},"  );\n",[14,454,455,456,117],{},"The reviewer reads five lines of declarative SQL. If those five lines match your security specification, ",[163,457,458],{},"every query the agent writes now or six months from now will adhere to that policy",[14,460,461],{},"Language models also write declarative SQL policies with much higher precision than they maintain across dozens of imperative JavaScript or Python controller guards. Writing a constraint or a policy is a compact, self-contained task with decades of training data and formal relational logic behind it.",[128,463],{},[131,465,467],{"id":466},"in-action-how-graphpg-implements-rls","In action: How graphpg implements RLS",[14,469,470,471,480,481,485],{},"We built ",[163,472,473],{},[474,475,479],"a",{"href":476,"rel":477},"https:\u002F\u002Fmihailtd.github.io\u002Fgraphpg\u002F",[478],"nofollow","graphpg"," (our in-house open-source API engine, tracked on our ",[474,482,484],{"href":483},"\u002Fblog\u002Fgraphpg","technology radar",") specifically around this architecture.",[14,487,488],{},"graphpg acts as an OAuth 2.0 resource server. It verifies incoming JWT access tokens and compiles both GraphQL and REST requests into single SQL statements executed inside a PostgreSQL transaction.",[184,490,492],{"id":491},"how-claims-become-database-session-settings","How claims become database session settings",[14,494,495],{},"When a client sends an authorized request:",[27,497,501],{"className":498,"code":499,"language":500,"meta":32,"style":32},"language-bash shiki shiki-themes github-light github-dark","curl -H \"Authorization: Bearer \u003Ctoken>\" \\\n  https:\u002F\u002Fapi.example.com\u002Frest\u002Fdocuments\n","bash",[34,502,503,517],{"__ignoreMap":32},[37,504,505,508,511,514],{"class":39,"line":40},[37,506,507],{"class":67},"curl",[37,509,510],{"class":47}," -H",[37,512,513],{"class":102}," \"Authorization: Bearer \u003Ctoken>\"",[37,515,516],{"class":47}," \\\n",[37,518,519],{"class":39,"line":61},[37,520,521],{"class":102},"  https:\u002F\u002Fapi.example.com\u002Frest\u002Fdocuments\n",[14,523,524,525,528],{},"graphpg verifies the token signature, checks expiry and audience, and opens a transaction. Before running the query, it executes a batched ",[34,526,527],{},"set_config"," statement within that transaction:",[27,530,532],{"className":233,"code":531,"language":235,"meta":32,"style":32},"SELECT\n  set_config('role', 'authenticated', true),\n  set_config('request.jwt.claim.sub', 'alice', true),\n  set_config('request.jwt.claim.role', 'editor', true),\n  set_config('request.jwt.claim.department', 'cardiology', true),\n  set_config('app.tenant_id', 'tenant-1', true);\n",[34,533,534,539,544,549,554,559],{"__ignoreMap":32},[37,535,536],{"class":39,"line":40},[37,537,538],{},"SELECT\n",[37,540,541],{"class":39,"line":61},[37,542,543],{},"  set_config('role', 'authenticated', true),\n",[37,545,546],{"class":39,"line":74},[37,547,548],{},"  set_config('request.jwt.claim.sub', 'alice', true),\n",[37,550,551],{"class":39,"line":85},[37,552,553],{},"  set_config('request.jwt.claim.role', 'editor', true),\n",[37,555,556],{"class":39,"line":261},[37,557,558],{},"  set_config('request.jwt.claim.department', 'cardiology', true),\n",[37,560,561],{"class":39,"line":267},[37,562,563],{},"  set_config('app.tenant_id', 'tenant-1', true);\n",[14,565,566,567,570],{},"The third argument (",[34,568,569],{},"is_local = true",") instructs PostgreSQL to scope these settings strictly to the current transaction. When the transaction commits or rolls back, PostgreSQL discards the settings automatically. Connection pool poisoning is impossible because no session setting leaks into later pooled connections.",[184,572,574],{"id":573},"real-world-test-validation","Real-world test validation",[14,576,577,578,585],{},"In graphpg's test suite (",[474,579,582],{"href":580,"rel":581},"https:\u002F\u002Fgithub.com\u002Fmihailtd\u002Fgraphpg\u002Fblob\u002Fmain\u002Fcrates\u002Fgraphpg\u002Ftests\u002Frls_auth.rs",[478],[34,583,584],{},"crates\u002Fgraphpg\u002Ftests\u002Frls_auth.rs","), we verify this exact behavior against a live PostgreSQL 18 instance with two isolated tenants and medical departments:",[27,587,591],{"className":588,"code":589,"language":590,"meta":32,"style":32},"language-rust shiki shiki-themes github-light github-dark","\u002F\u002F A token for Alice in cardiology, tenant-1\nlet alice_token = token(json!({\n    \"sub\": \"alice\",\n    \"role\": \"editor\",\n    \"department\": \"cardiology\",\n    \"tenant\": \"tenant-1\"\n}));\n\n\u002F\u002F An agent issues an unfiltered GraphQL query\nlet response = app\n    .oneshot(\n        Request::post(\"\u002Fgraphql\")\n            .header(\"authorization\", format!(\"Bearer {alice_token}\"))\n            .header(\"content-type\", \"application\u002Fjson\")\n            .body(Body::from(json!({\n                \"query\": \"{ documents { nodes { id name ownerId department tenantId } } }\"\n            }).to_string()))\n            .unwrap(),\n    )\n    .await\n    .unwrap();\n","rust",[34,592,593,598,603,608,613,618,623,628,632,637,642,647,652,657,662,667,672,678,684,690,696],{"__ignoreMap":32},[37,594,595],{"class":39,"line":40},[37,596,597],{},"\u002F\u002F A token for Alice in cardiology, tenant-1\n",[37,599,600],{"class":39,"line":61},[37,601,602],{},"let alice_token = token(json!({\n",[37,604,605],{"class":39,"line":74},[37,606,607],{},"    \"sub\": \"alice\",\n",[37,609,610],{"class":39,"line":85},[37,611,612],{},"    \"role\": \"editor\",\n",[37,614,615],{"class":39,"line":261},[37,616,617],{},"    \"department\": \"cardiology\",\n",[37,619,620],{"class":39,"line":267},[37,621,622],{},"    \"tenant\": \"tenant-1\"\n",[37,624,625],{"class":39,"line":273},[37,626,627],{},"}));\n",[37,629,630],{"class":39,"line":279},[37,631,248],{"emptyLinePlaceholder":247},[37,633,634],{"class":39,"line":285},[37,635,636],{},"\u002F\u002F An agent issues an unfiltered GraphQL query\n",[37,638,639],{"class":39,"line":290},[37,640,641],{},"let response = app\n",[37,643,644],{"class":39,"line":296},[37,645,646],{},"    .oneshot(\n",[37,648,649],{"class":39,"line":302},[37,650,651],{},"        Request::post(\"\u002Fgraphql\")\n",[37,653,654],{"class":39,"line":308},[37,655,656],{},"            .header(\"authorization\", format!(\"Bearer {alice_token}\"))\n",[37,658,659],{"class":39,"line":313},[37,660,661],{},"            .header(\"content-type\", \"application\u002Fjson\")\n",[37,663,664],{"class":39,"line":318},[37,665,666],{},"            .body(Body::from(json!({\n",[37,668,669],{"class":39,"line":324},[37,670,671],{},"                \"query\": \"{ documents { nodes { id name ownerId department tenantId } } }\"\n",[37,673,675],{"class":39,"line":674},17,[37,676,677],{},"            }).to_string()))\n",[37,679,681],{"class":39,"line":680},18,[37,682,683],{},"            .unwrap(),\n",[37,685,687],{"class":39,"line":686},19,[37,688,689],{},"    )\n",[37,691,693],{"class":39,"line":692},20,[37,694,695],{},"    .await\n",[37,697,699],{"class":39,"line":698},21,[37,700,701],{},"    .unwrap();\n",[14,703,704],{},"Even though the GraphQL query requested all documents without filters:",[396,706,707,713,719,725],{},[142,708,709,710,117],{},"Alice sees her own cardiology documents in ",[34,711,712],{},"tenant-1",[142,714,715,716,718],{},"Bob's neurology documents in ",[34,717,712],{}," remain hidden.",[142,720,721,722,718],{},"Evan's cardiology documents in ",[34,723,724],{},"acme-2",[142,726,727,728,731],{},"Unauthenticated requests run as ",[34,729,730],{},"anon"," and see only public documents.",[14,733,734],{},"The agent authored a query that looked like a security hole, and the database answered with complete tenant isolation.",[128,736],{},[131,738,740],{"id":739},"defensive-architecture-rls-plus-end-to-end-type-safety","Defensive architecture: RLS plus end-to-end type safety",[14,742,743,744,748],{},"Row-Level Security solves authorization at the data boundary. In an earlier article, we covered how ",[474,745,747],{"href":746},"\u002Fblog\u002Fend-to-end-type-safety-ai-coding-agents","End-to-End Type Safety"," solves schema drift at the compile-time boundary.",[14,750,751],{},"When you combine both patterns, you create a dual-barrier architecture for agentic development:",[139,753,754,764],{},[142,755,756,759,760,763],{},[163,757,758],{},"Compile-time barrier (Types):"," PostgreSQL catalog changes regenerate GraphQL SDL and OpenAPI 3.1 contracts via graphpg. Code generators convert contracts into TypeScript types. If an agent changes a column name or omits a required input, ",[34,761,762],{},"tsc"," fails before the code ever runs.",[142,765,766,769],{},[163,767,768],{},"Runtime barrier (RLS):"," When the agent's code runs, PostgreSQL enforces tenant boundaries, ownership rules, and access control policies directly in the database kernel. If an agent generates an unfiltered query, the database limits the result set to the user's permitted rows.",[14,771,772],{},"Neither barrier requires trust in the model's memory, attention span, or prompt compliance.",[128,774],{},[131,776,778],{"id":777},"best-practices-for-rls-in-agentic-applications","Best practices for RLS in agentic applications",[14,780,781],{},"If you adopt RLS for agent-assisted projects, keep three operational practices in mind:",[184,783,785],{"id":784},"_1-separate-table-owners-from-application-roles","1. Separate table owners from application roles",[14,787,788,789,792,793,113,795,797,798,801],{},"PostgreSQL superusers and table owners bypass Row-Level Security by default. Ensure your application connects as an unprivileged login role (such as ",[34,790,791],{},"authenticator",") that switches to ",[34,794,333],{},[34,796,730],{},". Never run application queries as the database owner or superuser, or apply ",[34,799,800],{},"ALTER TABLE ... FORCE ROW LEVEL SECURITY"," if the owner must connect directly.",[184,803,805],{"id":804},"_2-combine-permissive-and-restrictive-policies","2. Combine PERMISSIVE and RESTRICTIVE policies",[14,807,808,809,812],{},"Use ",[34,810,811],{},"PERMISSIVE"," policies (the default) to define business access rules: document owners can read their files, and department editors can read departmental files.",[14,814,815,816,819],{},"Combine them with an ",[34,817,818],{},"AS RESTRICTIVE"," policy for tenant isolation:",[27,821,823],{"className":233,"code":822,"language":235,"meta":32,"style":32},"CREATE POLICY tenant_barrier ON documents\n  AS RESTRICTIVE\n  FOR ALL\n  TO authenticated\n  USING (tenant_id = current_setting('app.tenant_id', true));\n",[34,824,825,830,834,838,842],{"__ignoreMap":32},[37,826,827],{"class":39,"line":40},[37,828,829],{},"CREATE POLICY tenant_barrier ON documents\n",[37,831,832],{"class":39,"line":61},[37,833,264],{},[37,835,836],{"class":39,"line":74},[37,837,270],{},[37,839,840],{"class":39,"line":85},[37,841,276],{},[37,843,844],{"class":39,"line":261},[37,845,282],{},[14,847,848,849,852,853,855],{},"PostgreSQL requires all ",[34,850,851],{},"RESTRICTIVE"," policies to pass alongside at least one ",[34,854,811],{}," policy. This prevents an overly broad permission policy from ever leaking records across tenant boundaries.",[184,857,859],{"id":858},"_3-index-your-policy-columns","3. Index your policy columns",[14,861,862,863,865,866,869,870,873],{},"Row-Level Security injects policy predicates into every table scan. If your policy checks ",[34,864,112],{}," and ",[34,867,868],{},"owner_id",", composite indexes covering ",[34,871,872],{},"(tenant_id, owner_id)"," ensure policy evaluation remains sub-millisecond even on tables with millions of rows.",[128,875],{},[131,877,879],{"id":878},"key-takeaways","Key takeaways",[396,881,882,888,894,900],{},[142,883,884,887],{},[163,885,886],{},"Application-layer authorization is too fragile for AI coding agents."," Agents authoring dozens of queries will eventually miss a scoping filter or spread an unvalidated input.",[142,889,890,893],{},[163,891,892],{},"PostgreSQL Row-Level Security moves the boundary to the database kernel."," Unfiltered queries written by agents are transparently rewritten to include tenant and ownership rules.",[142,895,896,899],{},[163,897,898],{},"Reviewers audit declarative SQL policies with predictable effort."," Human reviewers inspect a five-line SQL policy once, rather than verifying fifty scattered controllers across pull requests.",[142,901,902,905],{},[163,903,904],{},"graphpg provides zero-drift RLS integration."," Session variables set per-transaction from verified JWT claims ensure seamless RLS enforcement across both GraphQL and REST.",[14,907,908,909,914,915,920,921,117],{},"To learn more about configuring authentication and session settings in graphpg, explore the ",[474,910,913],{"href":911,"rel":912},"https:\u002F\u002Fmihailtd.github.io\u002Fgraphpg\u002Fdocs\u002Fauthentication",[478],"Authentication and RLS Guide"," and the ",[474,916,919],{"href":917,"rel":918},"https:\u002F\u002Fmihailtd.github.io\u002Fgraphpg\u002Fdocs\u002Fai-agents",[478],"AI Agents Guide"," on the ",[474,922,924],{"href":476,"rel":923},[478],"graphpg documentation website",[926,927,928],"style",{},"html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}",{"title":32,"searchDepth":61,"depth":61,"links":930},[931,936,937,938,942,943,948],{"id":133,"depth":61,"text":134,"children":932},[933,934,935],{"id":186,"depth":74,"text":187},{"id":206,"depth":74,"text":207},{"id":213,"depth":74,"text":214},{"id":226,"depth":61,"text":227},{"id":384,"depth":61,"text":385},{"id":466,"depth":61,"text":467,"children":939},[940,941],{"id":491,"depth":74,"text":492},{"id":573,"depth":74,"text":574},{"id":739,"depth":61,"text":740},{"id":777,"depth":61,"text":778,"children":944},[945,946,947],{"id":784,"depth":74,"text":785},{"id":804,"depth":74,"text":805},{"id":858,"depth":74,"text":859},{"id":878,"depth":61,"text":879},"\u002Fimages\u002Fcovers\u002Frow-level-security-ai-coding-agents.png","2026-10-18","Why AI coding agents routinely introduce data leaks in application-layer authorization, and how PostgreSQL Row-Level Security (RLS) turns authorization into an auditable data-layer barrier that no agent query can bypass.","md",{},"\u002Fblog\u002Frow-level-security-ai-coding-agents",{"title":5,"description":951},"blog\u002Frow-level-security-ai-coding-agents",[958,959,960,479,961],"postgresql","security","ai","architecture","blog_post","tsR94edGQ2WeXmhtU7xaxY4CpE-4ksc350wMfUC0AP4",null,1792338348433]