@supabase/server - v1.7.0
    Preparing search index...

    Error Handling

    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. code and message still describe the failure specifically. To disclose nothing, format the response yourself with createSupabaseContext (see Custom error formatting).

    Error
    └── SupabaseServerErrorcatch this for anything from @supabase/server
    ├── EnvErroralways status 500
    └── AuthErrorstatus 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 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".
    • 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 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.
    • 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, 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.

    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_role key, where an sb_publishable_… / sb_secret_… key is expected
    • a value that isn't a Supabase API key at all

    When 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.

    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
    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.

    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_CREDENTIALS and Errors[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 null rather than erroring, and surfaces here. SUPABASE_JWKS must be valid JSON; SUPABASE_JWKS_URL must be https (plain http is only accepted for loopback hosts, so the Supabase CLI works against http://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. hint points at withPostgresAdminClient if bypassing RLS is intended.
    • any other custom role — not supported yet; details.supportedRoles lists what is.
    • a non-string role claim — 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. 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.

    As 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.

    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 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
    }
    }