Skip to content

Supabase Middleware: composable, reusable middleware for Web Fetch handlers

15 Oct 2026

·

15 minute read

On October 2 at Supabase Select we shipped @supabase/middleware, composable, type-safe middleware for Web Fetch handlers. You can now write per-request logic once and run it on any runtime that supports fetch. The same middleware also works inside frameworks like Hono, H3, Elysia, NestJS, and TanStack Start. To use it in a framework, you copy a short bridge file into your project. @supabase/middleware is free, MIT-licensed, and built for TypeScript developers who write fetch handlers.

Why we built Supabase Middleware#

@supabase/server has covered the setup step since May: one wrapper gives your handler a client scoped to the caller, an admin client, and verified identity and claims. But your handler still carries its own per-request logic on top: checking a flag, handling CORS, deciding what a request needs before your code runs. That logic stays stuck in the handler where you wrote it.

Move to another runtime, and you write it again. Adopt a framework, and you rewrite it as that framework's middleware, and it stays there. Start a second project and you copy the file, or your coding agent writes it from scratch, a little different each time. Every version is another chance for a security mistake.

We wanted that logic to live in one place and go wherever your handler goes. So we built Supabase Middleware. You define each piece once, compose them in order, and use them on any fetch runtime or inside a framework.

How Supabase Middleware works#

Here's what that looks like in a handler. The setup from @supabase/server and the logic you add on top become entries in the same list.


_12
import { pipeline } from '@supabase/middleware'
_12
import { withCors } from '@supabase/middleware/cors'
_12
import { withSupabaseClient } from '@supabase/server/middleware/client'
_12
import { withRequiredClaims } from '@supabase/server/middleware/required-claims'
_12
_12
export default {
_12
fetch: pipeline([withCors({}), withRequiredClaims(), withSupabaseClient()], async (req, ctx) => {
_12
// ctx.supabase is scoped to the caller
_12
const { data } = await ctx.supabase.from('todos').select()
_12
return Response.json(data)
_12
}),
_12
}

A request goes through the entries in order. withCors answers the preflight and adds CORS headers to responses. withRequiredClaims verifies the caller's JWT; if it's invalid, the chain stops there and the caller gets a 401. withSupabaseClient adds ctx.supabase, a client scoped to that caller. Then your handler runs, and all it contains is the part that's yours.

Each entry does one job and doesn't know about the handler it sits in. It adds one typed key to a shared ctx, and your handler sees everything added before it, with the right types. An entry can also return a Response and stop the chain, or read and change the response on its way out. That's why you can move an entry to another handler without rewriting it.

What Supabase Middleware ships#

@supabase/middleware is the engine behind the pipeline above, and withSupabase in @supabase/server runs on it. @supabase/server also ships the middleware below as standalone entries. Two of them, withSupabaseClient and withSupabaseAdminClient, are the same pieces withSupabase is built from. The engine adds withCors and withFeatureFlag.

MiddlewareWhat it does
withCorsHandles CORS for the request
withFeatureFlagAdds ctx.featureFlag, admits when the flag is on, returns 404 otherwise
withClaimsAdds ctx.jwtClaims, null when the request has no token. Anonymous requests pass through. An invalid token still gets a 401
withRequiredClaimsVerifies the caller's JWT and adds ctx.jwtClaims. Rejects with a 401 if it's invalid
withSupabaseClientAdds ctx.supabase, a Supabase client scoped to the caller
withSupabaseAdminClientAdds ctx.supabaseAdmin, a service-role client that bypasses RLS
withPostgresClientAdds ctx.postgres, a Postgres connection with RLS applied. The caller's claims apply only within the transaction
withPostgresAdminClientAdds ctx.postgresAdmin, a Postgres connection that bypasses RLS. Queries run as the role in the connection string
withOAuthProtectedResourceAnswers the OAuth protected-resource metadata request for MCP clients and adds an OAuth challenge header to a 401

Most of these aren't new capabilities. ctx.supabase, for example, has been documented as RLS-scoped since May. What's new is the engine. Every piece is an entry in the same list, so you can add one, drop one, or write your own, and the same list goes wherever your handler goes.

Where Supabase Middleware runs#

You can pick the runtime or framework that fits the job and change it later without rewriting your per-request logic. This section shows how that works on other runtimes and inside a framework.

Export the pipeline as a fetch handler#

Moving to another runtime doesn't mean rewriting your logic. That matters when your code doesn't all run in one place. Say some of your functions run on Supabase Edge Functions and others on Vercel Functions or Cloudflare Workers, and the auth and CORS rules should match on both. You write those rules once, and each runtime runs them.

This works because the pipeline in the example above is a standard fetch handler: a function that takes a Request and returns a Response. Supabase Edge Functions, Vercel Functions, Cloudflare Workers, Deno, Bun, and Node 22 or newer can all run one, and the engine is plain Web Fetch.

Using the engine doesn't tie you to Supabase. It needs no database, no Supabase project, and no account, and it's MIT-licensed. So if you leave Supabase, the engine and everything you wrote with it go with you.

Note that one middleware doesn't run everywhere. withPostgresClient and withPostgresAdminClient don't run on Cloudflare Workers, because pg needs a TCP socket. The limit comes from pg. The engine itself runs wherever fetch does. On Workers, use withSupabaseClient and ctx.supabase instead.

Inside a framework with a bridge#

Adopting a framework doesn't require rewriting your logic either. For example, say your Edge Functions are plain fetch handlers, your API runs on Hono, and the same auth and CORS rules should apply to both.

Each framework has its own middleware format and its own context object, so middleware written for one doesn't work in another. Without a bridge, adopting a framework means rewriting your logic as that framework's middleware. A bridge removes that step. It's a short file you copy into your project, not a package, so the only thing to install is @supabase/middleware. It runs the middleware you already wrote inside the framework's own middleware slot, and the file is yours to edit.

Bridges for Hono, H3, Elysia, NestJS, and TanStack Start are available now, each typechecked with a minimal example app. Here's the Hono version. Copy hono/supabase-middleware.ts into your project, then pass toHono the same array of entries you'd give pipeline on Edge Functions. The entries stay as you wrote them, and the bridge is the only new piece.


_16
import { withSupabaseClient } from '@supabase/server/middleware/client'
_16
import { withRequiredClaims } from '@supabase/server/middleware/required-claims'
_16
import { Hono } from 'hono'
_16
_16
import { toHono } from './lib/supabase-middleware.js'
_16
_16
const app = new Hono()
_16
.use('*', toHono([withRequiredClaims(), withSupabaseClient()]))
_16
.get('/todos', async (c) => {
_16
const { data, error } = await c.var.supabase.from('todos').select()
_16
if (error) return c.json({ error: error.message }, 500)
_16
return c.json(data)
_16
})
_16
.get('/me', (c) => c.json({ id: c.var.jwtClaims.sub }))
_16
_16
export default { fetch: app.fetch }

Hono has its own middleware, which stays in Hono. Middleware written on the fetch standard works there too, and in any other framework with a bridge.

Build with the same engine we use#

We built the Supabase middleware with defineMiddleware, the same public API you get, and no private API sits behind it.

Give your app its own MCP server#

Say your users want to use your app from their own agent. That means an MCP server for your app, and an MCP server needs authorization: the OAuth protected-resource flow. It takes a metadata route, its preflight, a challenge header on the right responses, and URLs that match what the client called. Every part has to match. If one part is wrong, nothing errors, and the client fails to connect.

With Supabase Middleware, that whole flow is one entry. withOAuthProtectedResource handles OAuth discovery for MCP clients, and withSupabase({ auth: 'user' }) verifies the user's token and hands your handler a client scoped to them.


_14
import { pipeline } from 'npm:@supabase/middleware@1'
_14
import { withOAuthProtectedResource, withSupabase } from 'npm:@supabase/server@1'
_14
_14
export default {
_14
fetch: pipeline(
_14
// 1. OAuth discovery for MCP clients, 2. verify the user's token and scope a client to them
_14
[withOAuthProtectedResource(), withSupabase({ auth: 'user' })],
_14
async (req, { supabase }) => {
_14
// RLS scopes this query to the signed-in user
_14
const { data } = await supabase.from('tasks').select('id, title')
_14
return Response.json(data)
_14
}
_14
),
_14
}

withOAuthProtectedResource answers the metadata request before the auth check, and on the way back out it adds the OAuth challenge header to a 401. Because the client is scoped to the signed-in user, every tool call runs as that user, so your tools can be plain queries. We checked this in a working MCP server on Edge Functions, and the Bring your own MCP guide builds a full one on the same two entries.

Your RLS policies apply to an agent the same way they apply to a browser session. You don't need to copy them into each tool, and when a policy changes, the agent's access changes with it.

Write your own middleware#

Supabase doesn't ship everything your handlers need to do on each request. Your own auth flow, the way you call another service, and telemetry are per-request logic too, and without middleware you copy them from handler to handler. defineMiddleware turns each one into an entry you write once and use anywhere in a pipeline. It works like the ones we ship, because it's the same API we use.

You give it a key and a run function. run takes your config and returns the function that handles each request, and that function returns an object with your key. Here's one that adds a request ID:


_10
import { defineMiddleware } from '@supabase/middleware'
_10
_10
const withRequestId = defineMiddleware<'requestId', void, Record<never, never>, string>({
_10
key: 'requestId',
_10
run: () => async (req) => ({
_10
requestId: req.headers.get('x-request-id') ?? crypto.randomUUID(),
_10
}),
_10
})

The type arguments are the key, the config, what the middleware needs from earlier middleware, and what it adds to ctx. The types catch mistakes before you ship, and a mistake in per-request logic is easy to ship and slow to find. A duplicate key fails to compile, and so does a middleware placed before the one it needs. These checks are type-level, so they need TypeScript 5.4 or newer. Plain JavaScript gets the same runtime behavior without them.

An example custom middleware would be to extract the org ID from the caller's JWT.


_23
import { defineMiddleware, pipeline } from '@supabase/middleware'
_23
import type { JWTClaims } from '@supabase/server'
_23
import { withSupabaseClient } from '@supabase/server/middleware/client'
_23
import { withRequiredClaims } from '@supabase/server/middleware/required-claims'
_23
_23
// Needs ctx.jwtClaims from an earlier entry, adds ctx.orgId
_23
const withOrg = defineMiddleware<'orgId', void, { jwtClaims: JWTClaims }, string>({
_23
key: 'orgId',
_23
run: () => async (_req, ctx) => {
_23
const orgId = ctx.jwtClaims.app_metadata?.org_id
_23
if (typeof orgId !== 'string') {
_23
return Response.json({ error: 'No organization' }, { status: 403 })
_23
}
_23
return { orgId }
_23
},
_23
})
_23
_23
export default {
_23
fetch: pipeline([withRequiredClaims(), withOrg(), withSupabaseClient()], async (_req, ctx) => {
_23
const { data } = await ctx.supabase.from('projects').select().eq('org_id', ctx.orgId)
_23
return Response.json(data)
_23
}),
_23
}

Share your middleware across projects as a package#

Your logic isn't tied to one project either. Instead of copying a file into the next project, you publish your middleware as a package and install it. Logic in a package has a version, so when you change your auth flow or add telemetry, you publish a release instead of editing every repo.

For example, @supabase-labs/middleware-openfeature is a vendor-neutral package that depends only on the engine and @openfeature/core. We wrote it outside the engine repo from the authoring guide, with the same defineMiddleware as the rest.

Get started#

Install the package, then try the MCP server example.


_10
npm install @supabase/middleware

Ask ChatGPTAsk Claude

Build in a weekend, scale to millions