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.
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, withPostgresAdminClient, withOAuthProtectedResource). The @supabase/server/middleware/* subpaths and @supabase/server/oauth-protected-resource are alpha; the error payload documented here is stable either way.
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)
Every middleware that answers directly accepts the same option and trims its own short-circuit responses: withClaims, withRequiredClaims, withPostgresClient, withPostgresAdminClient, withOAuthProtectedResource. The option is per entry; a pipeline passes it to each one.
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
└── 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 {}.
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_CREDENTIALSThe 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_CREDENTIALA credential arrived, but not one any accepted auth mode can use. Three shapes:
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".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.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_KEYAn 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:
publishable-only endpoint (or the reverse)anon / service_role key, where an sb_publishable_… / sb_secret_… key is expectedWhen the key is a configured key of the same kind, held under a name the attempted mode does not accept, the hint names that key and the modes that would accept it: secret:<name> for that key alone, or secret:* for any key in the set. details.matchedKeyName carries the name. Bare secret and publishable accept only the key named default, so a named key sent to a bare mode lands here.
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_JWTA 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 |
its aud / iss claim does not match |
Mistyped audience / issuer option, or another project |
it has no aud / iss claim |
The claim the configured option checks is absent from the token |
its nbf claim is in the future |
Server clock skew |
its <claim> claim is malformed |
Claim present with the wrong type, such as a non-numeric iat, nbf, or exp; not a Supabase Auth token |
its <claim> claim failed validation |
Any other check jose reports on a registered claim |
| a registered claim failed validation | jose reported a claim failure without naming the claim |
| 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_CREDENTIALSFallback 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_CONFIGUREDAuth 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_FAILEDThe 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_CONFIGUREDA 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_ROLEwithPostgresClient 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.details.supportedRoles lists what is.role claim — a misconfigured custom-claims hook.CREATE_SUPABASE_CLIENT_ERRORAuth 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_ERRORGeneric authentication error. The default code when constructing an AuthError yourself.
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_URLSet 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_KEYThe 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_KEYSet SUPABASE_PUBLISHABLE_KEY, or add a "default" entry to SUPABASE_PUBLISHABLE_KEYS, or pass env.publishableKeys.
MISSING_SECRET_KEYAs MISSING_PUBLISHABLE_KEY, for SUPABASE_SECRET_KEYS / env.secretKeys.
MISSING_DEFAULT_SECRET_KEYSet SUPABASE_SECRET_KEY, or add a "default" entry to SUPABASE_SECRET_KEYS, or pass env.secretKeys.
MISSING_RESOURCE_SERVERwithOAuthProtectedResource is running outside Supabase Edge Functions, where it can't derive the resource URL from the request. It answers every request with a 500, except the OPTIONS preflight on the metadata route. The resource URL backs the metadata document, the WWW-Authenticate challenge, and ctx.oauthProtectedResource alike. Pass resourceServer; hint shows the shape. withOAuthProtectedResource treats the environment as Edge Functions when SUPABASE_FUNCTION_SLUG or SB_EXECUTION_ID is set, or when the host runtime is Deno. details.runtime carries the runtime name the SDK detected.
The escape hatches resourceMetadataResponse and unauthorizedResponse throw this error rather than returning it. withSupabase does not catch it either. Its boundary answers only its own client-construction failures.
MISSING_AUTHORIZATION_SERVERAs above for the authorization server. Only the metadata document needs it, so the 500 is confined to GET …/oauth-protected-resource. Pass authorizationServer, use fromSupabaseUrl(...) for Supabase Auth, or set SUPABASE_PUBLIC_URL / SUPABASE_URL.
MISSING_CONNECTION_STRINGwithPostgresClient / 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_ERRORGeneric environment error. The default code when constructing an EnvError yourself.
| 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 or a missing connection string |
withPostgresAdminClient() |
Auto-response | Same payload, on a missing connection string |
withOAuthProtectedResource() |
Auto-response | Same payload, when a default URL cannot be derived (a configured URL function's throw propagates) |
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 |
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.
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 })
},
}
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)
})
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
}
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".'
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
}
}