Supabase Middleware: composable, reusable middleware for Web Fetch handlers

Type safe, composable middleware for Web Fetch handlers. Write it once, use it on any fetch runtime or inside a framework.

Tomás Pozo, Katerina Skroumpelou ۸ دقیقه مطالعهمتن اصلی

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.

_12import { pipeline } from '@supabase/middleware'_12import { withCors } from '@supabase/middleware/cors'_12import { withSupabaseClient } from '@supabase/server/middleware/client'_12import { withRequiredClaims } from '@supabase/server/middleware/required-claims'_12_12export 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.

_16import { withSupabaseClient } from '@supabase/server/middleware/client'_16import { withRequiredClaims } from '@supabase/server/middleware/required-claims'_16import { Hono } from 'hono'_16_16import { toHono } from './lib/supabase-middleware.js'_16_16const 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_16export 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.

_14import { pipeline } from 'npm:@supabase/middleware@1'_14import { withOAuthProtectedResource, withSupabase } from 'npm:@supabase/server@1'_14_14export 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:

_10import { defineMiddleware } from '@supabase/middleware'_10_10const 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.

_23import { defineMiddleware, pipeline } from '@supabase/middleware'_23import type { JWTClaims } from '@supabase/server'_23import { withSupabaseClient } from '@supabase/server/middleware/client'_23import { withRequiredClaims } from '@supabase/server/middleware/required-claims'_23_23// Needs ctx.jwtClaims from an earlier entry, adds ctx.orgId_23const 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_23export 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.

_10npm install @supabase/middleware

منتشرشده در

Supabase Blog

خواندن در منبع

ادامه مطالعه