* fix: reject invalid JWTs immediately instead of falling through to next auth mode Introduce an INVALID sentinel so tryMode can distinguish "credential present but failed" from "credential absent." The main loop now short- circuits on INVALID instead of silently trying the next allowed mode. Also covers the case where a JWT verifies cryptographically but has no sub claim (or sub isn't a string) -- previously returned null (fallthrough), now returns INVALID (reject). BREAKING CHANGE: when multiple auth modes are allowed, a present-but-invalid JWT is now rejected with InvalidCredentialsError instead of falling through to the next mode. Clients that previously relied on silent fallthrough (e.g., stale token + valid apikey) must now either omit the Authorization header or refresh the token. * docs: clarify invalid-JWT no-fallthrough semantics Align documentation with the behavior introduced in the fix!: commit on this branch. Make clear across user-facing docs, TSDoc, and SKILL that: - A mode is "tried" only when its credential is actually present, so a request with no Authorization header still falls through. - A JWT that is present but fails verification (malformed, expired, wrong signature, missing sub) rejects with InvalidCredentialsError — it does not silently fall through to another allowed mode. Touches README, docs/auth-modes, docs/security, docs/error-handling, docs/api-reference, skills/supabase-server/SKILL, and TSDoc on the Allow type, WithSupabaseConfig.allow, and verifyCredentials. --------- Co-authored-by: Tomas Pozo <tomaspozogarzon@gmail.com>
7.1 KiB
Error Handling
Error classes
The SDK has two error classes, both with status (HTTP code) and code (machine-readable string) properties.
EnvError
Thrown when a required environment variable is missing or malformed. Always status: 500 — these are server configuration issues, not client errors.
| 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 |
ENV_ERROR |
Generic environment error |
AuthError
Thrown when authentication or authorization fails. Status is 401 for invalid credentials, 500 for server-side auth failures.
| Code | Status | Meaning |
|---|---|---|
INVALID_CREDENTIALS |
401 | No credential matched any allowed auth mode, or a JWT was present but failed verification |
CREATE_SUPABASE_CLIENT_ERROR |
500 | Auth succeeded but client creation failed |
AUTH_ERROR |
401 | Generic authentication error |
How errors surface in each layer
Different layers of the SDK handle errors differently. Understanding which pattern each function uses prevents surprises.
| Function | Pattern | What happens on error |
|---|---|---|
withSupabase() |
Auto-response | Returns Response.json({ message, code }, { status }) with CORS headers |
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 |
The two client factory functions (createContextClient, createAdminClient) are the only ones that throw. Everything else returns a result tuple { data, error }.
Handling errors in withSupabase
withSupabase handles errors automatically. If auth fails, the caller receives a JSON response:
{ "message": "Invalid credentials", "code": "INVALID_CREDENTIALS" }
with the appropriate HTTP status code and CORS headers. Your handler never runs.
If you need custom error formatting, use createSupabaseContext instead:
import { createSupabaseContext } from '@supabase/server'
export default {
fetch: async (req: Request) => {
const { data: ctx, error } = await createSupabaseContext(req, {
allow: 'user',
})
if (error) {
// Custom error format
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 })
},
}
Handling errors in Hono
The Hono adapter throws an HTTPException when auth fails. Access the original AuthError via .cause:
import { Hono } from 'hono'
import { HTTPException } from 'hono/http-exception'
import { withSupabase } from '@supabase/server/adapters/hono'
const app = new Hono()
app.use('*', withSupabase({ allow: 'user' }))
app.onError((err, c) => {
if (err instanceof HTTPException && err.cause) {
const authError = err.cause
return c.json(
{ message: authError.message, code: authError.code },
err.status,
)
}
return c.json({ message: 'Internal error' }, 500)
})
Handling errors in core primitives
Result-tuple functions:
import { verifyAuth, resolveEnv } from '@supabase/server/core'
// verifyAuth returns { data, error }
const { data: auth, error } = await verifyAuth(request, { allow: 'user' })
if (error) {
return Response.json({ message: error.message }, { status: error.status })
}
// resolveEnv returns { data, error }
const { data: env, error: envError } = resolveEnv()
if (envError) {
console.error(`Config issue [${envError.code}]: ${envError.message}`)
}
Client factories throw — wrap them in try/catch:
import {
verifyAuth,
createContextClient,
createAdminClient,
} from '@supabase/server/core'
import { EnvError } from '@supabase/server'
const { data: auth, error } = await verifyAuth(request, { allow: 'user' })
// ... handle error ...
try {
const supabase = createContextClient({ auth: { token: auth!.token } })
const supabaseAdmin = createAdminClient()
} catch (e) {
if (e instanceof EnvError) {
console.error(`Config issue [${e.code}]: ${e.message}`)
return Response.json({ message: e.message }, { status: 500 })
}
throw e
}
Using the Errors factory map
The Errors object provides factory functions for creating error instances by code. Useful when building custom error handling or testing:
import {
Errors,
MissingSupabaseURLError,
InvalidCredentialsError,
} from '@supabase/server'
// Create specific errors
const envError = Errors[MissingSupabaseURLError]()
// → EnvError { message: "SUPABASE_URL is required but not set", code: "MISSING_SUPABASE_URL", status: 500 }
const authError = Errors[InvalidCredentialsError]()
// → AuthError { message: "Invalid credentials", code: "INVALID_CREDENTIALS", status: 401 }
Checking error types
import { AuthError, EnvError } from '@supabase/server'
try {
// ... some operation
} catch (e) {
if (e instanceof AuthError) {
// e.status is 401 or 500
// e.code is 'INVALID_CREDENTIALS', 'CREATE_SUPABASE_CLIENT_ERROR', or 'AUTH_ERROR'
}
if (e instanceof EnvError) {
// e.status is always 500
// e.code is one of the MISSING_* constants or 'ENV_ERROR'
}
}