Skip to content

feat(errors): add self-identifying errors with hints and diagnostics - #130

Open
johnstonmatt wants to merge 3 commits into
mainfrom
FUNC/improve-withSupabase-errors
Open

feat(errors): add self-identifying errors with hints and diagnostics#130
johnstonmatt wants to merge 3 commits into
mainfrom
FUNC/improve-withSupabase-errors

Conversation

@johnstonmatt

@johnstonmattjohnstonmatt commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

Nearly every failure used to return the same generic { message: "Invalid credentials", code: "INVALID_CREDENTIALS" }, naming neither the cause nor the source library. All errors now identify themselves and explain what to do next.

  • Introduce SupabaseServerError, a shared base carrying source, a [@supabase/server] message prefix, a docs link, an optional hint, and non-sensitive details; add a single errorResponse() helper that renders it everywhere (with an x-supabase-server-error header exposed via CORS), while keeping top-level message/code unchanged for existing consumers
  • Make verifyUserJwt and the auth mode chain report the specific reason a request failed instead of a generic invalid-credentials fallback, adding MISSING_CREDENTIALS, INVALID_API_KEY, INVALID_JWT, JWKS_NOT_CONFIGURED, JWKS_FETCH_FAILED, and NO_KEYS_CONFIGURED, and surfacing server misconfiguration as 500 instead of 401
  • Give withClaims, withRequiredClaims, withPostgresClient, and withPostgresAdminClient the same specific, hinted errors instead of hand-rolled bodies
  • Add errors: { detailed: false } to withSupabase to trim response bodies to just code and message while leaving status, the error header, and the in-process error object untouched
  • Split UNUSABLE_CREDENTIAL out from MISSING_CREDENTIALS so a credential that arrived but couldn't be used (wrong kind or unreadable Authorization header) is distinguishable from one that never arrived, via a shared diagnoseAuthorizationHeader classifier used by both verifyAuth and the withRequiredClaims gate
  • Ensure details never carries secret material — API keys are reported by prefix format and named keys by name only
  • Rewrite docs/error-handling.md and update docs/api-reference.md / docs/postgres.md to document the new error classes, codes, and response-trimming behavior

@johnstonmatt
johnstonmatt requested review from a team as code ownersAugust 27, 2026 15:21
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.
`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.
@johnstonmatt
johnstonmattforce-pushed the FUNC/improve-withSupabase-errors branch from 0d530f1 to 517dbaeCompareAugust 27, 2026 15:30
@johnstonmattjohnstonmatt changed the title feat(errors): specific, self-identifying errors with hints and diagnosticsfeat(errors): make withSupabase errors self-identifying with hintsAug 27, 2026
@pkg-pr-new

pkg-pr-newBot commented Aug 27, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@supabase/server@130

commit: 21dc29c

Comment threaddocs/api-reference.md

---

## Error Code Constants

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe we could add a "Possible Causes" or similar column to this table? So is easier for agents/users to think of a root case. Does not need to be very detailed, but for example, something like: "apikey or Authorization headers are missing"

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we can do that! do you think it is needed in addition to the other places this PR adds info, like docs/error-handling.md ?

{ status: 401 },
const { apikey } = extractCredentials(req)
return errorResponse(
Errors[MissingCredentialsError]({

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question: on this branch (and the matching one in verifyCredentials), the top-level code is MISSING_CREDENTIALS even though a credential did arrive, just the wrong kind. The received.authorization: 'api-key' details and the "API keys belong in the apikey header" hint cover the diagnosis. But with errors: { detailed: false } both get stripped, and the caller sees a bare MISSING_CREDENTIALS while they are definitely sending a key. Is one fallback code the deliberate trade-off to keep the catalogue small, or is "credential of the wrong kind" worth its own code? Not blocking either way. The hint is what matters for the Studio case!

@johnstonmattjohnstonmattAug 27, 2026

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it was an oversight on my part, we should have states that are uniquely identifiable by code, good catch! Fixed in 21dc29c.

What do you think about { detailed: false } more generally? I was worried some users might consider the verbose errors over-sharing, maybe even to a degree where it is perceived insecure. I did bundle it in a separate commit though, in case that fear is imagined and we don't want to maintain that config/code-path

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I say we keep it. Default true and the "verbosity control, not a security boundary" framing are both right, I think!

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.
@johnstonmattjohnstonmatt changed the title feat(errors): make withSupabase errors self-identifying with hintsfeat(errors): add self-identifying errors with hints and diagnosticsAug 27, 2026
@mandarini

Copy link
Copy Markdown
Collaborator

@johnstonmatt One follow-up, since detailed: false makes the code the only signal: supabase-js sends the publishable key in both apikey and Authorization: Bearer. On an auth: 'user' endpoint that request hits the apikey !== 'absent' branch in explainFallthrough first and returns INVALID_API_KEY ("check you're pointing at the right project"), never UNUSABLE_CREDENTIAL. The new tests only set the Authorization header. Could the key-in-Authorization check win whenever the mode list accepts no API keys?

Comment threadsrc/errors.ts
},
),

[CreateSupabaseClientError]: (options?: { cause?: unknown }): AuthError =>

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Small observation: The two middleware call sites that raise this (src/middleware/client/index.ts:52, src/middleware/admin-client/index.ts:48) still call Errors[CreateSupabaseClientError]() with no arguments, so error.cause is undefined there while the hint tells the reader to log it. create-supabase-context.ts:92 passes { cause: e }. I think these two should match.

| [`MISSING_DEFAULT_SECRET_KEY`](#missing_default_secret_key) | No default secret key found |
| [`MISSING_RESOURCE_SERVER`](#missing_resource_server) | `withOAuthProtectedResource` cannot derive a `resourceServer` |
| [`MISSING_AUTHORIZATION_SERVER`](#missing_authorization_server) | `withOAuthProtectedResource` cannot derive an authorization server |
| [`ENV_ERROR`](#env_error) | Generic environment error |

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MISSING_CONNECTION_STRING is missing from this table and has no ### section below, so the error.docs URL that docsFor() generates for withPostgresClient / withPostgresAdminClient points at an anchor that doesn't exist. It's also absent from the api-reference constants table.


Fallback code, returned when a credential was present but no more specific code applies.

> **Changed in v1.6.** 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.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this should be version 1.5

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants

@johnstonmatt@mandarini@tomaspozo