Uh oh!
There was an error while loading. Please reload this page.
feat(errors): add self-identifying errors with hints and diagnostics - #130
feat(errors): add self-identifying errors with hints and diagnostics#130johnstonmatt wants to merge 3 commits into
Conversation
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.
0d530f1 to
517dbaeComparecommit: |
| --- | ||
| ## Error Code Constants |
There was a problem hiding this comment.
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"
There was a problem hiding this comment.
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]({ |
There was a problem hiding this comment.
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!
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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.
mandarini
commented
Aug 28, 2026
@johnstonmatt One follow-up, since |
| }, | ||
| ), | ||
| [CreateSupabaseClientError]: (options?: { cause?: unknown }): AuthError => |
There was a problem hiding this comment.
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 | |
There was a problem hiding this comment.
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. |
There was a problem hiding this comment.
I think this should be version 1.5
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.SupabaseServerError, a shared base carryingsource, a[@supabase/server]message prefix, adocslink, an optionalhint, and non-sensitivedetails; add a singleerrorResponse()helper that renders it everywhere (with anx-supabase-server-errorheader exposed via CORS), while keeping top-levelmessage/codeunchanged for existing consumersverifyUserJwtand the auth mode chain report the specific reason a request failed instead of a generic invalid-credentials fallback, addingMISSING_CREDENTIALS,INVALID_API_KEY,INVALID_JWT,JWKS_NOT_CONFIGURED,JWKS_FETCH_FAILED, andNO_KEYS_CONFIGURED, and surfacing server misconfiguration as 500 instead of 401withClaims,withRequiredClaims,withPostgresClient, andwithPostgresAdminClientthe same specific, hinted errors instead of hand-rolled bodieserrors: { detailed: false }towithSupabaseto trim response bodies to justcodeandmessagewhile leaving status, the error header, and the in-process error object untouchedUNUSABLE_CREDENTIALout fromMISSING_CREDENTIALSso a credential that arrived but couldn't be used (wrong kind or unreadableAuthorizationheader) is distinguishable from one that never arrived, via a shareddiagnoseAuthorizationHeaderclassifier used by bothverifyAuthand thewithRequiredClaimsgatedetailsnever carries secret material — API keys are reported by prefix format and named keys by name onlydocs/error-handling.mdand updatedocs/api-reference.md/docs/postgres.mdto document the new error classes, codes, and response-trimming behavior