diff --git a/apps/blog/content/blog/prisma-8-supabase-rls-and-auth/OneContractGraph.tsx b/apps/blog/content/blog/prisma-8-supabase-rls-and-auth/OneContractGraph.tsx
new file mode 100644
index 0000000000..e832974653
--- /dev/null
+++ b/apps/blog/content/blog/prisma-8-supabase-rls-and-auth/OneContractGraph.tsx
@@ -0,0 +1,45 @@
+import { highlightPrisma8 } from "./highlight-prisma8";
+
+const BEFORE = `supabase/migrations/
+├── 20250514_add_notes_policy.sql
+├── 20250602_update_notes_policy.sql
+└── 20250618_fix_notes_policy.sql
+
++ dashboard edits outside git`;
+
+const AFTER = `namespace public {
+ model Note {
+ userId Uuid
+ user supabase:auth.AuthUser @relation(fields: [userId], references: [id])
+
+ @@rls
+ }
+
+ policy_select note_owner_read {
+ roles = [authenticated]
+ using = "\\"userId\\"::uuid = auth.uid()"
+ }
+}`;
+
+export async function OneContractGraph() {
+ const after = await highlightPrisma8(AFTER);
+ return (
+
+
+
+ RLS with SQL migrations
+
+
{BEFORE}
+
+
+
+ RLS in the Prisma schema
+ {/* Highlighted with the same extended prisma grammar the MDX code
+ fences use; colors resolve through the --ch-N variables. */}
+
+
+
+
One file to edit, migrate, and review.
+
+ );
+}
diff --git a/apps/blog/content/blog/prisma-8-supabase-rls-and-auth/RlsFlowDemo.tsx b/apps/blog/content/blog/prisma-8-supabase-rls-and-auth/RlsFlowDemo.tsx
new file mode 100644
index 0000000000..e58e1eefb9
--- /dev/null
+++ b/apps/blog/content/blog/prisma-8-supabase-rls-and-auth/RlsFlowDemo.tsx
@@ -0,0 +1,61 @@
+import { highlight, type HighlightedCode } from "codehike/code";
+import { RlsFlowDemoClient } from "./RlsFlowDemoClient";
+import { highlightPrisma8 } from "./highlight-prisma8";
+
+const SNIPPETS: { value: string; lang: string }[] = [
+ {
+ lang: "typescript",
+ value: `app.get('/notes', async (c) => {
+ const auth = c.req.header('authorization');
+ const jwt = auth?.startsWith('Bearer ')
+ ? auth.slice(7)
+ : undefined;
+});`,
+ },
+ {
+ lang: "typescript",
+ value: `app.get('/notes', async (c) => {
+ const auth = c.req.header('authorization');
+ const jwt = auth?.startsWith('Bearer ')
+ ? auth.slice(7)
+ : undefined;
+
+ const db = await getDb();
+ const bound = await db.asUser(jwt);
+});`,
+ },
+ {
+ lang: "typescript",
+ value: `app.get('/notes', async (c) => {
+ const auth = c.req.header('authorization');
+ const jwt = auth?.startsWith('Bearer ')
+ ? auth.slice(7)
+ : undefined;
+
+ const db = await getDb();
+ const bound = await db.asUser(jwt);
+
+ const notes = await bound.orm.public.Note
+ .select('id', 'title', 'body')
+ .all()
+ .toArray();
+
+ return c.json({ notes });
+});`,
+ },
+];
+
+// The policy Postgres enforces in step 4, as it appears in the Prisma schema.
+const POLICY = `policy_select note_owner_read {
+ target = Note
+ roles = [authenticated]
+ using = "\\"userId\\"::uuid = auth.uid()"
+}`;
+
+export async function RlsFlowDemo() {
+ const highlighted = (await Promise.all(
+ SNIPPETS.map(({ value, lang }) => highlight({ value, lang, meta: "" }, "github-from-css")),
+ )) as HighlightedCode[];
+ const policyHtml = await highlightPrisma8(POLICY);
+ return ;
+}
diff --git a/apps/blog/content/blog/prisma-8-supabase-rls-and-auth/RlsFlowDemoClient.tsx b/apps/blog/content/blog/prisma-8-supabase-rls-and-auth/RlsFlowDemoClient.tsx
new file mode 100644
index 0000000000..59b1242b16
--- /dev/null
+++ b/apps/blog/content/blog/prisma-8-supabase-rls-and-auth/RlsFlowDemoClient.tsx
@@ -0,0 +1,225 @@
+"use client";
+
+import { Component, createRef, Fragment, useEffect, useRef, useState, type RefObject } from "react";
+import { Pre, type HighlightedCode } from "codehike/code";
+import {
+ calculateTransitions,
+ getStartingSnapshot,
+ type TokenTransitionsSnapshot,
+} from "codehike/utils/token-transitions";
+import { ChevronLeft, ChevronRight, Pause, Play } from "lucide-react";
+
+type Actor = "client" | "prisma" | "postgres";
+
+type Phase = {
+ step: number;
+ label: string;
+ shortLabel: string;
+ actor: Actor;
+ detail: string;
+};
+
+const STEP_HOLD_MS = 6500;
+
+const ACTORS: { id: Actor; label: string }[] = [
+ { id: "client", label: "Client" },
+ { id: "prisma", label: "Prisma" },
+ { id: "postgres", label: "Postgres" },
+];
+
+const PHASES: Phase[] = [
+ {
+ step: 0,
+ label: "The request carries a token",
+ shortLabel: "Request",
+ actor: "client",
+ detail:
+ "Every request arrives with the user's Supabase Auth session token, a signed JWT, in the Authorization header.",
+ },
+ {
+ step: 1,
+ label: "db.asUser(jwt)",
+ shortLabel: "Verify + bind",
+ actor: "prisma",
+ detail:
+ "asUser verifies the token's signature against your project's public signing keys, then binds the user's role and id to the database session. A forged or expired token never reaches Postgres.",
+ },
+ {
+ step: 2,
+ label: "Query with no user filter",
+ shortLabel: "Query",
+ actor: "prisma",
+ detail:
+ "The handler selects notes without a where userId clause. The client has no query methods until a role is bound, so this step cannot be skipped.",
+ },
+ {
+ step: 3,
+ label: "Postgres applies the policy",
+ shortLabel: "Enforce",
+ actor: "postgres",
+ detail:
+ "Postgres evaluates the select policy for every row and returns only those where userId matches the token's auth.uid(). This is the policy_select block from the schema, which Prisma migrated as CREATE POLICY.",
+ },
+];
+
+class SmoothPre extends Component<{ code: HighlightedCode }> {
+ preRef: RefObject = createRef();
+
+ getSnapshotBeforeUpdate() {
+ if (!this.preRef.current) return null;
+ return getStartingSnapshot(this.preRef.current);
+ }
+
+ componentDidUpdate(
+ _prev: { code: HighlightedCode },
+ _ps: unknown,
+ snap: TokenTransitionsSnapshot | null,
+ ) {
+ if (!this.preRef.current || !snap) return;
+ const transitions = calculateTransitions(this.preRef.current, snap);
+ transitions.forEach(({ element, keyframes, options }) => {
+ element.animate(keyframes, {
+ duration: options.duration * 1000,
+ delay: options.delay * 1000,
+ easing: options.easing,
+ fill: options.fill,
+ });
+ });
+ }
+
+ render() {
+ return ;
+ }
+}
+
+type Props = {
+ snippets: HighlightedCode[];
+ /** The schema policy block, pre-highlighted, shown on the Enforce step. */
+ policyHtml: string;
+};
+
+export function RlsFlowDemoClient({ snippets, policyHtml }: Props) {
+ const [phaseIndex, setPhaseIndex] = useState(0);
+ const [playing, setPlaying] = useState(true);
+ const [inView, setInView] = useState(false);
+ const containerRef = useRef(null);
+
+ useEffect(() => {
+ const el = containerRef.current;
+ if (!el || typeof IntersectionObserver === "undefined") {
+ setInView(true);
+ return;
+ }
+ const obs = new IntersectionObserver(([entry]) => setInView(entry.isIntersecting), {
+ threshold: 0.25,
+ });
+ obs.observe(el);
+ return () => obs.disconnect();
+ }, []);
+
+ useEffect(() => {
+ if (!playing || !inView) return;
+ const id = setInterval(() => {
+ setPhaseIndex((i) => (i + 1) % PHASES.length);
+ }, STEP_HOLD_MS);
+ return () => clearInterval(id);
+ }, [playing, inView]);
+
+ const phase = PHASES[phaseIndex];
+ const code = snippets[phaseIndex] as HighlightedCode | undefined;
+
+ function goTo(index: number) {
+ setPlaying(false);
+ setPhaseIndex(((index % PHASES.length) + PHASES.length) % PHASES.length);
+ }
+
+ return (
+
+ {code ? (
+
+ ) : (
+ // The Enforce step shows the policy block from the schema, highlighted
+ // with the Prisma 8 grammar, rather than another codehike snippet.
+
+ )}
+
+ No where clause in application code. The policy is the filter.
+
+ )}
+
+
+
+ );
+}
diff --git a/apps/blog/content/blog/prisma-8-supabase-rls-and-auth/highlight-prisma8.ts b/apps/blog/content/blog/prisma-8-supabase-rls-and-auth/highlight-prisma8.ts
new file mode 100644
index 0000000000..2e3649febd
--- /dev/null
+++ b/apps/blog/content/blog/prisma-8-supabase-rls-and-auth/highlight-prisma8.ts
@@ -0,0 +1,44 @@
+import { createHighlighter, type Highlighter, type ThemeRegistration } from "shiki";
+import { prisma8Language } from "@prisma-docs/ui/lib/prisma8-language";
+
+// Maps the GitHub scopes to the same --ch-N variables codehike's
+// github-from-css theme uses, so shiki output follows light/dark mode
+// exactly like the codehike-rendered snippets elsewhere on the page.
+const chCssTheme: ThemeRegistration = {
+ name: "ch-css",
+ type: "dark",
+ fg: "var(--ch-4)",
+ bg: "transparent",
+ settings: [
+ { settings: { foreground: "var(--ch-4)" } },
+ {
+ scope: ["comment", "punctuation.definition.comment"],
+ settings: { foreground: "var(--ch-1)" },
+ },
+ {
+ scope: ["keyword", "keyword.operator", "storage.type", "storage.modifier"],
+ settings: { foreground: "var(--ch-7)" },
+ },
+ { scope: ["entity.name", "entity.name.function"], settings: { foreground: "var(--ch-5)" } },
+ {
+ scope: ["support", "support.type", "variable.language", "constant.language"],
+ settings: { foreground: "var(--ch-2)" },
+ },
+ { scope: ["string", "punctuation.definition.string"], settings: { foreground: "var(--ch-8)" } },
+ {
+ scope: ["variable.parameter", "variable.other.property"],
+ settings: { foreground: "var(--ch-3)" },
+ },
+ ],
+};
+
+let highlighterPromise: Promise | undefined;
+
+export async function highlightPrisma8(code: string): Promise {
+ highlighterPromise ??= createHighlighter({
+ themes: [chCssTheme],
+ langs: [prisma8Language as never],
+ });
+ const highlighter = await highlighterPromise;
+ return highlighter.codeToHtml(code, { lang: "prisma", theme: "ch-css" });
+}
diff --git a/apps/blog/content/blog/prisma-8-supabase-rls-and-auth/index.mdx b/apps/blog/content/blog/prisma-8-supabase-rls-and-auth/index.mdx
new file mode 100644
index 0000000000..5827aa1349
--- /dev/null
+++ b/apps/blog/content/blog/prisma-8-supabase-rls-and-auth/index.mdx
@@ -0,0 +1,290 @@
+---
+title: "Declare Supabase RLS in your Prisma schema"
+slug: "prisma-8-supabase-rls-and-auth"
+date: "2026-08-28"
+authors:
+ - "Will Madden"
+ - "Ankur Datta"
+metaTitle: "Supabase RLS in your Prisma schema | Prisma 8"
+metaDescription: "Prisma 8 puts your Supabase tables, auth.users foreign keys, and Row Level Security policies in one schema file. Prisma writes the migration. Postgres enforces every rule."
+metaImagePath: "/prisma-8-supabase-rls-and-auth/imgs/meta.png"
+heroImagePath: "/prisma-8-supabase-rls-and-auth/imgs/hero.svg"
+heroImageAlt: "Supabase RLS policies and auth.users, declared in a Prisma schema"
+tags:
+ - "orm"
+ - "announcement"
+---
+
+import { RlsFlowDemo } from "./RlsFlowDemo";
+import { OneContractGraph } from "./OneContractGraph";
+
+Your Supabase authorization model can be one file.
+
+Prisma is a TypeScript ORM. You describe your tables in a schema file, and Prisma generates the migrations and a typed query client from it. [Prisma 8](https://www.prisma.io/docs/orm) adds a Supabase extension that brings the two parts an ORM on Supabase left to hand-written SQL into that same file: the foreign keys into `auth.users` and your Row Level Security policies.
+
+What you get:
+
+- **One file for the whole model.** Tables, `auth.users` relations, and RLS policies live next to each other. Prisma writes the migration, policies included, and Postgres enforces the rules.
+- **Queries with no user filter.** `db.asUser(jwt)` runs every query as the signed-in user, and the policy does the filtering. The privilege level is on the same line as the query.
+- **Your existing policies read back.** `contract infer` reads a live Supabase database, policies included, and writes the schema for you.
+
+Here is the entire authorization model for a notes app:
+
+```prisma
+namespace public {
+ model Note {
+ id Uuid @id @default(uuid())
+ title String
+ userId Uuid
+ user supabase:auth.AuthUser @relation(fields: [userId], references: [id], onDelete: Cascade)
+
+ @@rls
+ }
+
+ policy_select note_owner_read {
+ target = Note
+ roles = [authenticated]
+ using = "\"userId\"::uuid = auth.uid()"
+ }
+}
+```
+
+Three lines in this file are new to Prisma:
+
+1. `user supabase:auth.AuthUser` is a real foreign key into Supabase's `auth.users` table. Prisma knows the shape of the tables Supabase owns and does not generate DDL for them.
+2. `@@rls` turns on Row Level Security, so no role sees a row unless a policy grants it.
+3. `policy_select` is one rule: the operation, the roles it applies to, and the same `USING` expression you would write in SQL.
+
+Three commands take it live:
+
+```npm
+npx prisma@latest contract emit
+npx prisma@latest migration plan --name init
+npx prisma@latest db migrate --advance-ref db
+```
+
+The table, the foreign key into `auth.users`, RLS, and the policy are now in your database. In your server code, the query has no filter:
+
+```ts
+const db = await getDb();
+const asUser = await db.asUser(jwt);
+
+const notes = await asUser.orm.public.Note
+ .select('id', 'title')
+ .all()
+ .toArray();
+```
+
+We ran the full four-policy version of this schema against a fresh Supabase project on `prisma@latest` (8.0.0-rc.13) while writing this post. Two users, one client, no filters:
+
+```text
+alice sees only her rows: true
+cross-user update touches 0 rows: true
+forged insert rejected: true
+anon sees nothing: true
+service_role sees all: true
+forged JWT rejected: SUPABASE.JWT_INVALID
+```
+
+Row Level Security is one of the most-requested features in Prisma's history. [The issue](https://github.com/prisma/orm/issues/12735) has been open since April 2022 and has over 400 reactions. Prisma 8 is available today as a release candidate. The rest of this post shows what changes if you build on Supabase today, and how the pieces work.
+
+## If you use supabase-js today
+
+Supabase's model is right: the rules live in Postgres, and RLS enforces them. The problems below sit between your code and Postgres, in which key built the client, where the policies live, where the types come from, and what the query builder can express. Each one has a public thread, and we link the ones with the most reactions.
+
+### 1. The query does not show which key built the client
+
+This is how a server route scopes notes to the signed-in user today:
+
+```ts
+const supabase = createClient(url, key);
+
+const { data: notes } = await supabase
+ .from('notes')
+ .select('id, title')
+ .eq('user_id', user.id);
+```
+
+Whether `.eq('user_id', user.id)` matters depends on `key`: with the anon key and a good RLS policy it is redundant, and with the service role key it is the only thing between one user and everyone's notes:
+
+```ts
+const supabase = createClient(url, process.env.SUPABASE_SERVICE_ROLE_KEY);
+
+const { data: notes } = await supabase.from('notes').select('id, title');
+// every note in the table, for every user
+```
+
+Nothing in the query tells a reviewer which key built the client, because the key is set in one file and the query lives in another. This is common enough that Supabase has an official troubleshooting thread for the reverse case, [a service role client that suddenly obeys RLS](https://github.com/orgs/supabase/discussions/30146) because a user session cookie was attached to it. A table left reachable with the anon key and no policy is what the `rls_disabled_in_public` [security advisor](https://supabase.com/docs/guides/observability/advisors?lint=0013_rls_disabled_in_public) catches after the fact, and developers have asked for [protection before the fact](https://github.com/orgs/supabase/discussions/11538) since 2023.
+
+**With Prisma 8**, the client has no query methods until you pick a role: `db.orm` does not exist and using it is a type error, while `db.asUser(jwt).orm`, `db.asAnon().orm`, and `db.asServiceRole().orm` do. The privilege level is in the call, on the same line as the query, where a reviewer can see it.
+
+### 2. Policies are SQL you write, apply, and diff by hand
+
+The policy that makes the anon-key path safe looks like this in a Supabase migration:
+
+```sql
+alter table public.notes enable row level security;
+
+create policy "notes_owner_read" on public.notes
+ for select to authenticated
+ using (user_id = auth.uid());
+
+alter table public.notes
+ add constraint notes_user_id_fkey
+ foreign key (user_id) references auth.users (id) on delete cascade;
+```
+
+That is fine on day one. Six months later the effective policy on `notes` is the sum of every migration that ever touched it, plus whatever someone changed in the dashboard. To answer "who can read `notes` right now?" you read all of them.
+
+The Supabase CLI tracker shows the drift: `db diff` and `db pull` have [missed RLS policies on views](https://github.com/supabase/cli/issues/2264) ("I always have to manually add the RLS part when pushing or pulling migrations"), [storage policies created in the dashboard](https://github.com/supabase/cli/issues/1840) did not come back with `db pull`, and developers have asked for a command that [lists the policies on a table](https://github.com/supabase/cli/issues/2803). Supabase's own [declarative schema work](https://github.com/orgs/supabase/discussions/44938) responds to the same problem, but it is still SQL in a directory separate from the code that queries the tables.
+
+**With Prisma 8**, each policy is a block next to the model it protects. Changing one is a schema diff in a pull request, and Prisma writes the DDL. A project that already has policies gets them back with `contract infer`.
+
+### 3. Types come from a separate generator, and the query is a string
+
+After every migration you run `supabase gen types typescript` and commit the output. Forget once and `notes.user_id` is a string in one file and a UUID column in another. Teams that add a CI check for stale types have hit generated output that [changes between runs](https://github.com/supabase/postgres-meta/issues/959) with no schema change.
+
+The larger cost is how those types get applied. `.select('id, title, author(*)')` is a string, and supabase-js parses it at the type level to work out the result shape. That is a lot of type-level work, and the tracker shows where it gives way:
+
+- `Json` columns raised ["type instantiation is excessively deep"](https://github.com/supabase/supabase-js/issues/808) from July 2023 until the issue, with 36 reactions and no fix, was closed for inactivity in April 2026.
+- A to-one join was [typed as an array](https://github.com/supabase/postgrest-js/issues/408) for 20 months, and six weeks after the issue closed a comment reported the same result for a table with two foreign keys into `profiles`.
+- `insert()`, `update()`, and `upsert()` accepted [values the column would reject](https://github.com/supabase/supabase-js/issues/1636), and the older [insert type-safety issue](https://github.com/supabase/supabase-js/issues/1655) stayed open from November 2023 to July 2026.
+- Ordering by a column on a related table has been [an open request](https://github.com/supabase/supabase-js/issues/971) since February 2024.
+
+**With Prisma 8**, the relation is declared in the schema, so the client already knows that `author` is one row and `posts` is many: `.include('author')` returns an object, `.create()` accepts only what the model allows, and ordering a relation is `.include('posts', (p) => p.orderBy((f) => f.createdAt.desc()))`. The types come out of `contract emit`, from the same file that produced the migration, so there is no second generator to forget and no select string to parse.
+
+### 4. There are no transactions
+
+supabase-js talks to Postgres through PostgREST, one HTTP request per query, so two inserts are two requests and if the second fails the first stays. [Client-side database transactions](https://github.com/orgs/supabase/discussions/526) has been the request since January 2021 and has 356 upvotes, and the tracker issue, [long-running transactions at the client side](https://github.com/supabase/postgrest-js/issues/219), has 95 reactions and is still open. Supabase's most recent reply there, from April 2025, points to Postgres functions called through `.rpc()`, which moves the logic into SQL, outside your TypeScript and outside the generated types.
+
+**With Prisma 8**, the role-bound client has `transaction()`, and the policies apply inside it as they do outside:
+
+```ts
+const asUser = await db.asUser(jwt);
+
+await asUser.transaction(async (tx) => {
+ await tx.orm.public.Note.create({ title: 'Part 1', body: null, userId });
+ await tx.orm.public.Note.create({ title: 'Part 2', body: null, userId });
+});
+// Both rows exist, or neither does.
+```
+
+### 5. Values come back as strings, and errors come back as values
+
+PostgREST returns JSON, so a `timestamptz` arrives as a string and an `int8` arrives as a JavaScript number that is rounded once it passes 2^53. Returning dates as `Date` was [closed as completed in 2021](https://github.com/supabase/postgrest-js/issues/201), yet a comment from September 2025 still asks for a workaround and the follow-up [request](https://github.com/supabase/supabase-js/issues/1650) was closed for inactivity in May 2026. Converting large integers to `BigInt` [has been open since 2022](https://github.com/supabase/postgrest-js/issues/319).
+
+Errors take the same path: every query resolves to `{ data, error }`, a decision from [2020](https://github.com/supabase/supabase-js/issues/32), and while `throwOnError()` exists, its result type [kept `null`](https://github.com/supabase/supabase-js/issues/801) for 20 months and a comment after the fix reports the same with `.then()`.
+
+**With Prisma 8**, a `timestamptz` column decodes to a `Date`, a `BigInt` column to a `bigint`, and an integer outside JavaScript's safe range raises `RUNTIME.DECODE_FAILED` instead of being rounded. A failed query throws a structured error with a code, and a successful one returns the rows.
+
+### Side by side
+
+| | supabase-js today | Prisma 8 with the Supabase extension |
+| --------------------------- | ------------------------------------------------------- | ----------------------------------------------------------------- |
+| Where policies live | SQL migrations, plus the dashboard | `policy_*` blocks next to the model |
+| Who picks the privilege | The key passed to `createClient`, often in another file | `asUser(jwt)`, `asAnon()`, or `asServiceRole()` on the query line |
+| Foreign key to `auth.users` | Hand-written `alter table` | `user supabase:auth.AuthUser` |
+| Types | `supabase gen types`, then a parsed select string | `contract emit`, from the same file as the migration |
+| Transactions | A Postgres function called through `.rpc()` | `asUser(jwt).transaction()` |
+| Order by a related table | Open request since 2024 | `.include('posts', (p) => p.orderBy(...))` |
+| Dates and 64-bit integers | Strings and rounded numbers | `Date` and `bigint` |
+| Errors | `{ data, error }` on every query | Thrown, with a code |
+| Existing project | Keep the SQL | `contract infer` reads policies back |
+| Runs in | Browser, server, Edge Functions | Node.js and Bun servers |
+
+## How it works
+
+### The privilege level is in the call
+
+`asUser()` verifies the JWT against your project's public signing keys, then runs every query that follows as that user. The token is the access token from a Supabase Auth session, the same one `supabase-js` holds in the browser. A forged or expired token is rejected before any query runs, and once a query does run, the select policy is the filter and Postgres applies it.
+
+
+
+### Writes are checked by the database
+
+```ts
+await asUser.orm.public.Note.select('id').create({ title, body: null, userId: someoneElse });
+// Error: new row violates row-level security policy for table "note"
+```
+
+Postgres raised that error before the row was written, so there is no `if` statement in the application to get wrong.
+
+The check script that produced the six lines above is 50 lines and lives in the [extension guide](https://www.prisma.io/docs/orm/extensions/supabase#10-check-that-your-policies-work). Run it against your own project.
+
+### Policy changes are migrations
+
+Say you add a `published` flag and let anyone read published notes. Two additions to the schema:
+
+```prisma
+model Note {
+ // ...
+ published Boolean @default(false)
+}
+
+policy_select note_public_read {
+ target = Note
+ roles = [anon, authenticated]
+ using = "published = true"
+}
+```
+
+Then the same three commands as before:
+
+```npm
+npx prisma@latest contract emit
+npx prisma@latest migration plan --name public-notes
+npx prisma@latest db migrate --advance-ref db
+```
+
+Prisma diffs the schema against the last applied contract and writes only the delta, policies included:
+
+```sql
+ALTER TABLE "public"."note" ADD COLUMN "published" bool DEFAULT false NOT NULL;
+CREATE POLICY "note_public_read_443ba5fa" ON "public"."note"
+ AS PERMISSIVE FOR SELECT TO anon, authenticated
+ USING (published = true);
+```
+
+The policy change shows up in the pull request next to the column it depends on. Removing a policy is a migration in the same way.
+
+### It reads back, too
+
+Already have policies? `npx prisma@latest contract infer` reads the live database, including RLS policies and the foreign keys into `auth.users`, and writes the Prisma schema for you. You keep the authorization model you have and move it into one file.
+
+
+
+## What stays in supabase-js
+
+Auth flows in the browser, Storage, Realtime, and anything that runs inside Supabase Edge Functions. Prisma talks to Postgres directly over your project's session pooler from Node.js or Bun, and runs next to `supabase-js`. Sign the user in with `supabase-js`, and pass the session's access token to `db.asUser()` on the server.
+
+Three boundaries apply today: the `auth.users` relation is a constraint you cannot `include`, the client runs on Node.js and Bun only, and policy expressions are strings that Prisma migrates and reads back but does not type-check. The [extension guide](https://www.prisma.io/docs/orm/extensions/supabase#current-limitations) has the full list.
+
+## Try it in ten minutes
+
+:::note[Release candidate]
+Prisma 8 is available today as a release candidate. The API is stable, and we are collecting feedback before general availability. [Tell us what you find in Discord](https://pris.ly/discord).
+:::
+
+Create a free Supabase project, then:
+
+```npm
+npx prisma@latest orm init --yes --target postgres --authoring psl
+npm install @prisma/orm-extension-supabase
+```
+
+1. Add `extensions: [supabasePack]` to the ORM config in `prisma.config.ts`.
+2. Set `DATABASE_URL` to your project's session pooler connection string.
+3. Paste the schema above into `src/prisma/contract.prisma`.
+4. Run:
+
+```npm
+npx prisma@latest contract emit
+npx prisma@latest migration plan --name init
+npx prisma@latest db migrate --advance-ref db
+```
+
+The [step-by-step guide](https://www.prisma.io/docs/orm/extensions/supabase) covers the client, querying as each role, and the check script.
+
+{/* TODO before publish: add the public example repo link */}
+
+Your authorization model is now a file in your repo, and Postgres enforces it.
diff --git a/apps/blog/package.json b/apps/blog/package.json
index dd45776298..a799cfc53d 100644
--- a/apps/blog/package.json
+++ b/apps/blog/package.json
@@ -46,6 +46,7 @@
"babel-plugin-react-compiler": "catalog:",
"next-validate-link": "catalog:",
"postcss": "catalog:",
+ "shiki": "^4.0.2",
"tailwindcss": "catalog:",
"tsx": "catalog:",
"typescript": "catalog:"
diff --git a/apps/blog/public/prisma-8-supabase-rls-and-auth/imgs/hero.svg b/apps/blog/public/prisma-8-supabase-rls-and-auth/imgs/hero.svg
new file mode 100644
index 0000000000..1580683680
--- /dev/null
+++ b/apps/blog/public/prisma-8-supabase-rls-and-auth/imgs/hero.svg
@@ -0,0 +1,106 @@
+
diff --git a/apps/blog/public/prisma-8-supabase-rls-and-auth/imgs/meta.png b/apps/blog/public/prisma-8-supabase-rls-and-auth/imgs/meta.png
new file mode 100644
index 0000000000..05b084cc8d
Binary files /dev/null and b/apps/blog/public/prisma-8-supabase-rls-and-auth/imgs/meta.png differ
diff --git a/apps/blog/source.config.ts b/apps/blog/source.config.ts
index 678f936afc..726683f355 100644
--- a/apps/blog/source.config.ts
+++ b/apps/blog/source.config.ts
@@ -1,5 +1,6 @@
import remarkDirective from "remark-directive";
import {
+ rehypeCodeDefaultOptions,
remarkDirectiveAdmonition,
remarkMdxFiles,
remarkMdxMermaid,
@@ -7,6 +8,8 @@ import {
import { remarkImage } from "fumadocs-core/mdx-plugins";
import { defineCollections, defineConfig, frontmatterSchema } from "fumadocs-mdx/config";
import lastModified from "fumadocs-mdx/plugins/last-modified";
+import { bundledLanguages, type BundledLanguage, type LanguageRegistration } from "shiki";
+import { prisma8Language } from "@prisma-docs/ui/lib/prisma8-language";
import { z } from "zod";
import convert from "npm-to-yarn";
import { rehypeCodeOptions } from "@prisma-docs/ui/mdx/rehype-code-options";
@@ -67,6 +70,16 @@ export default defineConfig({
remarkMdxFiles,
remarkMdxMermaid,
],
+ rehypeCodeOptions: {
+ ...rehypeCodeDefaultOptions,
+ // Passing langs replaces the default set (all bundled languages), so
+ // rebuild it with the bundled prisma grammar swapped for the extended
+ // one that knows Prisma 8 namespace and policy blocks.
+ langs: [
+ ...(Object.keys(bundledLanguages) as BundledLanguage[]).filter((lang) => lang !== "prisma"),
+ prisma8Language as unknown as LanguageRegistration,
+ ],
+ },
remarkCodeTabOptions: { parseMdx: true },
remarkNpmOptions: {
persist: { id: "package-manager" },
diff --git a/apps/blog/src/app/global.css b/apps/blog/src/app/global.css
index 1fb62c0489..27dad7ef25 100644
--- a/apps/blog/src/app/global.css
+++ b/apps/blog/src/app/global.css
@@ -2163,3 +2163,189 @@ g[data-top="true"] .vector-demo-point {
padding-top: 10px;
margin-top: auto;
}
+
+/* RLS flow walkthrough (prisma-8-supabase-rls-and-auth) */
+
+/* The RLS demo's code lines run to ~45 columns, so give the code column the
+ wider share, and let anything longer scroll instead of overlapping the captions. */
+.rls-flow .bloom-demo-body {
+ grid-template-columns: minmax(0, 1.25fr) minmax(0, 1fr);
+}
+
+.rls-flow .bloom-demo-code {
+ min-width: 0;
+ overflow-x: auto;
+}
+
+@media (max-width: 720px) {
+ .rls-flow .bloom-demo-body {
+ grid-template-columns: minmax(0, 1fr);
+ }
+}
+
+.rls-flow-policy pre {
+ margin: 0;
+ padding: 0 18px;
+ background: transparent !important;
+ font: inherit;
+ white-space: pre;
+}
+
+.rls-flow-captions {
+ display: flex;
+ flex-direction: column;
+ gap: 14px;
+ padding: 18px;
+}
+
+.rls-flow-caption {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+}
+
+.rls-flow-caption p {
+ margin: 0;
+ font-size: 0.875rem;
+ line-height: 1.55;
+ color: var(--color-foreground-neutral);
+}
+
+.rls-flow-rail {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ font-family: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
+ font-size: 0.6875rem;
+ letter-spacing: 0.04em;
+ text-transform: uppercase;
+}
+
+.rls-flow-rail-node {
+ padding: 3px 10px;
+ border-radius: 999px;
+ border: 1px solid var(--color-stroke-neutral);
+ color: var(--color-foreground-muted);
+ transition:
+ background 200ms ease,
+ border-color 200ms ease,
+ color 200ms ease;
+}
+
+.rls-flow-rail-node[data-active="true"] {
+ background: color-mix(in srgb, #5fb878 18%, transparent);
+ border-color: #5fb878;
+ color: var(--color-foreground-neutral);
+}
+
+.rls-flow-rail-arrow {
+ flex: 0 0 14px;
+ height: 1px;
+ background: var(--color-stroke-neutral);
+ position: relative;
+}
+
+.rls-flow-rail-arrow::after {
+ content: "";
+ position: absolute;
+ right: 0;
+ top: -2.5px;
+ border-left: 5px solid var(--color-stroke-neutral);
+ border-top: 3px solid transparent;
+ border-bottom: 3px solid transparent;
+}
+
+.rls-flow-footer {
+ margin-top: auto;
+ padding: 8px 12px;
+ border-radius: 8px;
+ border: 1px solid var(--color-stroke-neutral);
+ background: color-mix(in srgb, var(--color-foreground-ppg) 6%, transparent);
+ font-size: 0.8125rem;
+ color: var(--color-foreground-muted);
+}
+
+.rls-flow-footer code {
+ font-size: 0.75rem;
+}
+
+/* One-contract before/after graph (prisma-8-supabase-rls-and-auth) */
+
+.contract-graph {
+ margin: 1.75rem 0;
+ border: 1px solid var(--color-stroke-neutral);
+ border-radius: 10px;
+ background: var(--color-background-default);
+ overflow: hidden;
+}
+
+.contract-graph-cols {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
+}
+
+@media (max-width: 720px) {
+ .contract-graph-cols {
+ grid-template-columns: minmax(0, 1fr);
+ }
+}
+
+.contract-graph-col {
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+ padding: 16px 18px;
+}
+
+.contract-graph-col + .contract-graph-col {
+ border-left: 1px solid var(--color-stroke-neutral);
+}
+
+@media (max-width: 720px) {
+ .contract-graph-col + .contract-graph-col {
+ border-left: none;
+ border-top: 1px solid var(--color-stroke-neutral);
+ }
+}
+
+.contract-graph-tag {
+ font-family: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
+ font-size: 0.6875rem;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ color: var(--color-foreground-muted);
+}
+
+.contract-graph-col[data-after="true"] .contract-graph-tag {
+ color: #5fb878;
+}
+
+.contract-graph-card {
+ border: 1px solid var(--color-stroke-neutral);
+ border-radius: 8px;
+ padding: 12px 14px;
+ background: var(--color-background-subtle, transparent);
+ overflow-x: auto;
+}
+
+.contract-graph-col[data-after="true"] .contract-graph-card {
+ border-color: color-mix(in srgb, #5fb878 45%, var(--color-stroke-neutral));
+}
+
+.contract-graph-card pre {
+ margin: 0;
+ background: transparent !important;
+ font: normal 400 0.8125rem/1.7 var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
+ white-space: pre;
+}
+
+.contract-graph-plain {
+ color: var(--color-foreground-muted);
+}
+
+.contract-graph-footer {
+ padding: 10px 18px;
+ border-top: 1px solid var(--color-stroke-neutral);
+ font-size: 0.8125rem;
+ color: var(--color-foreground-muted);
+}
diff --git a/apps/docs/content/docs/orm/extensions/meta.json b/apps/docs/content/docs/orm/extensions/meta.json
index 31c9289d0a..e555093298 100644
--- a/apps/docs/content/docs/orm/extensions/meta.json
+++ b/apps/docs/content/docs/orm/extensions/meta.json
@@ -1,4 +1,4 @@
{
"title": "Extensions",
- "pages": ["index", "using-extensions"]
+ "pages": ["index", "using-extensions", "supabase"]
}
diff --git a/apps/docs/content/docs/orm/extensions/supabase.mdx b/apps/docs/content/docs/orm/extensions/supabase.mdx
new file mode 100644
index 0000000000..89d7d15010
--- /dev/null
+++ b/apps/docs/content/docs/orm/extensions/supabase.mdx
@@ -0,0 +1,437 @@
+---
+title: Supabase
+description: 'Learn step-by-step how to use Prisma 8 with Supabase: reference auth.users from your schema and manage Row Level Security policies with Prisma migrations.'
+url: /orm/extensions/supabase
+metaTitle: How to use Prisma 8 with Supabase
+metaDescription: 'Set up Prisma 8 with a Supabase project, declare RLS policies in your Prisma schema, reference the auth.users table with a real foreign key, and query as your users.'
+badge: early-access
+---
+
+Supabase gives you Postgres with authentication and Row Level Security built in. Until now, using an ORM on Supabase meant writing the RLS policies and the foreign keys into `auth.users` as SQL migrations by hand. With the Supabase extension for Prisma 8, you declare both in your Prisma schema, and Prisma migrates them like any other schema change.
+
+In this guide you build the data layer for a notes app. Every note belongs to a Supabase user, and each user can only reach their own notes. Ten steps, about fifteen minutes.
+
+:::note[Release candidate]
+
+Prisma 8 ships today as a release candidate of the `prisma` CLI and the `@prisma/orm-*` packages. The API surface is stable, and we are collecting feedback before general availability. Try it with your Supabase project and [share what you find in Discord](https://pris.ly/discord).
+
+:::
+
+#### What you'll learn:
+
+- How to set up a Prisma 8 project and connect it to Supabase.
+- How to reference the `auth.users` table with a real foreign key.
+- How to write RLS policies in your schema and apply them with a migration.
+- How to run queries as a signed-in user, an anonymous visitor, or an admin.
+- How to check that your policies actually protect your data.
+
+### Prerequisites
+
+- [Node.js 22.18+](https://nodejs.org/)
+- A [Supabase project](https://supabase.com/dashboard) (the free tier works)
+
+## 1. Collect your Supabase connection details
+
+You need three values from your [Supabase dashboard](https://supabase.com/dashboard):
+
+- Your **session pooler connection string**: open your project, click **Connect** at the top, and copy the string under **Session pooler**.
+- Your **project URL**: found under **Project Settings** in the **API** section, it looks like `https://YOUR_PROJECT_REF.supabase.co`.
+- Your **database password**: the one you chose when creating the project.
+
+:::info
+
+Use the session pooler (port `5432`), not the transaction pooler (port `6543`). Prisma sets the signed-in user's role on the database session, and only the session pooler keeps that session intact. The direct connection host (`db.YOUR_PROJECT_REF.supabase.co`) also works, but only on IPv6 networks. If it fails with a DNS error, use the pooler.
+
+:::
+
+## 2. Set up a Prisma 8 project
+
+Create a project directory and scaffold a Prisma 8 project inside it:
+
+```npm
+npx prisma@latest orm init --yes --target postgres --authoring psl
+```
+
+This installs `@prisma/orm-postgres` and the Prisma 8 CLI, then creates several files:
+
+- `prisma.config.ts`: the configuration file.
+- `src/prisma/contract.prisma`: your Prisma schema. Prisma 8 calls this your **contract**, because it describes everything Prisma manages in your database.
+- `src/prisma/db.ts`: the database client. You will replace it with a Supabase-aware version in step 8.
+- `.env.example`: a template for your environment variables.
+
+If the directory has no `package.json`, the scaffold creates one with `"type": "module"`. It also runs the first `contract emit` for you, so the generated types exist from the start. The scaffold adds `prisma` as a dev dependency; this guide keeps using `npx prisma@latest` so every command runs the current release candidate.
+
+:::warning
+
+If your existing `package.json` contains `"type": "commonjs"`, change it to `"module"`. The scaffolded files use modern ESM imports.
+
+:::
+
+## 3. Install the Supabase extension
+
+```npm
+npm install @prisma/orm-extension-supabase
+```
+
+The extension teaches Prisma what Supabase owns in your database: the `auth` and `storage` tables and the `anon`, `authenticated`, and `service_role` roles. Prisma can reference those tables from your schema and never tries to create, change, or delete them. They stay managed by Supabase.
+
+## 4. Connect to your database
+
+Create a `.env` file with the two values from step 1, filling in your project ref, region, and password:
+
+```bash title=".env"
+DATABASE_URL="postgresql://postgres.YOUR_PROJECT_REF:YOUR_DB_PASSWORD@aws-0-YOUR_REGION.pooler.supabase.com:5432/postgres"
+SUPABASE_URL="https://YOUR_PROJECT_REF.supabase.co"
+```
+
+## 5. Register the extension
+
+Add the Supabase extension to `prisma.config.ts` so Prisma loads it together with your schema:
+
+```ts title="prisma.config.ts"
+import 'dotenv/config';
+import { definePrismaConfig } from '@prisma/cli-engine';
+import supabasePack from '@prisma/orm-extension-supabase/pack'; // [!code ++]
+import { defineConfig as ormConfig } from '@prisma/orm-postgres/config';
+
+export default definePrismaConfig({
+ orm: ormConfig({
+ contract: './src/prisma/contract.prisma',
+ extensions: [supabasePack], // [!code ++]
+ db: {
+ connection: process.env['DATABASE_URL']!,
+ },
+ }),
+});
+```
+
+## 6. Define your model and policies
+
+Replace the starter schema with the notes model:
+
+```prisma title="src/prisma/contract.prisma"
+namespace public {
+ model Note {
+ id Uuid @id @default(uuid())
+ title String
+ body String?
+ userId Uuid
+ user supabase:auth.AuthUser @relation(fields: [userId], references: [id], onDelete: Cascade)
+ createdAt Timestamptz @default(now())
+
+ @@map("note")
+ @@rls
+ }
+
+ // A signed-in user reads only their own notes.
+ policy_select note_owner_read {
+ target = Note
+ roles = [authenticated]
+ using = "\"userId\"::uuid = auth.uid()"
+ }
+
+ // A signed-in user can only create notes they own.
+ policy_insert note_owner_create {
+ target = Note
+ roles = [authenticated]
+ withCheck = "\"userId\"::uuid = auth.uid()"
+ }
+
+ // A signed-in user can update only their own notes and cannot
+ // hand them to another user.
+ policy_update note_owner_update {
+ target = Note
+ roles = [authenticated]
+ using = "\"userId\"::uuid = auth.uid()"
+ withCheck = "\"userId\"::uuid = auth.uid()"
+ }
+
+ // A signed-in user can delete only their own notes.
+ policy_delete note_owner_delete {
+ target = Note
+ roles = [authenticated]
+ using = "\"userId\"::uuid = auth.uid()"
+ }
+}
+```
+
+Three parts of this schema do work no Prisma schema could do before:
+
+- **The `user` relation** is a real foreign key into Supabase's `auth.users` table. A note can never point at a user that doesn't exist, and deleting a user deletes their notes. Postgres guarantees both. The `supabase:` prefix tells Prisma the target belongs to the extension, not to you.
+- **`@@rls`** turns on Row Level Security for the table. No role sees any row unless a policy grants it.
+- **The `policy_*` blocks** are your access rules. Each one names the operation it covers, the roles it applies to, and the same `USING` and `WITH CHECK` conditions you would otherwise write in SQL.
+
+:::info
+
+The `userId` column must be `Uuid` because `auth.users.id` is a `uuid`; Prisma does not convert types for you. The `user` relation exists for the constraint only: you cannot `include` the user in a query, and trying is a compile-time error rather than a runtime surprise.
+
+There is also no policy for the `anon` role, on purpose. With RLS enabled and no matching policy, anonymous visitors see zero rows.
+
+:::
+
+## 7. Apply your schema to the database
+
+Three commands take the schema live:
+
+```npm
+npx prisma@latest contract emit
+npx prisma@latest migration plan --name init
+npx prisma@latest db migrate --advance-ref db
+```
+
+Here is what each does:
+
+- `contract emit` checks your schema and generates the TypeScript types.
+- `migration plan` compares your schema against the database and writes a migration under `migrations/app/`. The first run also snapshots the Supabase extension's contract under `migrations/snapshots/`, referenced from `migrations/supabase/`. Commit the whole `migrations` directory; it lets CI and production apply migrations without loading the extension package.
+- `db migrate` applies the migration. For this schema that means the table, an index on `userId`, the foreign key into `auth.users`, Row Level Security, and all four policies. `--advance-ref db` records which contract the database is on, so the next `migration plan` knows where to start.
+
+Always run `contract emit` before `migration plan`. The plan reads the emitted contract, not the `.prisma` file, so an unemitted edit plans as a no-op.
+
+To confirm the foreign key landed, run this in the Supabase SQL editor:
+
+```sql
+select conname, pg_get_constraintdef(oid)
+from pg_constraint
+where conrelid = 'public.note'::regclass and contype = 'f';
+```
+
+```js no-copy
+note_userId_fkey | FOREIGN KEY ("userId") REFERENCES auth.users(id) ON DELETE CASCADE
+```
+
+You wrote no SQL, and every future policy change shows up as a schema diff in your pull requests.
+
+:::info
+
+Every later schema change is the same three commands: `contract emit`, `migration plan --name `, `db migrate --advance-ref db`. If you apply a migration without `--advance-ref db`, the next `migration plan` fails with `MIGRATION.PLAN_ORIGIN_UNKNOWN` rather than silently re-planning from an empty database. The error prints the hash to set with `migration ref set db `.
+
+:::
+
+## 8. Create the client
+
+Replace the scaffolded `src/prisma/db.ts` with a client that knows about Supabase Auth:
+
+```ts title="src/prisma/db.ts"
+import 'dotenv/config';
+import { type SupabaseDb, supabase } from '@prisma/orm-extension-supabase/runtime';
+import type { Contract } from './contract.d';
+import contractJson from './contract.json' with { type: 'json' };
+
+let instance: Promise> | undefined;
+
+export function getDb(): Promise> {
+ instance ??= supabase({
+ contractJson,
+ url: process.env['DATABASE_URL']!,
+ jwksUrl: `${process.env['SUPABASE_URL']!}/auth/v1/.well-known/jwks.json`,
+ });
+ return instance;
+}
+```
+
+The `jwksUrl` points at your project's public signing keys. When a request arrives with a Supabase Auth token, Prisma checks the token's signature against those keys before running any query. A forged or expired token never reaches your database.
+
+:::info
+
+Configure exactly one key source. Current Supabase projects sign tokens with asymmetric keys, so `jwksUrl` is the right choice. The `jwtSecret` option exists for older projects that still sign with a shared secret; your dashboard showing a `JWT_SECRET` does not mean your project uses it. Setting both or neither throws `SUPABASE.CONFIG_INVALID`.
+
+:::
+
+## 9. Query as your users
+
+The client has no query methods until you choose who is asking. `db.orm` does not exist; `db.asUser(jwt).orm` does. This makes it impossible to run application queries with the connection's admin login and silently skip RLS.
+
+```ts
+const db = await getDb();
+
+// A signed-in user: RLS scopes every query to their own rows.
+const userDb = await db.asUser(jwt);
+const mine = await userDb.orm.public.Note.select('id', 'title').all().toArray();
+
+// An anonymous visitor: sees only what anon policies allow (here, nothing).
+const publicRows = await db.asAnon().orm.public.Note.select('id').all().toArray();
+
+// service_role: skips RLS entirely. Admin jobs only.
+const all = await db.asServiceRole().orm.public.Note.select('id', 'userId').all().toArray();
+```
+
+Notice what the user query does not have: a `where userId = ...` filter. The select policy from step 6 is the filter, applied by Postgres itself.
+
+Writes work the same way. Creating a note succeeds only when `userId` matches the signed-in user:
+
+```ts
+const note = await userDb.orm.public.Note.select('id', 'title', 'userId').create({
+ title: 'my note',
+ body: null,
+ userId, // must be the signed-in user's id, or the insert policy rejects it
+});
+```
+
+## 10. Check that your policies work
+
+Save this as `src/check.ts`. It signs up two users through Supabase Auth, then confirms every rule holds in both directions. It needs your project's anon key as `SUPABASE_ANON_KEY` in `.env` (**Project Settings** > **API Keys**), and email confirmation turned off under **Authentication** > **Sign In / Providers** > **Email** so that sign-up returns a session.
+
+```ts title="src/check.ts"
+import 'dotenv/config';
+import { getDb } from './prisma/db.js';
+
+const url = process.env['SUPABASE_URL']!;
+const anonKey = process.env['SUPABASE_ANON_KEY']!;
+
+async function signUp(email: string) {
+ const res = await fetch(`${url}/auth/v1/signup`, {
+ method: 'POST',
+ headers: { apikey: anonKey, 'Content-Type': 'application/json' },
+ body: JSON.stringify({ email, password: 'correct-horse-battery-staple-42' }),
+ });
+ const json = await res.json();
+ return { id: json.user.id as string, token: json.access_token as string };
+}
+
+const db = await getDb();
+const stamp = Date.now();
+const alice = await signUp(`alice+${stamp}@example.com`);
+const bob = await signUp(`bob+${stamp}@example.com`);
+const asAlice = await db.asUser(alice.token);
+const asBob = await db.asUser(bob.token);
+
+await asAlice.orm.public.Note.select('id').create({ title: 'alice note', body: null, userId: alice.id });
+await asBob.orm.public.Note.select('id').create({ title: 'bob note', body: null, userId: bob.id });
+
+// Each user sees exactly their own rows, with no where clause.
+const mine = await asAlice.orm.public.Note.select('title', 'userId').all().toArray();
+console.log('alice sees only her rows:', mine.length === 1 && mine[0]!.userId === alice.id);
+
+// Updating another user's note changes zero rows.
+const count = await asAlice.orm.public.Note.where({ userId: bob.id }).updateAndCount({ title: 'x' });
+console.log('cross-user update touches 0 rows:', count === 0);
+
+// Creating a note owned by someone else is rejected by the database.
+await asAlice.orm.public.Note.select('id').create({ title: 'forged', body: null, userId: bob.id })
+ .then(() => console.log('forged insert rejected: false'))
+ .catch((e) => console.log('forged insert rejected:', /row-level security/.test(String(e.message))));
+
+// Anonymous requests see nothing.
+const anon = await db.asAnon().orm.public.Note.select('id').all().toArray();
+console.log('anon sees nothing:', anon.length === 0);
+
+// service_role skips RLS. This line documents the bypass; it proves no policy.
+const all = await db.asServiceRole().orm.public.Note.select('id').all().toArray();
+console.log('service_role sees all:', all.length >= 2);
+
+// A token that was not signed by your project is rejected before any query runs.
+await db.asUser(alice.token.slice(0, -4) + 'xxxx')
+ .then(() => console.log('forged JWT rejected: false'))
+ .catch((e) => console.log('forged JWT rejected:', e.code));
+
+process.exit(0);
+```
+
+Run it:
+
+```npm
+npx tsx src/check.ts
+```
+
+```js no-copy
+alice sees only her rows: true
+cross-user update touches 0 rows: true
+forged insert rejected: true
+anon sees nothing: true
+service_role sees all: true
+forged JWT rejected: SUPABASE.JWT_INVALID
+```
+
+:::warning
+
+The `service_role` line only shows that the bypass exists. It skips RLS by design, so a test that reads through `asServiceRole()` can never prove a policy works. Keep it out of any test that is meant to guard a policy.
+
+:::
+
+Your schema, auth relationships, and access rules now live in one file, and Postgres enforces them on every query.
+
+## 11. Optional: deploy to Prisma Compute
+
+[Prisma Compute](https://www.prisma.io/compute) (public beta) is serverless TypeScript hosting that runs your app next to your database. Install the config SDK and add a committed config:
+
+```npm
+npm install @prisma/compute-sdk
+```
+
+```ts title="prisma.compute.ts"
+import { defineComputeConfig } from '@prisma/compute-sdk/config';
+
+export default defineComputeConfig({
+ app: {
+ name: 'prisma8-supabase-rls',
+ framework: 'hono',
+ httpPort: 8080,
+ env: '.env',
+ },
+});
+```
+
+Then deploy:
+
+```npm
+npx @prisma/cli@latest app deploy --create-project prisma8-supabase-rls
+```
+
+The CLI builds locally, uploads, and prints a live URL. Values from `.env` are configured through the deploy and never land in your repository. Because enforcement lives in Postgres, the deployed app behaves exactly like your local one.
+
+## Reading `auth.users` for admin work
+
+Application queries cannot reach the `auth` tables on any role; that boundary keeps your app from depending on Supabase's internal schema. For admin reads, `asServiceRole()` exposes the extension's own query surface:
+
+```ts
+const admin = db.asServiceRole();
+const users = await admin.supabase
+ .execute(admin.supabase.sql.auth.users.select('id', 'email').build())
+ .toArray();
+```
+
+On a hosted project, run a one-time grant first, because Supabase gives `service_role` no table privileges on `auth.*` over a direct connection:
+
+```sql
+GRANT USAGE ON SCHEMA auth TO service_role;
+GRANT SELECT ON TABLE auth.users TO service_role;
+```
+
+For creating users, password resets, and other account operations, use the Supabase Auth Admin API rather than direct SQL.
+
+## Troubleshooting
+
+- `MIGRATION.CONTRACT_SPACE_LAYOUT_VIOLATION` with `declaredButUnmigrated: supabase`: you skipped `migration plan`. Run `npx prisma@latest migration plan --name ` once and retry.
+- `MIGRATION.PLAN_ORIGIN_UNKNOWN`: migrations exist but no `db` ref is set, usually because a `db migrate` ran without `--advance-ref db`. Run `npx prisma@latest migration ref set db ` with the hash the error prints, then keep using `--advance-ref db`.
+- `CLI.UNKNOWN_COMMAND` for `migrate`: the command is `db migrate` in the current release candidate.
+- `MIGRATION.RUNNER_FAILED` with `cannot drop column ... because other objects depend on it`: you removed a column and a policy that references it in the same change. The planner currently drops the column first ([prisma/orm#30226](https://github.com/prisma/orm/issues/30226)). Remove the policy in one migration, then the column in the next.
+- `SUPABASE.CONFIG_INVALID`: set exactly one of `jwksUrl` or `jwtSecret`.
+- `SUPABASE.JWT_INVALID` with an algorithm mismatch in `meta.reason`: you passed an asymmetric-key token to a `jwtSecret` client or vice versa; switch the key source.
+- Connection timeouts or DNS failures: use the session pooler host, not `db..supabase.co`, on IPv4 networks.
+- `db verify` reports missing `storage.iceberg_namespaces` / `storage.iceberg_tables` on a current hosted project: known issue, the extension's storage description lags the hosted platform. Migrations and runtime behavior are unaffected; use `npx prisma@latest db verify --marker-only` until the updated extension ships.
+
+## Current limitations
+
+- References into Supabase's tables are constraints, not navigable relations; `include` across them is a compile-time error.
+- No transaction can span your tables and the `auth` tables.
+- Node.js and Bun only; the runtime needs a real Postgres driver, so edge runtimes are not supported.
+- Policy conditions are strings; Prisma names, migrates, and reads back policies but does not type-check what's inside the quotes.
+- Realtime subscriptions and Storage are not covered by this extension; keep using `supabase-js` for those.
+- Triggers and functions (for example, create-a-profile-on-signup) are not schema objects yet; write them as raw SQL migration operations.
+
+## Prompt your coding agent
+
+```text
+Add a model owned by the Supabase Auth user to my Prisma 8 contract: a Uuid foreign key to supabase:auth.AuthUser with onDelete: Cascade, @@rls enabled, and owner-only select/insert/update/delete policies using auth.uid(). Then emit the contract, plan a migration, and apply it.
+```
+
+## Next steps
+
+- Add more models and policies as your app grows; every change ships as a reviewed migration.
+- Read [Using extensions](/orm/extensions/using-extensions) to see how extensions plug into Prisma 8.
+- Check out [Prisma Compute](https://www.prisma.io/docs/compute) to run your app next to your database.
+
+### More info
+
+- [Supabase Row Level Security docs](https://supabase.com/docs/guides/database/postgres/row-level-security)
+- [Prisma 8 docs](/orm)
diff --git a/apps/docs/cspell.json b/apps/docs/cspell.json
index 01645d086e..9c9b4e5fbe 100644
--- a/apps/docs/cspell.json
+++ b/apps/docs/cspell.json
@@ -69,6 +69,10 @@
"codemods",
"coinflips",
"columnx",
+ "conname",
+ "conrelid",
+ "constraintdef",
+ "contype",
"CREATEDB",
"createmany",
"Ctype",
@@ -282,6 +286,7 @@
"postgres",
"postgresql",
"PostgreSQL",
+ "PostgREST",
"Postico",
"precheck",
"prechecks",
diff --git a/apps/docs/source.config.ts b/apps/docs/source.config.ts
index 84af3f4adf..dfd25917f5 100644
--- a/apps/docs/source.config.ts
+++ b/apps/docs/source.config.ts
@@ -1,7 +1,13 @@
import remarkDirective from "remark-directive";
-import { remarkDirectiveAdmonition, remarkMdxFiles } from "fumadocs-core/mdx-plugins";
+import {
+ rehypeCodeDefaultOptions,
+ remarkDirectiveAdmonition,
+ remarkMdxFiles,
+} from "fumadocs-core/mdx-plugins";
import { remarkImage } from "fumadocs-core/mdx-plugins";
import { defineConfig, defineDocs, frontmatterSchema, metaSchema } from "fumadocs-mdx/config";
+import { bundledLanguages, type BundledLanguage, type LanguageRegistration } from "shiki";
+import { prisma8Language } from "@prisma-docs/ui/lib/prisma8-language";
import lastModified from "fumadocs-mdx/plugins/last-modified";
import { z } from "zod";
import convert from "npm-to-yarn";
@@ -100,6 +106,16 @@ export default defineConfig({
remarkMdxFiles,
remarkConsoleUtm,
],
+ rehypeCodeOptions: {
+ ...rehypeCodeDefaultOptions,
+ // Passing langs replaces the default set (all bundled languages), so
+ // rebuild it with the bundled prisma grammar swapped for the extended
+ // one that knows Prisma 8 namespace and policy blocks.
+ langs: [
+ ...(Object.keys(bundledLanguages) as BundledLanguage[]).filter((lang) => lang !== "prisma"),
+ prisma8Language as unknown as LanguageRegistration,
+ ],
+ },
remarkCodeTabOptions: {
parseMdx: true,
},
diff --git a/packages/ui/src/lib/prisma8-language.ts b/packages/ui/src/lib/prisma8-language.ts
new file mode 100644
index 0000000000..b3aba7a87b
--- /dev/null
+++ b/packages/ui/src/lib/prisma8-language.ts
@@ -0,0 +1,549 @@
+// Shiki's bundled prisma grammar (MIT, from prisma/language-tools) extended for
+// Prisma 8 contract syntax: namespace blocks, policy_* RLS blocks, and
+// cross-space field types like supabase:auth.AuthUser.
+// Registered through rehypeCodeOptions.langs in each app's source.config.ts.
+export const prisma8Language = {
+ displayName: "Prisma",
+ fileTypes: ["prisma"],
+ name: "prisma",
+ patterns: [
+ {
+ include: "#namespace_block_definition",
+ },
+ {
+ include: "#policy_block_definition",
+ },
+ {
+ include: "#triple_comment",
+ },
+ {
+ include: "#double_comment",
+ },
+ {
+ include: "#multi_line_comment",
+ },
+ {
+ include: "#model_block_definition",
+ },
+ {
+ include: "#config_block_definition",
+ },
+ {
+ include: "#enum_block_definition",
+ },
+ {
+ include: "#type_definition",
+ },
+ ],
+ repository: {
+ array: {
+ begin: "\\[",
+ beginCaptures: {
+ "1": {
+ name: "punctuation.definition.tag.prisma",
+ },
+ },
+ end: "]",
+ endCaptures: {
+ "1": {
+ name: "punctuation.definition.tag.prisma",
+ },
+ },
+ name: "source.prisma.array",
+ patterns: [
+ {
+ include: "#value",
+ },
+ ],
+ },
+ assignment: {
+ patterns: [
+ {
+ begin: "^\\s*(\\w+)\\s*(=)\\s*",
+ beginCaptures: {
+ "1": {
+ name: "variable.other.assignment.prisma",
+ },
+ "2": {
+ name: "keyword.operator.terraform",
+ },
+ },
+ end: "\\n",
+ patterns: [
+ {
+ include: "#value",
+ },
+ {
+ include: "#double_comment_inline",
+ },
+ ],
+ },
+ ],
+ },
+ attribute: {
+ captures: {
+ "1": {
+ name: "entity.name.function.attribute.prisma",
+ },
+ },
+ match: "(@@?[.\\w]+)",
+ name: "source.prisma.attribute",
+ },
+ attribute_with_arguments: {
+ begin: "(@@?[.\\w]+)(\\()",
+ beginCaptures: {
+ "1": {
+ name: "entity.name.function.attribute.prisma",
+ },
+ "2": {
+ name: "punctuation.definition.tag.prisma",
+ },
+ },
+ end: "\\)",
+ endCaptures: {
+ "0": {
+ name: "punctuation.definition.tag.prisma",
+ },
+ },
+ name: "source.prisma.attribute.with_arguments",
+ patterns: [
+ {
+ include: "#named_argument",
+ },
+ {
+ include: "#value",
+ },
+ ],
+ },
+ boolean: {
+ match: "\\b(true|false)\\b",
+ name: "constant.language.boolean.prisma",
+ },
+ config_block_definition: {
+ begin: "^\\s*(generator|datasource)\\s+([A-Za-z]\\w*)\\s+(\\{)",
+ beginCaptures: {
+ "1": {
+ name: "storage.type.config.prisma",
+ },
+ "2": {
+ name: "entity.name.type.config.prisma",
+ },
+ "3": {
+ name: "punctuation.definition.tag.prisma",
+ },
+ },
+ end: "\\s*}",
+ endCaptures: {
+ "1": {
+ name: "punctuation.definition.tag.prisma",
+ },
+ },
+ name: "source.prisma.embedded.source",
+ patterns: [
+ {
+ include: "#triple_comment",
+ },
+ {
+ include: "#double_comment",
+ },
+ {
+ include: "#multi_line_comment",
+ },
+ {
+ include: "#assignment",
+ },
+ ],
+ },
+ double_comment: {
+ begin: "//",
+ end: "$\\n?",
+ name: "comment.prisma",
+ },
+ double_comment_inline: {
+ match: "//[^\\n]*",
+ name: "comment.prisma",
+ },
+ double_quoted_string: {
+ begin: '"',
+ beginCaptures: {
+ "0": {
+ name: "string.quoted.double.start.prisma",
+ },
+ },
+ end: '"',
+ endCaptures: {
+ "0": {
+ name: "string.quoted.double.end.prisma",
+ },
+ },
+ name: "unnamed",
+ patterns: [
+ {
+ include: "#string_interpolation",
+ },
+ {
+ match: "([-%./:=?@\\\\_\\w]+)",
+ name: "string.quoted.double.prisma",
+ },
+ ],
+ },
+ enum_block_definition: {
+ begin: "^\\s*(enum)\\s+([A-Za-z]\\w*)\\s+(\\{)",
+ beginCaptures: {
+ "1": {
+ name: "storage.type.enum.prisma",
+ },
+ "2": {
+ name: "entity.name.type.enum.prisma",
+ },
+ "3": {
+ name: "punctuation.definition.tag.prisma",
+ },
+ },
+ end: "\\s*}",
+ endCaptures: {
+ "0": {
+ name: "punctuation.definition.tag.prisma",
+ },
+ },
+ name: "source.prisma.embedded.source",
+ patterns: [
+ {
+ include: "#triple_comment",
+ },
+ {
+ include: "#double_comment",
+ },
+ {
+ include: "#multi_line_comment",
+ },
+ {
+ include: "#enum_value_definition",
+ },
+ ],
+ },
+ enum_value_definition: {
+ patterns: [
+ {
+ captures: {
+ "1": {
+ name: "variable.other.assignment.prisma",
+ },
+ },
+ match: "^\\s*(\\w+)\\s*",
+ },
+ {
+ include: "#attribute_with_arguments",
+ },
+ {
+ include: "#attribute",
+ },
+ ],
+ },
+ field_definition: {
+ name: "scalar.field",
+ patterns: [
+ {
+ captures: {
+ "1": {
+ name: "variable.other.assignment.prisma",
+ },
+ "2": {
+ name: "invalid.illegal.colon.prisma",
+ },
+ "3": {
+ name: "variable.language.relations.prisma",
+ },
+ "4": {
+ name: "support.type.primitive.prisma",
+ },
+ "5": {
+ name: "keyword.operator.list_type.prisma",
+ },
+ "6": {
+ name: "keyword.operator.optional_type.prisma",
+ },
+ "7": {
+ name: "invalid.illegal.required_type.prisma",
+ },
+ },
+ match:
+ "^\\s*(\\w+)(\\s*:)?\\s+((?!(?:Int|BigInt|String|DateTime|Bytes|Decimal|Float|Json|Boolean)\\b)\\b(?:\\w+:)?\\w+(?:\\.\\w+)*)?(Int|BigInt|String|DateTime|Bytes|Decimal|Float|Json|Boolean)?(\\[])?(\\?)?(!)?",
+ },
+ {
+ include: "#attribute_with_arguments",
+ },
+ {
+ include: "#attribute",
+ },
+ ],
+ },
+ functional: {
+ begin: "(\\w+)(\\()",
+ beginCaptures: {
+ "1": {
+ name: "support.function.functional.prisma",
+ },
+ "2": {
+ name: "punctuation.definition.tag.prisma",
+ },
+ },
+ end: "\\)",
+ endCaptures: {
+ "0": {
+ name: "punctuation.definition.tag.prisma",
+ },
+ },
+ name: "source.prisma.functional",
+ patterns: [
+ {
+ include: "#value",
+ },
+ ],
+ },
+ identifier: {
+ patterns: [
+ {
+ match: "\\b(\\w)+\\b",
+ name: "support.constant.constant.prisma",
+ },
+ ],
+ },
+ literal: {
+ name: "source.prisma.literal",
+ patterns: [
+ {
+ include: "#boolean",
+ },
+ {
+ include: "#number",
+ },
+ {
+ include: "#double_quoted_string",
+ },
+ {
+ include: "#identifier",
+ },
+ ],
+ },
+ map_key: {
+ name: "source.prisma.key",
+ patterns: [
+ {
+ captures: {
+ "1": {
+ name: "variable.parameter.key.prisma",
+ },
+ "2": {
+ name: "punctuation.definition.separator.key-value.prisma",
+ },
+ },
+ match: "(\\w+)\\s*(:)\\s*",
+ },
+ ],
+ },
+ model_block_definition: {
+ begin: "^\\s*(model|type|view)\\s+([A-Za-z]\\w*)\\s*(\\{)",
+ beginCaptures: {
+ "1": {
+ name: "storage.type.model.prisma",
+ },
+ "2": {
+ name: "entity.name.type.model.prisma",
+ },
+ "3": {
+ name: "punctuation.definition.tag.prisma",
+ },
+ },
+ end: "\\s*}",
+ endCaptures: {
+ "0": {
+ name: "punctuation.definition.tag.prisma",
+ },
+ },
+ name: "source.prisma.embedded.source",
+ patterns: [
+ {
+ include: "#triple_comment",
+ },
+ {
+ include: "#double_comment",
+ },
+ {
+ include: "#multi_line_comment",
+ },
+ {
+ include: "#field_definition",
+ },
+ ],
+ },
+ multi_line_comment: {
+ begin: "/\\*",
+ end: "\\*/",
+ name: "comment.prisma",
+ },
+ named_argument: {
+ name: "source.prisma.named_argument",
+ patterns: [
+ {
+ include: "#map_key",
+ },
+ {
+ include: "#value",
+ },
+ ],
+ },
+ number: {
+ match:
+ "((0([Xx])\\h*)|([-+])?\\b(([0-9]+\\.?[0-9]*)|(\\.[0-9]+))(([Ee])([-+])?[0-9]+)?)([DFLUdfglu]|UL|ul)?\\b",
+ name: "constant.numeric.prisma",
+ },
+ string_interpolation: {
+ patterns: [
+ {
+ begin: "\\$\\{",
+ beginCaptures: {
+ "0": {
+ name: "keyword.control.interpolation.start.prisma",
+ },
+ },
+ end: "\\s*}",
+ endCaptures: {
+ "0": {
+ name: "keyword.control.interpolation.end.prisma",
+ },
+ },
+ name: "source.tag.embedded.source.prisma",
+ patterns: [
+ {
+ include: "#value",
+ },
+ ],
+ },
+ ],
+ },
+ triple_comment: {
+ begin: "///",
+ end: "$\\n?",
+ name: "comment.prisma",
+ },
+ type_definition: {
+ patterns: [
+ {
+ captures: {
+ "1": {
+ name: "storage.type.type.prisma",
+ },
+ "2": {
+ name: "entity.name.type.type.prisma",
+ },
+ "3": {
+ name: "support.type.primitive.prisma",
+ },
+ },
+ match: "^\\s*(type)\\s+(\\w+)\\s*=\\s*(\\w+)",
+ },
+ {
+ include: "#attribute_with_arguments",
+ },
+ {
+ include: "#attribute",
+ },
+ ],
+ },
+ value: {
+ name: "source.prisma.value",
+ patterns: [
+ {
+ include: "#array",
+ },
+ {
+ include: "#functional",
+ },
+ {
+ include: "#literal",
+ },
+ ],
+ },
+ namespace_block_definition: {
+ begin: "^\\s*(namespace)\\s+([A-Za-z]\\w*)\\s*(\\{)",
+ beginCaptures: {
+ "1": {
+ name: "storage.type.model.prisma",
+ },
+ "2": {
+ name: "entity.name.type.model.prisma",
+ },
+ "3": {
+ name: "punctuation.definition.tag.prisma",
+ },
+ },
+ end: "\\s*\\}",
+ endCaptures: {
+ "0": {
+ name: "punctuation.definition.tag.prisma",
+ },
+ },
+ name: "source.prisma.embedded.source",
+ patterns: [
+ {
+ include: "#triple_comment",
+ },
+ {
+ include: "#double_comment",
+ },
+ {
+ include: "#multi_line_comment",
+ },
+ {
+ include: "#model_block_definition",
+ },
+ {
+ include: "#policy_block_definition",
+ },
+ {
+ include: "#enum_block_definition",
+ },
+ {
+ include: "#type_definition",
+ },
+ ],
+ },
+ policy_block_definition: {
+ begin: "^\\s*(policy_(?:select|insert|update|delete|all))\\s+([A-Za-z]\\w*)\\s*(\\{)",
+ beginCaptures: {
+ "1": {
+ name: "storage.type.config.prisma",
+ },
+ "2": {
+ name: "entity.name.type.config.prisma",
+ },
+ "3": {
+ name: "punctuation.definition.tag.prisma",
+ },
+ },
+ end: "\\s*\\}",
+ endCaptures: {
+ "0": {
+ name: "punctuation.definition.tag.prisma",
+ },
+ },
+ name: "source.prisma.embedded.source",
+ patterns: [
+ {
+ include: "#triple_comment",
+ },
+ {
+ include: "#double_comment",
+ },
+ {
+ include: "#multi_line_comment",
+ },
+ {
+ include: "#assignment",
+ },
+ ],
+ },
+ },
+ scopeName: "source.prisma",
+};
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 2726703ce6..55075bf577 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -432,6 +432,9 @@ importers:
postcss:
specifier: 'catalog:'
version: 8.5.28
+ shiki:
+ specifier: ^4.4.3
+ version: 4.4.3
tailwindcss:
specifier: 'catalog:'
version: 4.3.3