* 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
22 KiB
Error Handling
Every error this library produces identifies itself and tells you what to do about it. An error carries:
| Field | Description |
|---|---|
source |
Always "@supabase/server" — which library produced this |
code |
Machine-readable code, e.g. MISSING_CREDENTIALS |
message |
Human-readable description, prefixed [@supabase/server] |
hint |
The actionable next step. Omitted when there isn't a useful one |
docs |
Link to the section of this page for code |
details |
Structured diagnostics — accepted auth modes, what the request carried, key names |
status |
HTTP status code (on the error object; not in the JSON body) |
details never contains secret material: no key values, no token payloads. API keys are reported by format ("secret", "publishable", "legacy-jwt"), named keys by name only, and JWTs by their public alg / kid header fields.
What a failure looks like
HTTP/1.1 401 Unauthorized
x-supabase-server-error: MISSING_CREDENTIALS
Access-Control-Expose-Headers: x-supabase-server-error
{
"source": "@supabase/server",
"code": "MISSING_CREDENTIALS",
"message": "[@supabase/server] No credentials found on the request. This endpoint accepts auth mode(s): \"user\", \"publishable\".",
"hint": "Send one of: Authorization: Bearer <jwt> (for auth mode \"user\"); apikey: <publishable key> (for auth mode \"publishable\").",
"docs": "https://github.com/supabase/server/blob/main/docs/error-handling.md#missing_credentials",
"details": {
"acceptedAuthModes": ["user", "publishable"],
"received": { "authorization": "absent", "apikey": "absent" }
}
}
The code is repeated in the x-supabase-server-error response header, and added to Access-Control-Expose-Headers so cross-origin browser code can actually read it.
Every layer that answers a request directly uses this shape: withSupabase, and the middleware that short-circuit (withClaims, withRequiredClaims, withPostgresClient).
Trimming the response body
hint, docs, and details are written for whoever is building against the endpoint. To keep them off the wire, set errors: { detailed: false } — the body reduces to code and message:
withSupabase({ auth: 'user', errors: { detailed: false } }, handler)
HTTP/1.1 401 Unauthorized
x-supabase-server-error: MISSING_CREDENTIALS
{
"code": "MISSING_CREDENTIALS",
"message": "[@supabase/server] No credentials found on the request. This endpoint accepts auth mode(s): \"user\"."
}
The status code and the x-supabase-server-error header are unaffected, and message keeps its [@supabase/server] prefix — so the error stays traceable without the source field. The error object itself is untouched: createSupabaseContext callers and the framework adapters still see hint, docs, and details in full.
This is a verbosity control, not a security boundary.
codeandmessagestill describe the failure specifically. To disclose nothing, format the response yourself withcreateSupabaseContext(see Custom error formatting).
Error classes
Error
└── SupabaseServerError ← catch this for anything from @supabase/server
├── EnvError ← always status 500
└── AuthError ← status 401 or 500
import { SupabaseServerError } from '@supabase/server'
try {
const supabase = createAdminClient()
} catch (e) {
if (e instanceof SupabaseServerError) {
console.error(e.code, e.message, e.hint, e.docs)
return Response.json(e.toJSON(), { status: e.status })
}
throw e
}
toJSON() returns the payload above, and is picked up automatically by JSON.stringify — so logging the error yields the full diagnostics instead of {}.
AuthError codes
Thrown when authentication fails. 401 means the request's credentials are at fault. 500 means the server is misconfigured — the request could not have succeeded no matter what it sent, so don't blame the caller.
| Code | Status | Meaning |
|---|---|---|
MISSING_CREDENTIALS |
401 | The request carried no credentials at all |
UNUSABLE_CREDENTIAL |
401 | A credential arrived, but not one any accepted mode can use |
INVALID_API_KEY |
401 | An apikey was sent but matched no configured key |
INVALID_JWT |
401 | A JWT was sent but failed verification |
INVALID_CREDENTIALS |
401 | Fallback when nothing more specific applies |
JWKS_NOT_CONFIGURED |
500 | A JWT was sent but no JWKS is configured to verify it |
JWKS_FETCH_FAILED |
500 | The remote JWKS could not be fetched or parsed |
NO_KEYS_CONFIGURED |
500 | An auth mode was requested that no configured key could ever match |
UNSUPPORTED_ROLE |
500 | The caller's role claim names a role withPostgresClient refuses |
CREATE_SUPABASE_CLIENT_ERROR |
500 | Auth succeeded but client creation failed |
AUTH_ERROR |
401 | Generic authentication error |
MISSING_CREDENTIALS
The request carried nothing: no apikey header, and no Authorization header at all.
details.acceptedAuthModes lists what the endpoint accepts; hint tells you exactly which header to send for each.
If something did arrive but couldn't be used, the code is UNUSABLE_CREDENTIAL instead. The two partition the space exactly, so the code alone tells you which situation you're in — which matters when errors: { detailed: false } strips hint and details.
UNUSABLE_CREDENTIAL
A credential arrived, but not one any accepted auth mode can use. Three shapes:
- Wrong kind. An
sb_*API key in theAuthorizationheader where a user JWT is required. The Supabase SDK sends the key in both theapikeyandAuthorizationheaders, so this is easy to hit by accident.details.received.authorizationis"api-key". - API key to an endpoint that reads none. Every accepted mode is
user, so an API key can't satisfy it in either header. This is what an unauthenticatedsupabase-jscall to auser-only endpoint looks like: the publishable key rides both headers, but no session token does. It's reported here rather than asINVALID_API_KEY— the key isn't wrong, it's the wrong kind of credential, and "check your project's keys" would send you hunting for a mismatch that doesn't exist. - Unreadable. A header this library can't read a bearer token out of — wrong scheme (
Basic …), wrong casing (bearer— the scheme is case-sensitive), a bare value with no scheme, orBearerwith an empty token.details.received.authorizationis"non-bearer-scheme".
The message names which one happened, so the diagnosis survives even with hint and details stripped. withRequiredClaims and withClaims report an identical request identically — they only ever accept a user token, so the second shape is the one they hit.
INVALID_API_KEY
An apikey header was present but matched none of the keys configured for the attempted modes. Only reported when a publishable or secret mode was actually attempted — on a user-only endpoint an API key is UNUSABLE_CREDENTIAL instead.
The hint prioritises format mismatches, since sending the wrong kind of key is the most common cause:
- a secret key sent to a
publishable-only endpoint (or the reverse) - a legacy JWT-style
anon/service_rolekey, where ansb_publishable_…/sb_secret_…key is expected - a value that isn't a Supabase API key at all
Otherwise the key was well-formed but simply unknown — usually a different Supabase project. details.configuredKeyNames lists the names configured for the attempted modes, and details.received.apikey gives the format of what you sent.
INVALID_JWT
A JWT was present in Authorization but failed verification. The message names the specific reason and hint explains it:
| Reason | Usual cause |
|---|---|
| the token has expired | Stale access token, or server clock skew |
| the signature did not verify | JWKS belongs to a different project |
no key in the JWKS matches the token's kid |
Wrong project, or a rotated signing key with stale JWKS |
its header is missing alg or kid |
Legacy JWT signed with the shared JWT secret |
it has no sub claim |
Not a user token — likely an anon / service_role JWT |
| a registered claim failed validation | nbf in the future, or a mismatched aud / iss |
| the token is malformed | Truncated, URL-encoded, or quoted token |
details.jwt carries the token's alg and kid — both client-supplied and public — which is what you need to debug a JWKS mismatch. Claim values are never included.
A present-but-invalid JWT rejects immediately rather than falling through to the next auth mode, so this code always wins over a later mode's failure.
INVALID_CREDENTIALS
Fallback code, returned when a credential was present but no more specific code applies.
Changed in v1.5. This used to be the only code returned for a failed request. The specific codes above now cover essentially every real failure, so match on those instead.
INVALID_CREDENTIALSandErrors[InvalidCredentialsError]()remain exported and working.
JWKS_NOT_CONFIGURED
Auth mode "user" was requested and a JWT was supplied, but no JWKS is configured — the token cannot be verified.
This is a 500, not a 401. The endpoint can never authenticate a user in this state.
Set SUPABASE_JWKS_URL (e.g. https://<project-ref>.supabase.co/auth/v1/.well-known/jwks.json) or SUPABASE_JWKS (inline JSON), or pass env.jwks.
A malformed value resolves to
nullrather than erroring, and surfaces here.SUPABASE_JWKSmust be valid JSON;SUPABASE_JWKS_URLmust behttps(plainhttpis only accepted for loopback hosts, so the Supabase CLI works againsthttp://localhost:54321).
withClaims / withRequiredClaims report this same code when they reach verification without a JWKS — they only get there with a token in hand, so the situation is identical. Their hint names their own jwks option instead of env.jwks, and details.middleware says which one asked.
JWKS_FETCH_FAILED
The remote JWKS endpoint could not be reached, timed out, or returned something unusable — so a token that may well be valid could not be verified.
A 500: an upstream outage is not the caller's fault. The underlying error is attached as cause.
NO_KEYS_CONFIGURED
A publishable or secret auth mode was requested, but no key it could match is configured. Covers both an empty key set and a named mode like publishable:mobile when no "mobile" key exists.
A 500 — that mode can never match any request. details.mode names the offending mode and details.configuredKeyNames lists what is configured.
This is only reported once every mode has been tried. With auth: ['publishable:mobile', 'secret'], a valid secret key still succeeds even though the first mode is unreachable.
UNSUPPORTED_ROLE
withPostgresClient will not assume the Postgres role the caller's verified role claim names, and refuses rather than silently running the query as anon — which would return zero rows and leave nothing to debug.
role: "service_role"— that role bypasses RLS, the guarantee this middleware exists to provide.hintpoints atwithPostgresAdminClientif bypassing RLS is intended.- any other custom role — not supported yet;
details.supportedRoleslists what is. - a non-string
roleclaim — a misconfigured custom-claims hook.
CREATE_SUPABASE_CLIENT_ERROR
Auth succeeded but createClient() failed — almost always a missing or malformed SUPABASE_URL or API key. The underlying error is attached as cause.
When the cause is an EnvError, its specific code (e.g. MISSING_DEFAULT_PUBLISHABLE_KEY) is preserved instead, along with that error's hint and details.
AUTH_ERROR
Generic authentication error. The default code when constructing an AuthError yourself.
EnvError codes
Thrown when a required environment variable is missing or malformed. Always status: 500.
| Code | Meaning |
|---|---|
MISSING_SUPABASE_URL |
SUPABASE_URL is not set |
MISSING_PUBLISHABLE_KEY |
Named publishable key not found in SUPABASE_PUBLISHABLE_KEYS |
MISSING_DEFAULT_PUBLISHABLE_KEY |
No default publishable key found |
MISSING_SECRET_KEY |
Named secret key not found in SUPABASE_SECRET_KEYS |
MISSING_DEFAULT_SECRET_KEY |
No default secret key found |
MISSING_RESOURCE_SERVER |
withOAuthProtectedResource cannot derive a resourceServer |
MISSING_AUTHORIZATION_SERVER |
withOAuthProtectedResource cannot derive an authorization server |
MISSING_CONNECTION_STRING |
No Postgres connection string is configured |
ENV_ERROR |
Generic environment error |
MISSING_SUPABASE_URL
Set SUPABASE_URL to your project URL (https://<project-ref>.supabase.co), or pass env.url. A local Supabase CLI stack uses http://localhost:54321.
MISSING_PUBLISHABLE_KEY
The requested named publishable key doesn't exist. The message and details.configuredKeyNames list which names are configured.
Add the entry to SUPABASE_PUBLISHABLE_KEYS — a JSON object of name → key — or pass env.publishableKeys.
MISSING_DEFAULT_PUBLISHABLE_KEY
Set SUPABASE_PUBLISHABLE_KEY, or add a "default" entry to SUPABASE_PUBLISHABLE_KEYS, or pass env.publishableKeys.
MISSING_SECRET_KEY
As MISSING_PUBLISHABLE_KEY, for SUPABASE_SECRET_KEYS / env.secretKeys.
MISSING_DEFAULT_SECRET_KEY
Set SUPABASE_SECRET_KEY, or add a "default" entry to SUPABASE_SECRET_KEYS, or pass env.secretKeys.
MISSING_RESOURCE_SERVER
withOAuthProtectedResource is running outside Supabase Edge Functions, where it can't derive the resource URL from the request. Pass resourceServer — hint shows the shape.
MISSING_AUTHORIZATION_SERVER
As above for the authorization server. Pass authorizationServer, use fromSupabaseUrl(...) for Supabase Auth, or set SUPABASE_PUBLIC_URL / SUPABASE_URL.
MISSING_CONNECTION_STRING
withPostgresClient / withPostgresAdminClient have no Postgres connection string to connect with, so they short-circuit with a 500 before running the handler.
Set SUPABASE_DB_URL, or pass connectionString to the middleware — details.middleware names which one asked. Supabase Edge Functions provide SUPABASE_DB_URL automatically; elsewhere, copy it from Project Settings → Database → Connection string.
ENV_ERROR
Generic environment error. The default code when constructing an EnvError yourself.
How errors surface in each layer
| Function | Pattern | What happens on error |
|---|---|---|
withSupabase() |
Auto-response | Returns the JSON payload above, with CORS and x-supabase-server-error |
withClaims() |
Auto-response | Same payload, short-circuiting the pipeline |
withRequiredClaims() |
Auto-response | Same payload, short-circuiting the pipeline |
withPostgresClient() |
Auto-response | Same payload, on an unsupported role claim |
createSupabaseContext() |
Result tuple | Returns { data: null, error: AuthError } |
verifyAuth() |
Result tuple | Returns { data: null, error: AuthError } |
verifyCredentials() |
Result tuple | Returns { data: null, error: AuthError } |
resolveEnv() |
Result tuple | Returns { data: null, error: EnvError } |
createContextClient() |
Throws | Throws EnvError |
createAdminClient() |
Throws | Throws EnvError |
withOAuthProtectedResource() |
Throws | Throws EnvError when required off Edge Functions and unconfigured |
Hono withSupabase() |
HTTPException | Throws HTTPException with cause: AuthError |
verifyAuth() also has the raw request in hand, so it adds diagnostics verifyCredentials() can't see — most usefully, an Authorization header that was present but unusable.
Custom error formatting
withSupabase responds for you. To shape the response yourself, use createSupabaseContext:
import { createSupabaseContext } from '@supabase/server'
export default {
fetch: async (req: Request) => {
const { data: ctx, error } = await createSupabaseContext(req, {
auth: 'user',
})
if (error) {
// Log everything, return only what the caller needs.
console.error(error.code, error.message, error.hint, error.details)
return Response.json(
{ success: false, error: { message: error.message, code: error.code } },
{ status: error.status },
)
}
const { data } = await ctx.supabase.from('todos').select()
return Response.json({ success: true, data })
},
}
Handling errors in Hono
The Hono adapter throws an HTTPException when auth fails. Access the original AuthError via .cause:
app.onError((err, c) => {
if (err instanceof HTTPException && err.cause instanceof AuthError) {
return c.json(err.cause.toJSON(), err.status)
}
return c.json({ message: 'Internal error' }, 500)
})
Handling errors in core primitives
import { verifyAuth, resolveEnv } from '@supabase/server/core'
const { data: auth, error } = await verifyAuth(request, { auth: 'user' })
if (error) {
return Response.json(error.toJSON(), { status: error.status })
}
const { data: env, error: envError } = resolveEnv()
if (envError) {
console.error(`[${envError.code}] ${envError.message}\n${envError.hint}`)
}
Client factories throw — wrap them in try/catch:
import { createContextClient } from '@supabase/server/core'
import { SupabaseServerError } from '@supabase/server'
try {
const supabase = createContextClient({ auth: { token: auth.token } })
} catch (e) {
if (e instanceof SupabaseServerError) {
console.error(e.code, e.message, e.hint)
return Response.json(e.toJSON(), { status: e.status })
}
throw e
}
Using the Errors factory map
Errors provides a factory per code, each returning a fully-populated error.
import {
Errors,
MissingSupabaseURLError,
MissingSecretKeyError,
} from '@supabase/server'
Errors[MissingSupabaseURLError]()
// → EnvError { code: 'MISSING_SUPABASE_URL', status: 500, hint: 'Set SUPABASE_URL to …' }
// Pass the configured names to get them into the message and details.
Errors[MissingSecretKeyError]('mobile', ['default', 'web'])
// → message: '… No "mobile" secret key found. Configured names: "default", "web".'
Checking error types
import { AuthError, EnvError, SupabaseServerError } from '@supabase/server'
try {
// ...
} catch (e) {
if (e instanceof SupabaseServerError) {
// Anything from @supabase/server. e.code, e.status, e.hint, e.docs, e.details
}
if (e instanceof AuthError) {
// e.status is 401 (bad credentials) or 500 (server misconfigured)
}
if (e instanceof EnvError) {
// e.status is always 500
}
}