Files
Katerina Skroumpelou 65f870565a feat: compose withSupabase as a pipeline entry (#145)
* feat: compose withSupabase as a pipeline entry

* fix: fixes after review

* fix: more fixes from review
2026-09-09 14:06:45 +03:00

255 lines
13 KiB
Markdown

# Postgres (`ctx.postgres`)
> **Alpha.** Composing `withSupabase` as a `pipeline` entry and the
> `@supabase/server/middleware/*` subpaths track `@supabase/middleware` 0.x —
> entry shapes and context keys may change between 0.x releases. The
> `withSupabase(config, handler)` form is stable.
Two middleware give you a direct Postgres connection, mirroring the `ctx.supabase` / `ctx.supabaseAdmin` pair:
| Middleware | Subpath | Contributes | RLS |
| ------------------------- | ----------------------------- | ------------------- | ------------------------------ |
| `withPostgresClient` | `./middleware/postgres` | `ctx.postgres` | Enforced, scoped to the caller |
| `withPostgresAdminClient` | `./middleware/postgres-admin` | `ctx.postgresAdmin` | **Bypassed** |
Reach for the scoped one by default. The admin one is a deliberate opt-out, covered [below](#bypassing-rls).
`withPostgresClient` puts a direct Postgres connection on `ctx.postgres`, scoped to the calling user by RLS. It is the safe version of "authenticate, then query as the user": you write plain SQL, and Postgres — not your application code — decides which rows the caller may see.
```ts
import { pipeline } from '@supabase/middleware'
import { withSupabase } from '@supabase/server'
import { withPostgresClient } from '@supabase/server/middleware/postgres'
export default {
fetch: pipeline(
[withSupabase({ auth: 'user' }), withPostgresClient()],
async (_req, ctx) => {
// No WHERE clause — RLS scopes the rows to the caller.
const notes = await ctx.postgres.query`select id, body from notes`
return Response.json(notes)
},
),
}
```
Use this when PostgREST is not the right tool: multi-table joins, window functions, CTEs, `insert ... returning` with computed columns, or any query that is simply easier to express in SQL. For ordinary CRUD, `ctx.supabase` is still the better choice.
## What each query runs
Every query takes a connection from the pool and runs your SQL inside its own transaction, injecting the caller's claims exactly the way PostgREST does:
```sql
begin;
select set_config('request.jwt.claims', $claims, true); -- auth.uid() resolves
set local role "authenticated"; -- RLS now enforces
-- your query
commit;
```
Both `set_config`'s third argument and `set local` are transaction-local, so nothing leaks onto the pooled connection when it goes back to the pool.
## Writing queries safely
`query` is a tagged template. Every interpolation becomes a bind parameter, so an interpolated value is never SQL text and cannot change the shape of the statement:
```ts
const rows = await ctx.postgres
.query`select id, body from notes where id = ${id}`
// -> select id, body from notes where id = $1 with values [id]
```
That holds no matter what `id` contains. A value like `'; drop table notes; --` is sent as a parameter and compared as a string.
Tagged templates cannot carry type arguments, so annotate the binding rather than writing `query<NoteRow>`:
```ts
const rows: NoteRow[] = await ctx.postgres.query`select id, body from notes`
```
Passing a plain string to `query` throws. The two calls differ only in their brackets, so refusing is safer than reinterpreting one as the other.
### `queryRaw` for text you build
Use `queryRaw(text, params)` when the SQL cannot be a literal — a query builder or codegen emitting `{ sql, parameters }`, or a statement held in a constant:
```ts
const rows = await ctx.postgres.queryRaw(
'select id, body from notes where id = $1',
[id],
)
```
It is fully safe as long as caller-supplied values travel in `params`; that is exactly what `query` compiles down to. What it cannot do is stop you concatenating a value into `text`. The name is the warning, and it greps.
### `ident` for identifiers
Table names, column names and `order by` direction can never be bind parameters — `select $1 from notes` selects a literal, not a column. Those have to reach the server as SQL text, so check them against a set you control and quote them:
```ts
import { ident } from '@supabase/server/middleware/postgres'
const SORTABLE = new Set(['created_at', 'title'])
if (!SORTABLE.has(column)) throw new Error('unsupported sort column')
const rows = await ctx.postgres.queryRaw(
`select id, title from notes order by ${ident(column)} desc`,
)
```
`ident` stops injection; it does not authorize. Quoting a caller-supplied name yields a valid identifier, not a permitted one — it cannot break out of the statement, but it can still name a column the caller was never meant to read. The allowlist is what prevents that.
## Which roles are assumed
Only `authenticated` and `anon`. A verified token naming any other role is **refused** — a 500 with `code: 'UNSUPPORTED_ROLE'` and a message naming the role — rather than quietly downgraded:
| `role` claim | Result |
| ---------------------------- | ---------------------------------------------- |
| absent, or no token at all | `anon` |
| `anon` | `anon` |
| `authenticated` | `authenticated` |
| `service_role` | Refused, pointing at `withPostgresAdminClient` |
| anything else (custom roles) | Refused, naming the role |
Refusing rather than downgrading is deliberate. Running someone's query under the wrong identity returns **zero rows instead of an error**, which is close to undebuggable — you see an empty array and no indication that the role was the problem.
### Custom roles are not supported yet
Supabase lets you [define custom Postgres roles](https://supabase.com/docs/guides/storage/schema/custom-roles) and put them in the `role` claim, with RLS policies written `to manager`. That is a legitimate pattern and RLS still applies — custom roles are a dimension of RLS, not a way around it.
They are not supported here yet, and the reason is worth knowing. PostgREST connects as the unprivileged `authenticator` role, so `grant manager to authenticator` _is_ the authorization — Postgres itself decides which roles are reachable. This middleware connects with `SUPABASE_DB_URL`, which on Supabase is `postgres`: a role that already bypasses RLS and can `SET ROLE` into almost anything. With no equivalent boundary to lean on, v1 assumes a fixed pair of roles instead of trusting the claim.
Until custom-role support lands, issue tokens with `authenticated` or `anon`, or use `withPostgresAdminClient` and do the scoping in your own `where` clause.
### Write policies with the `auth.*` helpers
The single `request.jwt.claims` setting is the whole claim payload as JSON, and it is the only one this middleware sets. `auth.uid()`, `auth.role()`, and `auth.jwt()` all read it, so policies written the normal way work unchanged:
```sql
create policy "users read their own notes"
on public.notes for select to authenticated
using ((select auth.uid()) = user_id);
```
Reach for those helpers rather than reading settings by hand. In particular, the older singular GUCs — `current_setting('request.jwt.claim.sub')` and friends — are **not** set here; they are a legacy PostgREST convention that PostgREST itself has since removed. A policy that reads them directly sees `NULL` and quietly matches nothing.
## Composition
`withPostgresClient` needs the caller's verified claims at `ctx.jwtClaims`. That prerequisite is enforced at compile time, so there are exactly two ways to satisfy it.
**After `withSupabase`** — the context already carries `jwtClaims`, so place it next in the array:
```ts
pipeline([withSupabase({ auth: 'user' }), withPostgresClient()], handler)
```
**Standalone** — in a Supabase-agnostic `pipeline`, pair it with [`withClaims`](../src/middleware/claims/index.ts), which verifies the Bearer token against the project JWKS:
```ts
import { pipeline } from '@supabase/middleware'
import { withClaims } from '@supabase/server/middleware/claims'
import { withPostgresClient } from '@supabase/server/middleware/postgres'
export default {
fetch: pipeline([withClaims(), withPostgresClient()], async (_req, ctx) => {
const rows = await ctx.postgres.query`select id, title from posts`
return Response.json({ rows, caller: ctx.jwtClaims?.sub ?? 'anon' })
}),
}
```
Order matters. `withPostgresClient` before `withClaims` is a compile-time error:
```
middleware-prereq: key 'jwtClaims' is not yet on the context (check ordering)
```
`withClaims` is not an auth gate. It contributes claims when a token is present, and `null` when one is not. The standalone pipeline above therefore also serves anonymous callers, whose queries run as `anon`. To require an authenticated caller, swap in [`withRequiredClaims`](../src/middleware/required-claims/index.ts): it rejects token-less requests with a 401 before the handler runs and contributes non-null `jwtClaims`, so the handler reads `ctx.jwtClaims.sub` directly. Inside `withSupabase`, `auth: 'user'` provides the same gate.
## Table grants
Queries run as `authenticated` or `anon`, and on current Supabase projects new tables grant those roles nothing. RLS policies are not enough on their own — a policy filters rows the role is already allowed to touch.
```sql
grant select, insert on public.notes to authenticated;
```
Without the grant the query fails with `permission denied` (SQLSTATE `42501`) _before_ RLS is consulted. `withPostgresClient` recognizes that code and appends the role and the missing-grant hint to the error message, so the fix is in the error you actually see.
## Bypassing RLS
When a handler legitimately needs to cross user boundaries — an admin dashboard, a cron aggregate, a background job — compose `withPostgresAdminClient` instead. It contributes `ctx.postgresAdmin`, which runs queries as-is under the connection-string role: no claim injection, no role switch, no wrapping transaction.
```ts
import { pipeline } from '@supabase/middleware'
import { withSupabase } from '@supabase/server'
import { withPostgresAdminClient } from '@supabase/server/middleware/postgres-admin'
export default {
fetch: pipeline(
[withSupabase({ auth: 'secret' }), withPostgresAdminClient()],
async (_req, ctx) => {
const rows = await ctx.postgresAdmin
.query`select user_id, count(*) from notes group by user_id`
return Response.json(rows)
},
),
}
```
Unlike the scoped half it declares **no upstream prerequisite** — it never reads `ctx.jwtClaims`, so it works under `auth: 'secret'` and `auth: 'none'` where there is no caller identity at all.
Compose both when a handler needs each in turn. They share one pool, and `ctx.postgres` stays RLS-scoped regardless:
```ts
pipeline(
[
withSupabase({ auth: 'user' }),
withPostgresClient(),
withPostgresAdminClient(),
],
handler,
)
```
Two things worth being deliberate about:
- **Authorization becomes yours.** RLS is not consulted, so any per-user scoping has to be a `where` clause you write. The failure mode is silent — a forgotten clause returns every row rather than raising an error.
- **The split is the safety feature.** These are two middleware rather than one object with an `.admin` property so that bypassing RLS is visible at the composition site. You can grep a codebase for `withPostgresAdminClient` and find every handler that can cross user boundaries.
## Configuration
Both middleware take the same option:
```ts
withPostgresClient({ connectionString: 'postgresql://...' })
withPostgresAdminClient({ connectionString: 'postgresql://...' })
```
`connectionString` defaults to the `SUPABASE_DB_URL` environment variable, which Supabase Edge Functions provide automatically. If neither is set the middleware short-circuits with a 500 and code `MISSING_CONNECTION_STRING`, whose `hint` names the option to pass.
Connections are pooled per process, lazily, one pool per connection string (max 4 connections). The pool outlives individual requests — that is what makes this viable on a per-request runtime.
Both middleware share that cache, so composing the pair opens one pool, not two. Sharing is safe because everything the scoped half sets is transaction-local: a connection always returns to the pool clean, and an admin query can never inherit a previous caller's claims or role.
## Runtime support
`pg` opens a raw TCP socket, so both middleware run on **Node, Deno, Bun, and the Supabase Edge runtime** — but **not** on Workers-style isolates, which have no TCP. On those, use `ctx.supabase`, which talks HTTP to PostgREST.
`pg` is an optional peer dependency. Install it alongside the package when you use this middleware:
```sh
npm install pg
```
## Limits in this version
- **One transaction per `query()` call.** There is no multi-statement transaction API, so you cannot yet span several `query()` calls in one atomic unit. Put multi-statement logic in a database function and call it in a single query.
- **No read-replica routing** and **no trace propagation** — both are tracked separately.
- **No composing wrapper.** There is no `withPostgres()` that gives you both clients at once; list the two entries you want. The name is reserved in case that changes.
## See also
- [`docs/api-reference.md`](api-reference.md) — `withPostgresClient`, `withPostgresAdminClient`, `PostgresApi`, config types
- [`docs/security.md`](security.md) — how RLS fits the rest of the auth model