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). 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)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 |
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.
A credential arrived, but not one any accepted auth mode can use. Three shapes:
- Wrong kind. An
sb_*API key in theAuthorizationheader where a user JWT is required. The Supabase SDK sends the key in both theapikeyandAuthorizationheaders, so this is easy to hit by accident.details.received.authorizationis"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 unauthenticatedsupabase-jscall to auser-only endpoint looks like: the publishable key rides both headers, but no session token does. It's reported here rather than asINVALID_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, orBearerwith an empty token.details.received.authorizationis"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.
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_rolekey, where ansb_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.
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.
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_CREDENTIALSandErrors[InvalidCredentialsError]()remain exported and working.
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
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.
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.
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.
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.hintpoints atwithPostgresAdminClientif bypassing RLS is intended.- any other custom role — not supported yet;
details.supportedRoleslists what is. - a non-string
roleclaim — a misconfigured custom-claims hook.
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.
Generic 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 |
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.
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.
Set SUPABASE_PUBLISHABLE_KEY, or add a "default" entry to SUPABASE_PUBLISHABLE_KEYS, or pass env.publishableKeys.
As MISSING_PUBLISHABLE_KEY, for SUPABASE_SECRET_KEYS / env.secretKeys.
Set SUPABASE_SECRET_KEY, or add a "default" entry to SUPABASE_SECRET_KEYS, or pass env.secretKeys.
withOAuthProtectedResource is running outside Supabase Edge Functions, where it can't derive the resource URL from the request. Pass resourceServer — hint shows the shape.
As above for the authorization server. Pass authorizationServer, use fromSupabaseUrl(...) for Supabase Auth, or set SUPABASE_PUBLIC_URL / SUPABASE_URL.
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.
Generic 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 |
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.
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
}
}