<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Emil Alander</title>
    <description>The latest articles on DEV Community by Emil Alander (@emil_alander_7c).</description>
    <link>https://dev.to/emil_alander_7c</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4036872%2Fc1233f96-a04d-4ca4-bc74-39656aeac83b.jpg</url>
      <title>DEV Community: Emil Alander</title>
      <link>https://dev.to/emil_alander_7c</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/emil_alander_7c"/>
    <language>en</language>
    <item>
      <title>Next.js Middleware Auth Bypass: CVE-2025-29927</title>
      <dc:creator>Emil Alander</dc:creator>
      <pubDate>Sun, 19 Jul 2026 18:44:06 +0000</pubDate>
      <link>https://dev.to/emil_alander_7c/nextjs-middleware-auth-bypass-cve-2025-29927-36no</link>
      <guid>https://dev.to/emil_alander_7c/nextjs-middleware-auth-bypass-cve-2025-29927-36no</guid>
      <description>&lt;p&gt;A spoofable x-middleware-subrequest header let requests skip Next.js middleware, bypassing auth. Upgrade to the patched version; don't trust middleware alone.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt; — CVE-2025-29927 is a critical (CVSS 9.1) authorization bypass in Next.js. The framework used an internal header, &lt;code&gt;x-middleware-subrequest&lt;/code&gt;, to stop middleware from recursing — and it trusted that header even on requests coming from the outside. So an attacker could add &lt;code&gt;x-middleware-subrequest&lt;/code&gt; to any request and Next.js would &lt;strong&gt;skip middleware entirely&lt;/strong&gt;, sailing straight past any auth or redirect logic that lived there. &lt;strong&gt;The fix: upgrade Next.js to the patched release for your major (15.2.3, 14.2.25, 13.5.9, or 12.3.5).&lt;/strong&gt; The deeper fix: never make middleware the &lt;em&gt;sole&lt;/em&gt; authorization gate — re-check auth in the route handler, server action, and data layer too.&lt;/p&gt;

&lt;h2&gt;
  
  
  Is my Next.js app vulnerable to CVE-2025-29927?
&lt;/h2&gt;

&lt;p&gt;If you run a &lt;strong&gt;self-hosted&lt;/strong&gt; Next.js app (using &lt;code&gt;next start&lt;/code&gt; or &lt;code&gt;output: 'standalone'&lt;/code&gt;) on an affected version, and you gate access to protected routes in &lt;code&gt;middleware.ts&lt;/code&gt;, then yes — you were exposed.&lt;/p&gt;

&lt;p&gt;The affected and patched ranges, straight from the GitHub advisory (GHSA-f82v-jwr5-mffw) and NVD:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Release line&lt;/th&gt;
&lt;th&gt;Affected range&lt;/th&gt;
&lt;th&gt;Fixed in&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;15.x&lt;/td&gt;
&lt;td&gt;≥ 15.0.0, &amp;lt; 15.2.3&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;15.2.3&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;14.x&lt;/td&gt;
&lt;td&gt;≥ 14.0.0, &amp;lt; 14.2.25&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;14.2.25&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;13.x&lt;/td&gt;
&lt;td&gt;≥ 13.0.0, &amp;lt; 13.5.9&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;13.5.9&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;12.x / 11.x&lt;/td&gt;
&lt;td&gt;≥ 11.1.4, &amp;lt; 12.3.5&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;12.3.5&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The bug was introduced at &lt;strong&gt;11.1.4&lt;/strong&gt;. There is &lt;strong&gt;no separate 11.x patch&lt;/strong&gt; — if you're on 11.x, you must move to 12.3.5 (or apply the header workaround and move auth out of middleware).&lt;/p&gt;

&lt;p&gt;Check the &lt;em&gt;resolved&lt;/em&gt; version, not the range in &lt;code&gt;package.json&lt;/code&gt; — a caret range like &lt;code&gt;"next": "^14.1.0"&lt;/code&gt; tells you what's allowed, not what's installed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx next &lt;span class="nt"&gt;--version&lt;/span&gt;   &lt;span class="c"&gt;# the actually-resolved binary&lt;/span&gt;
npm &lt;span class="nb"&gt;ls &lt;/span&gt;next          &lt;span class="c"&gt;# the lockfile-resolved version in the tree&lt;/span&gt;
npm audit            &lt;span class="c"&gt;# flags GHSA-f82v-jwr5-mffw until you're patched&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Who was NOT exposed:&lt;/strong&gt; apps on &lt;strong&gt;Vercel&lt;/strong&gt; and &lt;strong&gt;Netlify&lt;/strong&gt;. Vercel's routing runs decoupled from the Next.js server, so the client-supplied header never reached the middleware evaluation the way it did in self-hosted setups. Static exports and Cloudflare Workers deployments were likewise not impacted.&lt;/p&gt;

&lt;h2&gt;
  
  
  How does the middleware bypass work?
&lt;/h2&gt;

&lt;p&gt;Next.js middleware runs before a request reaches a route — perfect for auth gating, redirects, and header rewriting. Internally, Next.js can re-invoke middleware as part of its own request handling, so it needs a way to stop that from recursing forever. It used an internal request header, &lt;code&gt;x-middleware-subrequest&lt;/code&gt;, to flag a request as an internal subrequest and tell the framework to &lt;strong&gt;skip running middleware&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The flaw: that header was trusted &lt;strong&gt;unconditionally&lt;/strong&gt;. Nothing verified it originated internally rather than from the client. Add the header to an inbound request and Next.js skips middleware — while still serving the underlying route. Any authorization decision in middleware was silently bypassed.&lt;/p&gt;

&lt;p&gt;The expected value tracked how Next.js evolved its recursion guard (per source-level analyses from Datadog Security Labs and ProjectDiscovery):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Pre-12.2:&lt;/strong&gt; the middleware file path, e.g. &lt;code&gt;x-middleware-subrequest: pages/_middleware&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;~12.2–13.1.x:&lt;/strong&gt; a single value like &lt;code&gt;middleware&lt;/code&gt; (or &lt;code&gt;src/middleware&lt;/code&gt; for a &lt;code&gt;src/&lt;/code&gt; layout).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;13.2.0+ / 14.x / 15.x:&lt;/strong&gt; a recursion-depth guard that counts occurrences and skips once the count exceeds &lt;code&gt;MAX_RECURSION_DEPTH&lt;/code&gt; (default &lt;strong&gt;5&lt;/strong&gt;), so the payload became the identifier repeated five times, colon-separated: &lt;code&gt;middleware:middleware:middleware:middleware:middleware&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Remediation: upgrade first
&lt;/h2&gt;

&lt;p&gt;The real remedy is upgrading Next.js to the patched release for your major line. Pin to at least the patched version so you get the fix without an unwanted major jump:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install &lt;/span&gt;next@14.2.25    &lt;span class="c"&gt;# or next@^14.2.25 to take later patches on your major&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A static dependency scanner flags exactly this — an outdated, vulnerable Next.js version sitting in &lt;code&gt;package.json&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"dependencies"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"next"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"14.1.0"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"react"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"18.2.0"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"react-dom"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"18.2.0"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(&lt;a href="https://www.guardlayer.io/blog/nextjs-middleware-auth-bypass-cve-2025-29927" rel="noopener noreferrer"&gt;See GuardLayer's live scan flag that version as vulnerable on the original post.&lt;/a&gt;) Be precise about what that catches: the tool flags the &lt;strong&gt;outdated version&lt;/strong&gt; — it does &lt;strong&gt;not&lt;/strong&gt; detect the &lt;code&gt;x-middleware-subrequest&lt;/code&gt; header spoof at the code level; nothing static in your handlers reveals that a header was trusted at the framework layer. The version flag is the actionable signal, and upgrading is the fix. For the broader picture, see &lt;a href="https://www.guardlayer.io/blog/vulnerable-npm-dependencies" rel="noopener noreferrer"&gt;auditing vulnerable npm dependencies&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  If you can't upgrade immediately: strip the header at the edge
&lt;/h2&gt;

&lt;p&gt;The advisory's documented workaround is to &lt;strong&gt;prevent external requests carrying &lt;code&gt;x-middleware-subrequest&lt;/code&gt; from reaching your app&lt;/strong&gt;. Because the header is what &lt;em&gt;causes&lt;/em&gt; middleware to be skipped, the stripping must happen at a layer &lt;em&gt;in front of&lt;/em&gt; the app.&lt;/p&gt;

&lt;p&gt;nginx reverse proxy — clear any client-supplied value before forwarding upstream:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;location&lt;/span&gt; &lt;span class="n"&gt;/&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;x-middleware-subrequest&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;proxy_pass&lt;/span&gt; &lt;span class="s"&gt;http://nextjs_upstream&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Cloudflare / generic WAF — block the request outright:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rego"&gt;&lt;code&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;any&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;names&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="s2"&gt;"x-middleware-subrequest"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="c1"&gt;# Action: Block&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Caveats: this only protects paths that actually pass through that proxy — if your origin is reachable directly, the header sails through, so strip it at &lt;em&gt;every&lt;/em&gt; ingress. HTTP header names are case-insensitive, so match regardless of casing. And it's a stopgap, not a repair of the underlying trust bug. (Cloudflare shipped a managed WAF rule on March 22, 2025 that blocks this header — but it's &lt;strong&gt;opt-in, not on by default&lt;/strong&gt;, because auto-blocking broke sites whose third-party auth middleware legitimately relied on the header.)&lt;/p&gt;

&lt;h2&gt;
  
  
  The durable lesson: middleware is not your only authorization layer
&lt;/h2&gt;

&lt;p&gt;Vercel's postmortem says it directly: &lt;em&gt;"We do not recommend Middleware to be the sole method of protecting routes."&lt;/em&gt; Middleware is best for cheap, coarse redirects — bouncing an unauthenticated visitor toward a login page for nicer UX — &lt;strong&gt;not&lt;/strong&gt; the last line of defense. Re-check authorization at the layer that actually returns the data:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// app/api/admin/users/route.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;NextResponse&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;next/server&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;getSession&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@/lib/auth&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;GET&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;session&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;getSession&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;               &lt;span class="c1"&gt;// re-check HERE, not just in middleware&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;session&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;NextResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Unauthenticated&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;401&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;role&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;admin&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;NextResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Forbidden&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;403&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;NextResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;users&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findMany&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Do the same in server components (&lt;code&gt;redirect("/login")&lt;/code&gt; at render time) and in &lt;a href="https://www.guardlayer.io/blog/nextjs-server-action-auth-check" rel="noopener noreferrer"&gt;server actions, where auth must be verified inside the action itself&lt;/a&gt;. The strongest backstop is pushing authorization into a data-access layer and enabling database &lt;strong&gt;Row-Level Security&lt;/strong&gt;, so a request that skips every application-layer check still can't read rows it doesn't own. That layered posture — covered in the &lt;a href="https://www.guardlayer.io/blog/nextjs-app-layer-security" rel="noopener noreferrer"&gt;Next.js app-layer security guide&lt;/a&gt; — is what turns a single-layer bug like CVE-2025-29927 into a non-event.&lt;/p&gt;

&lt;h2&gt;
  
  
  Confirm you're patched
&lt;/h2&gt;

&lt;p&gt;Send the exploit header at a protected route and compare it to the same request without the header. On a patched build they must be identical — both denied:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-i&lt;/span&gt; https://your-app.example.com/admin &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"x-middleware-subrequest: middleware"&lt;/span&gt;
&lt;span class="c"&gt;# Patched: same 302/401/403 as a request WITHOUT the header&lt;/span&gt;
&lt;span class="c"&gt;# Vulnerable: returns the protected page (bypass succeeded)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If adding the header changes the outcome, you're still vulnerable.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I write these while building &lt;a href="https://www.guardlayer.io/" rel="noopener noreferrer"&gt;GuardLayer&lt;/a&gt;, a static scanner for Next.js + Supabase apps. &lt;a href="https://www.guardlayer.io/blog/nextjs-middleware-auth-bypass-cve-2025-29927" rel="noopener noreferrer"&gt;Originally published on the GuardLayer blog.&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>nextjs</category>
      <category>security</category>
      <category>webdev</category>
      <category>javascript</category>
    </item>
    <item>
      <title>Does Prisma respect Supabase RLS? No — here's why</title>
      <dc:creator>Emil Alander</dc:creator>
      <pubDate>Sun, 19 Jul 2026 18:39:22 +0000</pubDate>
      <link>https://dev.to/emil_alander_7c/does-prisma-respect-supabase-rls-no-heres-why-k53</link>
      <guid>https://dev.to/emil_alander_7c/does-prisma-respect-supabase-rls-no-heres-why-k53</guid>
      <description>&lt;p&gt;Prisma and Drizzle connect as the postgres role and bypass Supabase RLS entirely, so your policies never protect ORM queries. Here's the fix.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TL;DR: No. Prisma and Drizzle open their own direct Postgres connection and log in as the &lt;code&gt;postgres&lt;/code&gt; role, which owns your tables and carries &lt;code&gt;BYPASSRLS&lt;/code&gt; — so Row Level Security is skipped on every ORM query. Point your app's connection at a dedicated, non-owner &lt;code&gt;NOBYPASSRLS&lt;/code&gt; role (and keep the auth check in your code), not at &lt;code&gt;postgres&lt;/code&gt;.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If you built your Supabase project assuming RLS is a safety net on the data itself, adding an ORM quietly punches a hole straight through it. Your policies are still there. They just never run for the ORM's connection. Here's the mechanism, the myth to unlearn, and three fixes in order of how much you should reach for them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Does Prisma respect Supabase RLS?
&lt;/h2&gt;

&lt;p&gt;No. RLS is not a global property of the database — Postgres enforces it &lt;strong&gt;per role, per statement&lt;/strong&gt;. A policy only bites for a role that is (a) not the table's owner, (b) has no &lt;code&gt;BYPASSRLS&lt;/code&gt; attribute, and (c) is not a superuser. The Supabase JS client satisfies all three because it reaches Postgres through PostgREST, which runs your query as the unprivileged &lt;code&gt;anon&lt;/code&gt; or &lt;code&gt;authenticated&lt;/code&gt; role. Prisma and Drizzle satisfy none of them: they read &lt;code&gt;DATABASE_URL&lt;/code&gt; and open a raw SQL connection as &lt;code&gt;postgres&lt;/code&gt;, which owns virtually every table you migrated &lt;em&gt;and&lt;/em&gt; holds &lt;code&gt;BYPASSRLS&lt;/code&gt;. Either fact alone is enough for Postgres to skip your policies.&lt;/p&gt;

&lt;p&gt;So the same query that returns one tenant's rows through &lt;code&gt;supabase-js&lt;/code&gt; returns &lt;strong&gt;every tenant's rows&lt;/strong&gt; through Prisma. That is not a bug in your policy — it's the connection role.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two doors into the same database
&lt;/h2&gt;

&lt;p&gt;There are two completely different paths to your data, and they authenticate as different roles.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The &lt;code&gt;supabase-js&lt;/code&gt; path (RLS enforced).&lt;/strong&gt; &lt;code&gt;supabase-js&lt;/code&gt; talks HTTP to PostgREST, not to Postgres directly. PostgREST connects as &lt;code&gt;authenticator&lt;/code&gt;, validates the request JWT, and does a &lt;code&gt;SET ROLE&lt;/code&gt; into &lt;code&gt;anon&lt;/code&gt; or &lt;code&gt;authenticated&lt;/code&gt; for the statement. Those are deliberately unprivileged, non-owning roles, so policies get evaluated.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The ORM path (RLS not enforced).&lt;/strong&gt; Prisma and Drizzle are ordinary Postgres clients. They bypass PostgREST entirely and log in as &lt;code&gt;postgres&lt;/code&gt;, whether you connect direct on port 5432, through the session pooler on 5432, or through the transaction pooler on 6543. The port and the pooler are irrelevant — the pooler is just a proxy in front of Postgres. What determines RLS enforcement is the &lt;strong&gt;login role&lt;/strong&gt;, and by default that's &lt;code&gt;postgres&lt;/code&gt; for all three connection styles.&lt;/p&gt;

&lt;p&gt;If you want the ground-up model of how policies and roles interact, the &lt;a href="https://www.guardlayer.io/blog/supabase-row-level-security-complete-guide" rel="noopener noreferrer"&gt;complete guide to Supabase Row Level Security&lt;/a&gt; walks through it from scratch.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the postgres role skips RLS (it's not superuser)
&lt;/h2&gt;

&lt;p&gt;The common explanation — "because &lt;code&gt;postgres&lt;/code&gt; is a superuser" — is &lt;strong&gt;false on the Supabase managed platform&lt;/strong&gt;, and it's worth correcting because it points people at the wrong fix. Supabase withholds superuser from &lt;code&gt;postgres&lt;/code&gt;; the only true superuser is the internal &lt;code&gt;supabase_admin&lt;/code&gt; role. Operations that require superuser, like &lt;code&gt;COPY … FROM PROGRAM&lt;/code&gt;, are blocked for &lt;code&gt;postgres&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The bypass comes from two other Postgres rules, either of which is independently sufficient:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Table ownership.&lt;/strong&gt; When Supabase runs your migrations, the resulting tables in &lt;code&gt;public&lt;/code&gt; are owned by the &lt;code&gt;postgres&lt;/code&gt; role. Per the Postgres manual, a table's owner bypasses that table's RLS by default. Since the ORM connects as &lt;code&gt;postgres&lt;/code&gt;, it's the owner of nearly every app table.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The &lt;code&gt;BYPASSRLS&lt;/code&gt; attribute.&lt;/strong&gt; Supabase's role config also gives &lt;code&gt;postgres&lt;/code&gt; the &lt;code&gt;BYPASSRLS&lt;/code&gt; attribute. From the PostgreSQL manual's Row Security Policies section:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Superusers and roles with the BYPASSRLS attribute always bypass the row security system when accessing a table."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Note the word &lt;strong&gt;"always"&lt;/strong&gt; for &lt;code&gt;BYPASSRLS&lt;/code&gt; — no table-level setting overrides it. Table owners bypass too, though the manual notes an owner &lt;em&gt;can&lt;/em&gt; opt back in with &lt;code&gt;ALTER TABLE ... FORCE ROW LEVEL SECURITY&lt;/code&gt;. This is the same reason the &lt;code&gt;service_role&lt;/code&gt; key bypasses RLS: Supabase creates it as &lt;code&gt;create role service_role nologin noinherit bypassrls;&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The consequence developers get wrong
&lt;/h2&gt;

&lt;p&gt;RLS policies protect the &lt;strong&gt;PostgREST path only&lt;/strong&gt; — the &lt;code&gt;anon&lt;/code&gt;/&lt;code&gt;authenticated&lt;/code&gt; roles that &lt;code&gt;supabase-js&lt;/code&gt;, the Data API, and the auto-generated REST endpoints run as. They do nothing for a direct SQL connection as &lt;code&gt;postgres&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The dangerous mental model is "RLS is a global safety net on the data." It isn't. The moment you add Prisma or Drizzle with the default &lt;code&gt;DATABASE_URL&lt;/code&gt;, you've opened a second door that walks past every policy: every &lt;code&gt;SELECT&lt;/code&gt;/&lt;code&gt;INSERT&lt;/code&gt;/&lt;code&gt;UPDATE&lt;/code&gt;/&lt;code&gt;DELETE&lt;/code&gt; the ORM issues sees and mutates all rows for all tenants. Teams routinely ship an ORM-backed API next to a "secured by RLS" project and assume tenant isolation still holds. On the ORM path, it does not.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix 1: Use supabase-js for user-reachable data, ORM for trusted server work
&lt;/h2&gt;

&lt;p&gt;The simplest and most honest architecture is a hybrid, and it's what most production Supabase apps actually run:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;supabase-js&lt;/code&gt;&lt;/strong&gt; for auth, realtime, storage, and user-facing CRUD → RLS enforced for free.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Prisma/Drizzle&lt;/strong&gt; for complex relational queries, transactions, and admin/migration work → treated as &lt;strong&gt;trusted server-side&lt;/strong&gt; access where you enforce authorization &lt;em&gt;in your application code&lt;/em&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Be clear with yourself about what fix 1 is: it is &lt;strong&gt;not RLS&lt;/strong&gt;. It's "we checked auth in the handler." An ORM connected as &lt;code&gt;postgres&lt;/code&gt; with RLS merely &lt;code&gt;ENABLE&lt;/code&gt;d on the table gives you zero database-level protection, because the owner bypasses it. Which means the app-layer check is now the &lt;em&gt;only&lt;/em&gt; thing standing between a request and a write — more on that below.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix 2: Enforce RLS through the ORM with set_config + SET LOCAL ROLE
&lt;/h2&gt;

&lt;p&gt;If you genuinely want your existing policies to run on the ORM path, you have to reproduce what PostgREST does: per request, inside one transaction, set the role to a non-owner role and set the JWT claims, run the query, then reset.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Prisma&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;PrismaClient&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@prisma/client&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;withUserRls&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;T&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;prisma&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;PrismaClient&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;jwt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;role&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="nx"&gt;fn&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Prisma&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;TransactionClient&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;T&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;prisma&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;$transaction&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;role&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;jwt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;role&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;authenticated&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;authenticated&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;anon&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;$executeRawUnsafe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`SET LOCAL ROLE &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;role&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;$executeRaw&lt;/span&gt;&lt;span class="s2"&gt;`SELECT set_config('request.jwt.claims', &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;jwt&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;, true)`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;fn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three non-negotiables: the connection role must &lt;strong&gt;not&lt;/strong&gt; be the table owner and must &lt;strong&gt;not&lt;/strong&gt; have &lt;code&gt;BYPASSRLS&lt;/code&gt; (see fix 3); everything must be &lt;strong&gt;transaction-scoped&lt;/strong&gt; (a session-level &lt;code&gt;SET&lt;/code&gt; persists on a pooled connection and leaks into the next user's request); and you must set the role &lt;em&gt;and&lt;/em&gt; the claims — the popular Prisma RLS extension sets the claims but not the role, so &lt;code&gt;TO authenticated&lt;/code&gt; policies silently won't match.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix 3: A dedicated least-privileged role (and why FORCE alone isn't enough)
&lt;/h2&gt;

&lt;p&gt;The robust fix underneath fixes 1 and 2 is the same: &lt;strong&gt;stop connecting your app as &lt;code&gt;postgres&lt;/code&gt;.&lt;/strong&gt; Create a login role that owns nothing and has no &lt;code&gt;BYPASSRLS&lt;/code&gt;, grant it the Supabase request roles, then point the runtime &lt;code&gt;DATABASE_URL&lt;/code&gt; at it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="k"&gt;role&lt;/span&gt; &lt;span class="n"&gt;app_user&lt;/span&gt; &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;login&lt;/span&gt; &lt;span class="n"&gt;password&lt;/span&gt; &lt;span class="s1"&gt;'REPLACE_WITH_STRONG_SECRET'&lt;/span&gt;
  &lt;span class="n"&gt;noinherit&lt;/span&gt; &lt;span class="n"&gt;nobypassrls&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;grant&lt;/span&gt; &lt;span class="n"&gt;anon&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;authenticated&lt;/span&gt; &lt;span class="k"&gt;to&lt;/span&gt; &lt;span class="n"&gt;app_user&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;grant&lt;/span&gt; &lt;span class="k"&gt;usage&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="k"&gt;schema&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;to&lt;/span&gt; &lt;span class="n"&gt;app_user&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;grant&lt;/span&gt; &lt;span class="k"&gt;select&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;insert&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;update&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;delete&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="k"&gt;all&lt;/span&gt; &lt;span class="n"&gt;tables&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="k"&gt;schema&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;to&lt;/span&gt; &lt;span class="n"&gt;app_user&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;grant&lt;/span&gt; &lt;span class="k"&gt;usage&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="k"&gt;all&lt;/span&gt; &lt;span class="n"&gt;sequences&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="k"&gt;schema&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;to&lt;/span&gt; &lt;span class="n"&gt;app_user&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;alter&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="k"&gt;privileges&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="k"&gt;schema&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;
  &lt;span class="k"&gt;grant&lt;/span&gt; &lt;span class="k"&gt;select&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;insert&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;update&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;delete&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;tables&lt;/span&gt; &lt;span class="k"&gt;to&lt;/span&gt; &lt;span class="n"&gt;app_user&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Because &lt;code&gt;app_user&lt;/code&gt; is neither owner nor &lt;code&gt;BYPASSRLS&lt;/code&gt;, plain &lt;code&gt;ENABLE ROW LEVEL SECURITY&lt;/code&gt; is now genuinely enforced for it. Keep a &lt;em&gt;separate&lt;/em&gt; &lt;code&gt;postgres&lt;/code&gt; string for migrations only.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;ALTER TABLE … FORCE ROW LEVEL SECURITY&lt;/code&gt; subjects the &lt;strong&gt;table owner&lt;/strong&gt; to policies — but does &lt;strong&gt;nothing&lt;/strong&gt; to &lt;code&gt;BYPASSRLS&lt;/code&gt; roles. So if your ORM still connects as &lt;code&gt;postgres&lt;/code&gt;, &lt;code&gt;FORCE&lt;/code&gt; alone will not save you. The primary control is the connection role.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this actually bites: the app-layer auth check
&lt;/h2&gt;

&lt;p&gt;Once you accept fix 1 — the ORM is trusted, RLS is not your backstop on that path — the &lt;strong&gt;application-layer auth check becomes the only thing protecting a write.&lt;/strong&gt; Here's the kind of Server Action that quietly becomes a hole:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;use server&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;prisma&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@/lib/prisma&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;deleteDocument&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// No auth check. RLS won't save you here — Prisma&lt;/span&gt;
  &lt;span class="c1"&gt;// connects as `postgres` and bypasses every policy.&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;prisma&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;delete&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;where&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When the database has stopped being your safety net, a missing guard in the handler is a real, exploitable hole. Verify the user at the top of every action that mutates data — see &lt;a href="https://www.guardlayer.io/blog/nextjs-server-action-auth-check" rel="noopener noreferrer"&gt;the auth check every Server Action needs&lt;/a&gt;. And keep the direct connection string server-only and out of git (&lt;a href="https://www.guardlayer.io/blog/keeping-secrets-out-of-nextjs" rel="noopener noreferrer"&gt;keeping secrets out of your Next.js bundle&lt;/a&gt;).&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Write these while building &lt;a href="https://www.guardlayer.io/" rel="noopener noreferrer"&gt;GuardLayer&lt;/a&gt;, a static scanner for Next.js + Supabase apps. It can't see your DB connection role — but it does flag a Server Action doing a Prisma/Drizzle write with no auth check, exactly the gap this bypass creates. &lt;a href="https://www.guardlayer.io/blog/orm-bypasses-supabase-rls" rel="noopener noreferrer"&gt;Originally published on the GuardLayer blog.&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>backend</category>
      <category>database</category>
      <category>postgres</category>
      <category>security</category>
    </item>
  </channel>
</rss>
