Skip to content

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

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

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

Conversation

@johnstonmatt

@johnstonmatt johnstonmatt 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 owners August 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
johnstonmatt force-pushed the FUNC/improve-withSupabase-errors branch from 0d530f1 to 517dbae Compare August 27, 2026 15:30
@johnstonmatt johnstonmatt changed the title feat(errors): specific, self-identifying errors with hints and diagnostics feat(errors): make withSupabase errors self-identifying with hints Aug 27, 2026
@pkg-pr-new

pkg-pr-new Bot commented Aug 27, 2026

Copy link
Copy Markdown

Open in StackBlitz

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

commit: d17b901

Comment thread docs/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
Contributor Author

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 ?

Comment thread src/middleware/required-claims/index.ts Outdated
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.
@johnstonmatt johnstonmatt changed the title feat(errors): make withSupabase errors self-identifying with hints feat(errors): add self-identifying errors with hints and diagnostics Aug 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 thread src/errors.ts
Comment thread docs/error-handling.md
Comment thread docs/error-handling.md Outdated
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.
Sign up for free to 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