Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 30 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -266,11 +266,12 @@ Adapters wrap `withSupabase` for a specific framework's middleware contract. The

> **Adapters are a community-driven initiative.** They're developed, maintained, and evolved by contributors — including responding to upstream framework changes. See [`src/adapters/README.md`](src/adapters/README.md) for the contribution requirements (tests, types, docs, build wiring) if you'd like to add or help maintain one.

| Framework | Import | Framework version | Docs |
| --------- | ---------------------------------- | ----------------- | -------------------------------------------------- |
| Hono | `@supabase/server/adapters/hono` | `^4.0.0` | [docs/adapters/hono.md](docs/adapters/hono.md) |
| H3 / Nuxt | `@supabase/server/adapters/h3` | `^2.0.0` | [docs/adapters/h3.md](docs/adapters/h3.md) |
| Elysia | `@supabase/server/adapters/elysia` | `^1.4.0` | [docs/adapters/elysia.md](docs/adapters/elysia.md) |
| Framework | Import | Framework version | Docs |
| --------- | ----------------------------------- | ----------------- | ---------------------------------------------------- |
| Hono | `@supabase/server/adapters/hono` | `^4.0.0` | [docs/adapters/hono.md](docs/adapters/hono.md) |
| H3 / Nuxt | `@supabase/server/adapters/h3` | `^2.0.0` | [docs/adapters/h3.md](docs/adapters/h3.md) |
| Elysia | `@supabase/server/adapters/elysia` | `^1.4.0` | [docs/adapters/elysia.md](docs/adapters/elysia.md) |
| Express | `@supabase/server/adapters/express` | `^5.0.0` | [docs/adapters/express.md](docs/adapters/express.md) |

See the per-adapter docs above for setup, per-route auth, CORS, error handling, and other patterns.

Expand Down Expand Up @@ -316,6 +317,20 @@ app.listen(3000)

The adapter does not handle CORS — use `@elysiajs/cors` for that.

### Express

```ts
import express from 'express'
import { withSupabase } from '@supabase/server/adapters/express'

const app = express()
app.use(withSupabase({ auth: 'user' }))

app.listen(3000)
```

See [docs/adapters/express.md](docs/adapters/express.md) for per-route auth (`requireAuth`, `withSupabaseRoute`), custom error handling, CORS, and more.

## Primitives

For when you need more control than `withSupabase` provides — multiple routes with different auth, custom response headers, or building your own wrapper.
Expand Down Expand Up @@ -450,21 +465,22 @@ For other environments, pass overrides via the `env` config option or `resolveEn
| **Deno / Bun** | Works out of the box via `export default { fetch }`. |
| **Node.js** | Use a [framework adapter](#framework-adapters) or [core primitives](#primitives) with your framework of choice. |

Using a framework? See [Framework Adapters](#framework-adapters) for Hono, H3 / Nuxt, and Elysia, or [`docs/ssr-frameworks.md`](docs/ssr-frameworks.md) for Next.js / SvelteKit / Remix (compose with [`@supabase/ssr`](https://github.com/supabase/ssr)).
Using a framework? See [Framework Adapters](#framework-adapters) for Hono, H3 / Nuxt, Elysia, and Express, or [`docs/ssr-frameworks.md`](docs/ssr-frameworks.md) for Next.js / SvelteKit / Remix (compose with [`@supabase/ssr`](https://github.com/supabase/ssr)).

### Does this replace `@supabase/ssr`?

No. `@supabase/ssr` handles cookie-based session management for frameworks like Next.js and SvelteKit. `@supabase/server` handles stateless, header-based auth for Edge Functions, Workers, and other backend runtimes. The composable primitives already work in SSR environments but require more setup — see [`docs/ssr-frameworks.md`](docs/ssr-frameworks.md) for the Next.js example. The two packages coexist and are not replacements for each other. Deeper integration with `@supabase/ssr` is on the roadmap.

## Exports

| Export | What's in it |
| ---------------------------------- | ----------------------------------------------------------------------------------------------------------------- |
| `@supabase/server` | `withSupabase`, `createSupabaseContext` |
| `@supabase/server/core` | `verifyAuth`, `verifyCredentials`, `extractCredentials`, `createContextClient`, `createAdminClient`, `resolveEnv` |
| `@supabase/server/adapters/hono` | `withSupabase` (Hono middleware) |
| `@supabase/server/adapters/h3` | `withSupabase` (H3 / Nuxt middleware) |
| `@supabase/server/adapters/elysia` | `withSupabase` (Elysia plugin) |
| Export | What's in it |
| ----------------------------------- | ----------------------------------------------------------------------------------------------------------------- |
| `@supabase/server` | `withSupabase`, `createSupabaseContext` |
| `@supabase/server/core` | `verifyAuth`, `verifyCredentials`, `extractCredentials`, `createContextClient`, `createAdminClient`, `resolveEnv` |
| `@supabase/server/adapters/hono` | `withSupabase` (Hono middleware) |
| `@supabase/server/adapters/h3` | `withSupabase` (H3 / Nuxt middleware) |
| `@supabase/server/adapters/elysia` | `withSupabase` (Elysia plugin) |
| `@supabase/server/adapters/express` | `withSupabase`, `requireAuth`, `withSupabaseRoute` (Express 5 middleware/route helpers) |

## Documentation

Expand All @@ -476,6 +492,7 @@ No. `@supabase/ssr` handles cookie-based session management for frameworks like
| How do I use this with Hono? | [`docs/adapters/hono.md`](docs/adapters/hono.md) |
| How do I use this with H3 / Nuxt? | [`docs/adapters/h3.md`](docs/adapters/h3.md) |
| How do I use this with Elysia? | [`docs/adapters/elysia.md`](docs/adapters/elysia.md) |
| How do I use this with Express? | [`docs/adapters/express.md`](docs/adapters/express.md) |
| How do I use low-level primitives for custom flows? | [`docs/core-primitives.md`](docs/core-primitives.md) |
| How do environment variables work across runtimes? | [`docs/environment-variables.md`](docs/environment-variables.md) |
| How do I handle errors? What codes exist? | [`docs/error-handling.md`](docs/error-handling.md) |
Expand Down
299 changes: 299 additions & 0 deletions docs/adapters/express.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,299 @@
# Express Adapter

## Setup

Install Express as a peer dependency:

```bash
pnpm add express
pnpm add -D @types/express
```

The adapter exports an Express 5 `RequestHandler` instead of a fetch handler. The resolved `SupabaseContext` is stored on `res.locals.supabaseContext` (typed via declaration merging) so every downstream handler can read it.

> **Express 5 only.** The adapter relies on Express 5's native async-error handling — an `async` middleware that returns a rejected promise propagates to your error pipeline without `express-async-errors` or any other wrapper.

## Basic app with auth

```ts
import express from 'express'
import { withSupabase } from '@supabase/server/adapters/express'

const app = express()

// Apply auth to all routes
app.use(withSupabase({ auth: 'user' }))

app.get('/todos', async (_req, res) => {
const { supabase } = res.locals.supabaseContext
const { data } = await supabase.from('todos').select()
res.json(data)
})

app.get('/profile', async (_req, res) => {
const { supabase, userClaims } = res.locals.supabaseContext
const { data } = await supabase
.from('profiles')
.select()
.eq('id', userClaims!.id)
res.json(data)
})

app.listen(3000)
```

The context is stored in `res.locals.supabaseContext` and contains the same `SupabaseContext` fields as the main `withSupabase` wrapper: `supabase`, `supabaseAdmin`, `userClaims`, `jwtClaims`, `authMode`, and `authKeyName`.

## Per-route auth

Two composition patterns are supported.

### Mount `withSupabase()` once, then guard with `requireAuth()`

When most routes share a baseline auth set, mount the middleware once and use `requireAuth()` per route to narrow the allowed modes. This is the recommended ergonomic pattern.

```ts
import express from 'express'
import { requireAuth, withSupabase } from '@supabase/server/adapters/express'

const app = express()

// App-wide: accept either a user JWT or a secret key
app.use(withSupabase({ auth: ['user', 'secret'] }))

// User-only
app.get('/me', requireAuth('user'), async (_req, res) => {
const { userClaims } = res.locals.supabaseContext
res.json(userClaims)
})

// Service-only
app.post('/admin/sync', requireAuth('secret'), async (_req, res) => {
const { supabaseAdmin } = res.locals.supabaseContext
const { data } = await supabaseAdmin
.from('audit_log')
.insert({ action: 'sync' })
res.json(data)
})

// Service-only, named key — only the "cron" secret key may call this
app.post('/admin/refresh', requireAuth('secret:cron'), async (_req, res) => {
const { supabaseAdmin } = res.locals.supabaseContext
await supabaseAdmin.rpc('refresh_popular')
res.json({ ok: true })
})

app.listen(3000)
```

`requireAuth(modes?)` reads `res.locals.supabaseContext` set by an upstream `withSupabase()`. If the context is missing or the established `authMode` / `authKeyName` does not match, it forwards an `AuthError` via `next(err)` so your error middleware can render the 401. The `publishable:*` and `secret:*` wildcards accept any named key for that base mode.

### Wrap a single route with `withSupabaseRoute()`

When you do NOT want a global middleware — for example, a single authenticated route in an otherwise public app — wrap the route directly. The handler receives the resolved context as a fourth argument, so you don't need to read `res.locals`.

```ts
import express from 'express'
import { withSupabaseRoute } from '@supabase/server/adapters/express'

const app = express()

// Public, no auth
app.get('/health', (_req, res) => {
res.json({ status: 'ok' })
})

// Just this route is authenticated
app.get(
'/todos',
withSupabaseRoute({ auth: 'user' }, async (_req, res, _next, ctx) => {
const { data } = await ctx.supabase.from('todos').select()
res.json(data)
}),
)

// Different mode on a different route — no global middleware needed
app.post(
'/admin/sync',
withSupabaseRoute({ auth: 'secret' }, async (_req, res, _next, ctx) => {
const { data } = await ctx.supabaseAdmin
.from('audit_log')
.insert({ action: 'sync' })
res.json(data)
}),
)

app.listen(3000)
```

`withSupabaseRoute()` also populates `res.locals.supabaseContext`, so chaining additional `requireAuth()` guards on the same route still works.

## Skip behavior

If a previous middleware already set `res.locals.supabaseContext`, subsequent `withSupabase` calls skip auth and call `next()` immediately. This lets a route-level middleware override an app-wide default:

```ts
import express from 'express'
import { withSupabase } from '@supabase/server/adapters/express'

const app = express()

// App-wide default: user auth
app.use(withSupabase({ auth: 'user' }))

// This route needs secret auth instead.
// The route-level middleware runs first, sets the context,
// and the app-wide middleware sees res.locals.supabaseContext
// is already populated and skips.
app.post('/webhook', withSupabase({ auth: 'secret' }), async (_req, res) => {
const { supabaseAdmin } = res.locals.supabaseContext
await supabaseAdmin.from('webhook_log').insert({})
res.json({ ok: true })
})

app.listen(3000)
```

`withSupabaseRoute()` is the terminal entry point for its own route and does NOT short-circuit on a pre-existing context — use it when you want the route's auth config to always run.

## Error handling

By default, an `AuthError` from `createSupabaseContext` is forwarded via `next(error)` so your existing Express error middleware handles it — the Express-idiomatic flow:

```ts
import express, { type ErrorRequestHandler } from 'express'
import { AuthError } from '@supabase/server'
import { withSupabase } from '@supabase/server/adapters/express'

const app = express()

app.use(withSupabase({ auth: 'user' }))

app.get('/todos', async (_req, res) => {
const { supabase } = res.locals.supabaseContext
const { data } = await supabase.from('todos').select()
res.json(data)
})

const errorHandler: ErrorRequestHandler = (err, _req, res, next) => {
if (err instanceof AuthError) {
res.status(err.status).json({ code: err.code, message: err.message })
return
}
next(err)
}

app.use(errorHandler)

app.listen(3000)
```

The `AuthError` retains its `.status`, `.code`, and `.message` so you can map directly to an HTTP response without re-parsing.

### Inline `onError` handler

Pass an `onError` callback to handle auth failures next to the middleware itself. When provided, the adapter calls it instead of `next(error)` — your handler owns response/next semantics:

```ts
app.use(
withSupabase({
auth: 'user',
onError: (error, _req, res) => {
res
.status(error.status)
.json({ code: error.code, message: error.message })
},
}),
)
```

If your `onError` throws or returns a rejected promise, the thrown error is forwarded via `next(err)` so Express's error pipeline still triggers. The same `onError` option is also available on `withSupabaseRoute()`.

## CORS

The Express adapter does not handle CORS — the `cors` option is excluded from its config type. Use the [`cors`](https://www.npmjs.com/package/cors) npm package:

```ts
import cors from 'cors'
import express from 'express'
import { withSupabase } from '@supabase/server/adapters/express'

const app = express()

app.use(cors())
app.use(withSupabase({ auth: 'user' }))

app.get('/todos', async (_req, res) => {
const { supabase } = res.locals.supabaseContext
const { data } = await supabase.from('todos').select()
res.json(data)
})

app.listen(3000)
```

## Request body forwarding

The adapter forwards request bodies for non-`GET`/`HEAD` methods so `createSupabaseContext` can read whatever it needs from the request. It works in two scenarios:

- **No body parser registered** — the raw `IncomingMessage` stream is forwarded directly.
- **A body parser (e.g., `express.json()`) ran** — the parsed `req.body` is re-serialized.

You do not need to register `express.json()` just to make auth work; mount it only if your route handlers consume `req.body`.

## Environment overrides

Pass `env` to override auto-detected environment variables, same as the main wrapper:

```ts
app.use(
withSupabase({
auth: 'user',
env: { url: 'http://localhost:54321' },
}),
)
```

## Supabase client options

Forward options to the underlying `createClient()` calls:

```ts
app.use(
withSupabase({
auth: 'user',
supabaseOptions: { db: { schema: 'api' } },
}),
)
```

## Trust proxy

When deploying Express behind a reverse proxy (Vercel, Fly, Heroku, an nginx ingress, etc.), enable `trust proxy` so the adapter composes the correct absolute URL from `X-Forwarded-Proto` and the forwarded host. The adapter reads `req.protocol`, which already honors this setting:

```ts
app.set('trust proxy', true)
app.use(withSupabase({ auth: 'user' }))
```

## TypeScript

`res.locals.supabaseContext` is typed via Express's declaration-merged `Express.Locals` namespace, so it's available on every `Response` after the middleware has run:

```ts
import type { Request, Response } from 'express'

function handler(_req: Request, res: Response) {
const { supabase, authMode } = res.locals.supabaseContext // fully typed
// ...
}
```

If you need to extend the adapter's config (e.g., wrap it in your own factory), import the `WithSupabaseExpressConfig` and `ExpressAuthErrorHandler` types:

```ts
import type {
ExpressAuthErrorHandler,
WithSupabaseExpressConfig,
} from '@supabase/server/adapters/express'
```
14 changes: 4 additions & 10 deletions jsr.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,11 @@
"./core": "./src/core/index.ts",
"./adapters/hono": "./src/adapters/hono/index.ts",
"./adapters/h3": "./src/adapters/h3/index.ts",
"./adapters/elysia": "./src/adapters/elysia/index.ts"
"./adapters/elysia": "./src/adapters/elysia/index.ts",
"./adapters/express": "./src/adapters/express/index.ts"
},
"publish": {
"include": [
"src/**/*.ts",
"README.md",
"LICENSE"
],
"exclude": [
"src/**/*.test.ts",
"src/**/*.spec.ts"
]
"include": ["src/**/*.ts", "README.md", "LICENSE"],
"exclude": ["src/**/*.test.ts", "src/**/*.spec.ts"]
}
}
Loading