Files
supabase__server/docs/error-handling.md
Matt Johnston b8b086c021 feat(errors): add self-identifying errors with hints and diagnostics (#130)
* 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
2026-08-31 17:16:38 +03:00

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. code and message still describe the failure specifically. To disclose nothing, format the response yourself with createSupabaseContext (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 the Authorization header where a user JWT is required. The Supabase SDK sends the key in both the apikey and Authorization headers, so this is easy to hit by accident. details.received.authorization is "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 unauthenticated supabase-js call to a user-only endpoint looks like: the publishable key rides both headers, but no session token does. It's reported here rather than as INVALID_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, or Bearer with an empty token. details.received.authorization is "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_role key, where an sb_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_CREDENTIALS and Errors[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 null rather than erroring, and surfaces here. SUPABASE_JWKS must be valid JSON; SUPABASE_JWKS_URL must be https (plain http is only accepted for loopback hosts, so the Supabase CLI works against http://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. hint points at withPostgresAdminClient if bypassing RLS is intended.
  • any other custom role — not supported yet; details.supportedRoles lists what is.
  • a non-string role claim — 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
  }
}