* docs: add MCP server guide and surface the OAuth entry point in the skill
docs/mcp.md explains the pipeline form for MCP servers
(withOAuthProtectedResource before the withSupabase gate), how the
public URLs are derived on Edge Functions and configured elsewhere, the
Supabase Auth prerequisites, the inline-handler inference gotcha, and the
stateless limits.
The agent skill gains the @supabase/server/oauth-protected-resource entry
point and a docs-table row pointing at the new page.
docs(mcp): drop the private sandbox link, match the guide title
docs(mcp): address review on the MCP guide
- CLI 2.117.0 injects SUPABASE_FUNCTION_SLUG only; SUPABASE_PUBLIC_URL is
honored when set but not set by the CLI.
- The asymmetric-key requirement comes from withSupabase verifying user
JWTs against the JWKS, not from OAuth 2.1.
- Document OAuthProtectedResourceConfig, UrlOption, fromSupabaseUrl and the
escape hatches in api-reference.md, and link there.
- Show withSupabase<Database> and a writing tool, the onerror hook for
factory errors, the OPTIONS preflight row, the root-path-without-slug
case, and that the one-shot limits belong to the handler, not the library.
* docs: list mcp.md in README and complete the metadata row
---------
Co-authored-by: Katerina Skroumpelou <sk.katherine@gmail.com>
Add a short section to docs/auth-modes.md and a README FAQ entry stating
that old-format anon/service_role JWT-style API keys and legacy HS256
user JWTs are rejected, and that projects must migrate to the new
sb_publishable_*/sb_secret_* key format and JWT signing keys to use
this library.
Claude-Session: https://claude.ai/code/session_013Xa3WdzF2qAy1ndnvaGQVK
Co-authored-by: kallebysantos <kalleby_santos@hotmail.com>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
* fix: only accept 'none' as the last auth mode
`auth` took `AuthModeWithKey[]`, which let `'none'` sit anywhere in the
list. It matches every request, so any mode after it is unreachable, and
a list of just `['none']` says nothing that a bare `auth: 'none'` doesn't
— both were accepted silently.
`AuthConfig` moves that rule into the type: a list is one or more
credentialed modes, optionally followed by `'none'`. The optional-user
form `['user', 'none']` is unaffected, which is what the e2e apps and the
docs use.
The deprecated `allow` keeps the looser element type so code mid-migration
still compiles, so `requiresCredentials` still scans the whole list rather
than checking the last entry.
* fix: name the unwrapped single mode in AuthConfig
AuthConfig reached the single-mode form through AuthModeWithKey, which
also carries 'none'. Splitting it into `'none' | CredentialedAuthMode`
makes the union say what it accepts — a bare mode, an ordered list, or
'none' — rather than leaving a reader to subtract 'none' from one member
and find it again in another.
The type is equivalent to what it replaced, so no call site moves; what
changes is that the unwrapped form is now stated, tested and documented
instead of inherited.
* feat: specific, self-identifying errors with hints and diagnostics
Nearly every failure returned `{ message: "Invalid credentials", code:
"INVALID_CREDENTIALS" }` — naming neither the cause nor the library it
came from.
Provenance. All errors now share a `SupabaseServerError` base carrying
`source: "@supabase/server"`, a `[@supabase/server]` message prefix (the
convention `deprecation.ts` already used for warnings), a `docs` link to
the matching `docs/error-handling.md` section, an optional `hint`, and
non-sensitive `details`. `toJSON()` renders the wire payload and is picked
up by `JSON.stringify`, so logging no longer yields `{}`. One
`errorResponse()` helper renders it everywhere, repeating the code in an
`x-supabase-server-error` header and adding that to
`Access-Control-Expose-Headers` so cross-origin callers can read it.
Top-level `message` and `code` are unchanged, so existing consumers and
the adapters keep working.
Diagnosis. `verifyUserJwt` now returns *why* a token failed instead of
`null`, and the mode chain records why each mode fell through, so the
final error names the real cause: `MISSING_CREDENTIALS`,
`INVALID_API_KEY`, `INVALID_JWT`, plus `JWKS_NOT_CONFIGURED`,
`JWKS_FETCH_FAILED` and `NO_KEYS_CONFIGURED` for states where no request
could ever have succeeded. `INVALID_CREDENTIALS` stays exported as the
fallback. Hints cover the mistakes people actually make — a secret key
sent to a publishable-only endpoint, a legacy anon/service_role key, an
`Authorization` header without the `Bearer` scheme, a JWT with no `kid`,
an expired token, a JWKS from the wrong project.
The middleware that answer directly get the same treatment rather than
their own hand-rolled bodies: `withClaims` / `withRequiredClaims` report
`MISSING_JWKS`, `MISSING_CREDENTIALS`, `INVALID_JWT`;
`withPostgresClient` / `withPostgresAdminClient` report
`MISSING_CONNECTION_STRING` and a catalogued `UNSUPPORTED_ROLE`.
`details` never carries key values or token payloads: API keys are
reported by prefix format, named keys by name, JWTs by `alg`/`kid` only.
Note: server misconfiguration now surfaces as 500 rather than 401. A
missing or unreachable JWKS, or an auth mode no configured key can match,
are not the caller's fault.
* feat: add `errors: { detailed: false }` to trim the error response body
`hint`, `docs`, and `details` are written for whoever is building against
the endpoint, and not everyone wants them on the wire. `errors.detailed`
(default `true`) reduces the body to `code` and `message` alone.
Provenance survives the trim: `message` keeps its `[@supabase/server]`
prefix, and the code is still sent as the `x-supabase-server-error`
header — so the error stays identifiable without the `source` field.
Response-only. The HTTP status is unaffected and the error object keeps
`hint`, `docs`, and `details` in full, so `createSupabaseContext` callers
and the framework adapters see everything.
Documented as a verbosity control rather than a security boundary — `code`
and `message` still name the failure specifically. Formatting the response
by hand via `createSupabaseContext` remains the way to disclose nothing.
* feat: distinguish UNUSABLE_CREDENTIAL from MISSING_CREDENTIALS
Review feedback on #130: the top-level code was `MISSING_CREDENTIALS`
even when a credential had arrived, just the wrong kind. `received.
authorization: 'api-key'` and the hint carried the diagnosis, but
`errors: { detailed: false }` strips both — leaving a caller who is
demonstrably sending a key staring at a bare `MISSING_CREDENTIALS`.
That mode makes the code the only thing a caller can rely on, so it has
to be true standing alone. `UNUSABLE_CREDENTIAL` (401) now covers "a
credential arrived that no accepted mode can use", partitioning the space
exactly against `MISSING_CREDENTIALS` ("nothing arrived"). It has two
shapes, named in the `message` so the diagnosis survives the trim:
- wrong kind: an `sb_*` API key in the Authorization header
- unreadable: wrong scheme, wrong casing, bare value, empty token
The unreadable shapes had the same defect and are fixed with it — a
`Basic` or lowercase-`bearer` header is not a missing credential either.
Classification moves into one shared `diagnoseAuthorizationHeader`, since
only the raw header separates "sent nothing" from "sent something
unreadable" and both `verifyAuth` and the `withRequiredClaims` gate need
that distinction. Previously the gate could not make it at all, so the
two disagreed on every scheme case. A parity matrix over all six header
shapes now pins gate and `withSupabase({ auth: 'user' })` to the same
status and code.
* fix: preserve error cause when client creation fails
* fix: report API keys as UNUSABLE_CREDENTIAL on user-only endpoints
Review feedback on #130: `supabase-js` sends the publishable key in both
the `apikey` and `Authorization` headers, so an unauthenticated browser
call to an `auth: 'user'` endpoint arrives with a key in each slot. The
`apikey !== 'absent'` branch in `explainFallthrough` was read first, so
the caller got `INVALID_API_KEY` — "check you are pointing at the right
Supabase project" — for a key that was never going to be looked up. With
`errors: { detailed: false }` the code is all they get, and it sent them
hunting for a key mismatch that does not exist.
`INVALID_API_KEY` means "matched none of the configured keys", which only
says something when a mode was doing that lookup. It is now gated on an
attempted `publishable` / `secret` mode; where no mode reads keys, a key
in either header is `UNUSABLE_CREDENTIAL` — not wrong, just the wrong
kind of credential. The apikey-header-only case had the same defect and
is fixed with it: "matched no key configured for auth mode(s): "user""
described a lookup that never happened.
The new diagnosis is shared as `apiKeyOnUserOnlyEndpoint`, so the
`withRequiredClaims` gate stops answering with "API keys belong in the
`apikey` header" for callers who already sent it there — that gate only
ever accepts a user JWT, so moving the key would not help. It keeps the
parity the gate is built for: an identical request, worded identically
from both paths. `ApiKeyInAuthorizationHeader` still covers the case
where the advice is right — a mixed `['user', 'publishable']` endpoint
with a key in `Authorization` alone.
* docs: add MissingConnectionStringError documentation and clarify credential error handling
Make the middleware work off Supabase Edge Functions. Previously it
hardcoded the Edge Functions proxy shape into its URL derivation, which is
wrong anywhere else, and took no configuration to correct it.
Adds `resourceServer` and `authorizationServer`, each a fixed string or a
function of the request, plus a `fromSupabaseUrl` adapter. Edge Functions
stays zero-config; off it, an unset option throws rather than advertising a
request-derived guess. `authorizationServer` falls back to
`SUPABASE_PUBLIC_URL` then `SUPABASE_URL` first, both rungs below the Edge
derivation so an internal gateway host can never displace the origin the
client used.
Also uses `SUPABASE_FUNCTION_SLUG` for a canonical `/functions/v1/{slug}`,
fixes three header-parsing bugs (comma-joined value, double port,
case-sensitive proto), and makes the advertised `resource` always equal the
URL the client called, as RFC 9728 §3.3 requires.
* refactor(middleware): rename withPostgres to withPostgresClient and harden it
Renames the export to sit alongside withSupabaseClient /
withSupabaseAdminClient, and extracts the pool into a shared core module so
the service-role companion can reuse it. Safe to rename now: the old name
exists only on 1.5.0-rc.* / beta, never on a stable release.
Three correctness fixes alongside it:
- The pool cache was keyed on nothing, so a second connectionString in the
same process silently queried the first database. Now keyed per string.
- The missing-connection-string 500 returned { error }, not the package's
standard { message, code }.
- An unguarded rollback in the catch could replace the caller's real error
with a connection error.
Adds unit coverage for each, plus a type-level check that composing without
an upstream jwtClaims stays a compile-time error.
* feat(middleware): add withPostgresAdminClient
Contributes ctx.postgresAdmin — a pg client that bypasses RLS, exported from
./middleware/postgres-admin. Queries run as-is under the connection-string
role: no claim injection, no role switch, no wrapping transaction.
Declares no upstream prerequisite, so unlike withPostgresClient it composes
under auth: 'secret' and auth: 'none'. Shares the pool cache with the scoped
half — same connection string, one pool. That is safe because everything the
scoped half sets is transaction-local, so a connection always returns clean.
Kept as a second middleware rather than a property on ctx.postgres:
defineMiddleware contributes exactly one ctx key, and the split keeps the RLS
bypass visible at the composition site.
* test(e2e): cover both postgres middleware against a real database
Adds /my-notes-pg and /all-notes-pg to the core Node app and the Deno edge
function, both running the identical unfiltered SELECT — one through
ctx.postgres, one through ctx.postgresAdmin. user2 sees none of user1's rows
through the scoped client and sees them through the admin one, which proves
claim injection, the role drop, and the bypass in a single contrast.
The edge function passes connectionString explicitly from E2E_DB_URL: the CLI
injects a SUPABASE_DB_URL addressing the database by container name, and
Deno's DNS resolver rejects the underscores in it. The Node app still covers
the SUPABASE_DB_URL default path.
* docs: document the postgres middleware pair
Adds docs/postgres.md covering both halves, the SQL each query runs, the two
composition paths, table grants, the RLS bypass and why it is a separate
middleware, and guidance to write policies with the auth.* helpers rather
than reading request.jwt.claim.* directly.
Wires both subpaths into typedoc entryPoints — without which neither export
reached api-docs/ — and adds README sections, Exports and env-var rows, and
api-reference entries.
* fix(middleware): discard the connection when a rollback fails
pg-pool only removes a client when release() is given a truthy argument, so
the previous release() returned a connection whose transaction could not be
unwound straight back to the pool — potentially still inside the caller's
transaction with their role set.
That was survivable while the pool served one middleware. It is not now that
withPostgresAdminClient shares it: that middleware begins no transaction and
sets up no session state, so it would silently inherit the leftover role on
the next checkout.
* fix(middleware): refuse unsupported roles instead of downgrading to anon
withPostgresClient silently mapped every role that was not 'authenticated'
to 'anon'. For a forged service_role that was the intent, but Supabase also
supports custom roles via the role claim, and RLS applies to those normally —
so a legitimate `role: manager` token was being answered with zero rows and
no indication that the role was the reason.
Now only 'authenticated' and 'anon' are assumed, and anything else
short-circuits with a 500 and code UNSUPPORTED_ROLE before the handler runs
or a connection is checked out. service_role gets a message pointing at
withPostgresAdminClient; other roles are named in the error.
Custom roles remain unsupported — the reason is that PostgREST connects as the
unprivileged authenticator, where `grant <role> to authenticator` is itself the
authorization, while we connect as postgres and have no such boundary to lean
on. Documented, and tracked separately.
Also hoists the per-request claims serialization out of the per-query path.
* docs: list every subpath in the README exports table
The table covered 8 of 13 entry points. Adding the postgres pair made the
omission look deliberate rather than incidental — a reader could reasonably
conclude withClaims has no subpath, which matters because it is the documented
prerequisite for composing withPostgresClient standalone.
* feat(middleware): make query a tagged template, add queryRaw and ident
`query` now takes a tagged template only, so every interpolation becomes a
bind parameter and can never alter the shape of the statement. `queryRaw(text,
params)` keeps the string form — it is fully safe with params, and it is the
only path that works for query builders and codegen emitting `{ sql,
parameters }`, or for SQL that has to interpolate an identifier.
Passing a plain string to `query` throws, naming `queryRaw`. The two calls
differ only in their brackets, so refusing beats reinterpreting: the string's
first character would otherwise be read as the whole template and a
one-character query would be sent.
`ident()` quotes identifiers, which can never be bind parameters — `select $1
from notes` selects a literal, not a column. It is implemented directly rather
than wrapping `pg.escapeIdentifier`: that top-level export only exists from
pg 8.11, while the peer range is `^8.0.0`, so a wrapper would be a runtime
TypeError on 8.0-8.5. It also rejects empty names and NUL bytes, which pg
passes straight through to a confusing server-side error.
`set local role` now quotes the role via `ident()`. The role is already
constrained to the SUPPORTED_ROLES allowlist, so this changes nothing today —
it keeps the interpolation safe if that list widens to the custom roles the
docstring promises.
Follows the prior art: Prisma shipped the dual overload and reversed it,
Slonik refuses plain strings outright, and postgres.js requires the tag with
`sql.unsafe` as the named escape hatch.
The e2e edge function built its query by interpolating a column list. As a
`query` tag that would have compiled to `select $1 from notes` and returned
the literal string for every row — valid SQL, wrong rows, no error. It now
uses `queryRaw`, with a comment explaining why.
* fix: refuse non-string role claims instead of downgrading to anon
* chore: keep prettier off the release-please changelog
---------
Co-authored-by: Katerina Skroumpelou <sk.katherine@gmail.com>
* feat: add plugins option to withSupabase
* fix: add plugins?: never to overload 1 to force correct overload resolution
TypeScript doesn't apply excess property checking during overload resolution,
so calls with plugins: [...] were silently matching overload 1 and typing ctx
as SupabaseContext<unknown>. Adding plugins?: never to overload 1's config
makes it definitively fail when plugins is present, falling through to the
correct overload.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* refactor: rename plugins option to middleware on withSupabase
The array holds middleware entries (per-request behavior from
defineMiddleware) — the word 'plugins' is reserved for the package-level
concept whose client namespace goes in createClient({ plugins }). One
word per concept: server-side composition is 'middleware', client-side
namespaces are 'plugins', a Plugin is the package that ships both.
PluginsCtx -> MiddlewareCtx; overload trick unchanged
(middleware?: never on overload 1).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(middleware): add postgres and claims middleware entrypoints
Graduate withPostgres and withClaims out of plugin-examples into
@supabase/server/middleware/*, so the PRFAQ's built-in middleware ship
from the package instead of example-local code (SDK-1163 item 5).
- withPostgres reads claims from ctx.jwtClaims (already populated by
withSupabase), so `middleware: [withPostgres()]` works with no separate
withClaims. Keeps the RLS role-clamp and tx-local request.jwt.claims
injection. pg is an optional peer dep (Node/Deno only, not Workers).
- withClaims ships for the standalone agnostic pipeline() case
(demo-only: no signature verification).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(middleware): surface table-grants hint on 42501 in withPostgres
Append the caller-role grants hint to permission-denied errors and document
the grants requirement in the withPostgres JSDoc.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* refactor: adopt @supabase/middleware importable getEnv API
Port from the @supabase/web-middleware PR-9 preview to
@supabase/middleware at main (0641674), which dropped ctx._runtime:
- withSupabase seeds the middleware chain via seedContext() instead of
faking a { _runtime } facet (the engine now marks contexts with a
symbol, so the structural fake no longer works)
- withPostgres defaults its connection string from the importable
getEnv('SUPABASE_DB_URL') instead of ctx._runtime.getEnv
- tests use vi.stubEnv for the env fallback; withClaims tests call the
handler as a bare fetch entry
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix: add explicit Middleware types to withClaims/withPostgres exports
JSR's slow-types check requires explicit types on public API symbols;
the inferred defineMiddleware return type failed 'Verify JSR packaging'.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* chore: rename allowBuilds key to @supabase/middleware
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* chore: adopt @supabase/middleware 0.3.0 from npm
Replaces the pkg.pr.new preview build with the released package.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(middleware): verify withClaims against the project JWKS
withClaims graduates from the demo-grade payload decoder to real
verification: the user-mode JWT leg of verifyCredentials moves into a
shared verifyUserJwt core (JWKS resolver caching, HS256 shared-secret
path, sb_* passthrough), used by both. No decode-only mode remains — an
invalid token short-circuits 401, a missing JWKS 500.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(middleware): add client and admin-client entrypoints
withSupabaseClient contributes ctx.supabase (RLS-scoped, caller's
token) and withSupabaseAdminClient contributes ctx.supabaseAdmin,
wrapping the existing createContextClient / createAdminClient
primitives. Composed under withSupabase they read the seeded
authMode / authKeyName to mirror verified credentials exactly;
standalone they work as plain engine entries.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* refactor: compose withSupabase on the engine clients
withSupabase now runs on @supabase/middleware for every request: the
two public client middleware fold around the user's middleware array
and handler, seeded with the verified auth identity via seedContext.
The host's second fetch argument (Workers env) is forwarded so
bindings reach getEnv. Public API, ctx keys, and error shapes are
unchanged — client-construction failures keep their historical JSON
responses (phase-guarded so handler throws still propagate), and the
existing test suite passes unmodified as the parity proof.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test: withClaims checking with different apikeys
* test: withSupabase checking middleware ctx is reflected at runtime
* refactor: keep same pattern while extracting test body
* test: withSupabase checking middleware array order impacts ctx dependency
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Kalleby Santos <kalleby_santos@hotmail.com>
* docs: clarify that bare secret/publishable auth matches only the 'default' key
Promote the skimmable default-key footnotes in docs/auth-modes.md to
prominent callouts that state explicitly that bare auth: 'secret' /
'publishable' matches ONLY the key named 'default' in the key set, with
no fallback to named keys. Also name the 'default' key in the README
publishable-key example comment.
Docs-only; no code behaviour change. Source-of-truth logic remains
keyName ?? 'default' in src/core/verify-credentials.ts.
* docs: name the key match in the README API-key-protected example
---------
Co-authored-by: Claude <noreply@anthropic.com>
* fix: apply Injectable programmatically so NestJS adapter loads in raw Node
The @Injectable() decorator on the guard in withSupabase() shipped
untranspiled (tsdown/oxc does not lower legacy decorators), crashing
`require`/`import` of the adapter with SyntaxError under plain Node.
Apply it as Injectable()(SupabaseAuthGuard) instead, and add a raw-Node
load smoke test (pnpm smoke) in CI to catch this class of regression.
Fixes#87
* fix: fail smoke test when no entrypoints are found in exports
adds generic Database type override to hono adapter
added test to ensure chained declarations preserve context type
updated docs to include alternative hono declaration
* docs: clarify bare auth mode matches only the default key
Bare `auth: 'publishable'` / `auth: 'secret'` match only the `default`
key in SUPABASE_PUBLISHABLE_KEYS / SUPABASE_SECRET_KEYS. This was
documented inconsistently — two spots wrongly claimed bare `secret`
accepts any key, and most tables/TSDoc omitted the detail entirely.
- Fix incorrect "accepts any secret key" statements in security.md and
the supabase-server skill (bare = default; `secret:*` = any)
- Name the `default` key directly in every auth-mode table (README,
auth-modes.md, security.md, src/index.ts TSDoc)
- Add the default-key sentence to the Secret mode section to match
Publishable, and clarify AuthMode / AuthModeWithKey TSDoc in types.ts
* docs: document default-key resolution for wildcard and client factories
Follow-up to the auth-mode default-key pass, closing two remaining gaps:
- Publishable mode section now mentions the `publishable:*` wildcard, so it
mirrors the Secret mode sentence exactly.
- Document which key createAdminClient / createContextClient use: the named
key when `auth.keyName` is set, otherwise `default` with a fallback to the
first key in the set. This was untouched by docs anywhere despite being
tested behavior, and it deliberately differs from the `secret` /
`publishable` auth modes, which match `default` only and never fall back.
* feat: explicit cors config shape ('default' | 'none' | { headers })
Keeps boolean/Record forms accepted but deprecated.
* refactor: rename cors 'none' to 'disabled'
Reads more clearly as an on/off switch and matches the SDK-1149 proposal.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>