Error Swallowing Anti-Patterns in better-auth#
Overview#
Several places in better-auth use blanket .catch(() => null) or catch (e) { logger.error(...) } patterns that silently convert infrastructure failures into misleading return values. The result: a database outage looks identical to "user not signed in," Stripe webhook failures look like successes, and SSR renders can crash with a RangeError that masks the real error.
Known Instances#
1. customSession Plugin — Infrastructure Errors Become null#
Location: packages/better-auth/src/plugins/custom-session/index.ts:107-109
The plugin calls the internal getSession() handler and unconditionally converts all rejections to null:
}).catch((e) => {
return null;
});
The core /get-session route already distinguishes between the two cases:
- No token / expired session → returns
nulllegitimately - DB failure → throws
APIError("INTERNAL_SERVER_ERROR")
By catching both, customSession collapses that distinction. A transient DB outage surfaces as null to every caller — triggering login redirects, cookie-cleanup hooks, and session invalidation for users who are actually authenticated .
Minimal fix (not yet merged as of 2026-08-03): re-throw APIError instances while keeping the null fallback for genuine no-session cases :
.catch((e) => {
if (isAPIError(e)) throw e;
return null;
});
Secondary instance: The same bare .catch(() => null) appears in the internal getSessionFromCtx helper at line 581-583, which is used across multiple routes and may warrant the same treatment .
2. TanStack Start SSR — APIError.status String Triggers RangeError#
Issue: #10132
When auth.api.getSession throws an APIError (on DB failure), TanStack Start's SSR error responder reads error.status as an HTTP status code. Because better-call's APIError stores the status name (e.g. "INTERNAL_SERVER_ERROR") on .status and the numeric code on .statusCode , the framework coerces the string to NaN, then passes it to the WHATWG Response constructor — which throws RangeError: The status provided (...) must be in the range of [200, 599]. The real failure is completely masked .
The documented TanStack Start integration snippet has no try/catch around auth.api.getSession, so users following the docs have no protection against this crash path .
Workaround (apply until docs are updated):
export const getSession = createServerFn({ method: "GET" }).handler(async () => {
const headers = getRequestHeaders();
try {
return await auth.api.getSession({ headers });
} catch (error) {
// APIError.status is a string; re-throw as plain Error so the SSR
// responder sees a valid numeric status (defaults to 500).
throw new Error("Session temporarily unavailable", { cause: error });
}
});
The maintainer closed the issue as an upstream h3 bug (h3js/h3#1397) . Better Auth's behavior — throwing APIError on backend failure — is intentional and documented. The APIError.status/.statusCode name/number split is a footgun for any framework that reads .status as a numeric HTTP code .
3. Stripe Webhook Hooks — DB Failures Silently Return 200 OK#
Location: packages/stripe/src/hooks.ts
All four Stripe event handlers (onCheckoutSessionCompleted, onSubscriptionCreated, onSubscriptionUpdated, onSubscriptionDeleted) wrap their entire body in a top-level try/catch that logs and swallows errors without re-throwing :
} catch (e: any) {
ctx.context.logger.error(`Stripe webhook failed. Error: ${e.message}`);
}
Because the functions return undefined after catching, the outer route handler returns 200 OK to Stripe. Stripe treats 2xx as successful delivery and will not retry the event. A database connection failure during a subscription update is silently discarded — no dead-letter queue, no retry .
Design Intent vs. Anti-Pattern#
Not every silent catch is a bug — the Expo SecureStore adapter and the Stripe customer email-sync hook intentionally suppress errors as explicit best-effort operations. The problematic anti-pattern emerges when silent suppression changes observable semantics for callers:
| Symptom | Impact |
|---|---|
Infrastructure error returns null | Callers can't distinguish "not signed in" from "DB down" |
| Error swallowed before HTTP response | Framework reads wrong status; real error is masked |
| Webhook acked before persistence succeeds | Processing failure invisible to the event source; no retry |
Related Issues & References#
- Issue #10566 —
customSessionerror swallowing - Issue #10132 — TanStack Start SSR
RangeError - better-auth error handling docs — intended
getSessionsemantics custom-session/index.ts— plugin source with the blanket catchapi/routes/session.ts— core session route with correctAPIErrordifferentiationstripe/src/hooks.ts— Stripe webhook event handlers