Authentication
Business routes make zero DB queries per request — a real JWT verified locally, not a session table. The cost is no instant revocation. Around that stateless contract — the two-step login, three server entry points, and the one place that must fall back to cookies.
Business routes make zero DB queries per request
On every request, Zapvol’s business routes (/api/*, except /api/auth) authenticate without touching the
database: the client carries a real JWT, the server verifies it locally with jose, and the session table is never
read. The JWT is issued by better-auth’s jwt plugin (EdDSA by default), its public keys served at /api/auth/jwks,
which jose fetches once and then keeps in memory. The cost is out in the open — no instant revocation: once issued,
a JWT stays valid for 30 days.
That stateless design buys one awkwardness: cookie and JWT don’t overlap in responsibility, so login takes two steps
before you hold a JWT. What follows lays out the contract — the two-step login, the three server entry points, why it’s
a real JWT rather than a session token, and the one place that must fall back to a cookie. The cookie session inside
/api/auth/* is better-auth’s own implementation and out of scope; this doc describes the contract the project builds
on top of it.
From login to first API call
Cookie and JWT have non-overlapping responsibilities: the cookie is only used inside /api/auth/*, where
better-auth itself relies on it to issue JWTs. Business routes (/api/* except /api/auth) only trust Bearer JWTs and
never read the cookie. This is why the flow has two steps — ① login writes the cookie, then ② getSession() must be
called explicitly to obtain the JWT. better-auth’s jwt plugin after-hook matches only the /get-session path; the
sign-in response itself does not carry set-auth-jwt, so a follow-up call is required.
Step ② — the await authClient.getSession() call — lives at auth-service.ts:23 (login) and :36 (register). The
login function must await this before returning. Otherwise the page redirect from useLogin().onSuccess fires the next
API request before the Bearer header is in localStorage, and every call 401s. Do not remove this line when editing the
login flow.
Step ③ runs through jose.jwtVerify at lib/auth-jwt.ts:35. jose handles the JWKS cache through createRemoteJWKSet:
the first request after boot fetches /api/auth/jwks (one PG hit), then reuses it for 10 minutes before refreshing.
Three server entry points
All three patterns share the same underlying primitive (lib/auth-jwt.ts:verifyJwt); they differ only in what the
wrapper layer does. The layering:
Pick the entry point that matches the response shape. Do not call verifyJwt manually except in contexts (like the WS
upgrade callback) where neither wrapper is available:
| Entry point | When to use | Where it’s used |
|---|---|---|
h({ auth: true }, handler) | Standard JSON CRUD. Return value auto-wrapped as c.json(ok(data)). | 12 routes (agents.ts, chats.ts, projects.ts, etc.) |
requireAuth middleware + raw c | Handler needs to touch c directly; response is not JSON. | tasks.ts /:id/stream /:id/messages, upload.ts, ai-text.ts streaming endpoints |
Raw verifyJwt(headers) | WebSocket upgrade — token arrives via ?token= query, header synthesized. | ws.ts:50 |
All three call lib/auth-jwt.ts:verifyJwt underneath. The differences come from what h() does beyond auth: body Zod
validation, return-value ok() wrapping, status code control. So:
- Returning a
Responseobject (SSE, file download) — cannot useh().h()would treat the Response as plain data and JSON-serialize it. - Body is
multipart/form-data— cannot useh()’sbody: schemaoption.h()internally callsc.req.json(), which throws on multipart. - WS upgrade callback has no
caccessible (it runs insideupgradeWebSocket’s factory). The raw primitive is the only option there.
The requireAuth middleware also calls RequestContext.set({ userId }) after verifying, matching h()’s behavior, so
the logger middleware picks up the userId tag on both paths.
Why real JWT and not Bearer-session-token
Three approaches were evaluated. Recording the rejected ones here to prevent the question “why not just use
better-auth’s bearer() plugin?” from re-surfacing.
| Approach | Per-request DB | Immediate revocation | Stateless | Why rejected |
|---|---|---|---|---|
| Cookie + DB session (original) | 1 PG query · session table | Yes | No | Cross-origin cookie unreliable under some browser / SameSite configs |
Bearer + session token (bearer() plugin) | 1 PG query · session table | Yes | No (token references a DB row) | Same DB cost; just swaps cookie for header without solving the problem |
| Bearer + real JWT (current) | 0 (jose local verify + JWKS in-memory cache) | No | Yes | (selected) — trades immediate revocation for zero DB queries + cross-service verifiability |
Concrete reasons for picking the current approach:
- Business routes account for ≥ 95% of request volume. Most handlers only use
user.id, which the JWT payload already carries. - Immediate revocation is not a hard requirement in an internal-tool context. Acceptable that deleting a
sessionrow leaves the JWT usable for up to 30 days. - Rotating
BETTER_AUTH_SECRETinvalidates every JWKS private key (they’re symmetrically encrypted by the secret) and forces every JWT to be re-issued — a nuclear option is available. - JWT is verifiable cross-service. If the app is later split, no central session DB is needed.
One exception: /api/mcp-oauth/authorize
This GET route is triggered by top-level navigation (window.location.href = ... at mcp-server-row.tsx:169,
mcp-server-detail.tsx:80, mcp-selector.tsx:233). Browser top-level navigation cannot attach an Authorization
header, so the only auth channel is the cookie.
auth.ts does not disable better-auth’s default cookie session, so the login response still includes
Set-Cookie: better-auth.session_token=.... This OAuth handoff relies on it. routes/mcp-oauth.ts:71 calls
auth.api.getSession({ headers }) directly to follow the cookie path, kept separate from the JWT path that all other
routes use.
Do not “clean this up” by switching to verifyJwt. Browser navigation cannot carry a Bearer header — the route will
return 401 the moment it’s converted. If you add new top-level-navigation GET routes that need auth, keep them on the
cookie path.
Out of scope
- No immediate revocation. Banning a user or forcing logout requires rotating
BETTER_AUTH_SECRET(invalidates every issued JWT, affects all users) or waiting for expiry. When per-user revocation becomes a hard requirement, add a Redis blocklist keyed byjti. Not built today. - No user-state caching in Redis. The
require-rolesmiddleware queriesuser_roleonce per admin request (~1 ms, admin routes only). Tier / subscription queries are similar — done at the service layer against PG when needed. This is a deliberate stateless decision: a Redis cache introduces invalidation complexity and a new failure mode. - JWT payload does not carry roles / tier. Both classes of attributes must take effect immediately, but a JWT payload is a snapshot from issuance time and only refreshes every 30 days. Authorization decisions always query PG. The JWT answers “who you are”, nothing else.
- Desktop does not use server auth.
apps/desktop/src/renderer/ipc/auth.tsis a local stub storing session in localStorage and never reaches the server. Out of scope here. - BUA extension has its own handshake. The
/ws/browserendpoint usespairingToken, not JWT. See BUA subsystem.
Troubleshooting
Symptom 1: After login, every API call returns 401
Check in this order:
- No
authTokenin localStorage — DevTools → Application → Local Storage. Empty means the token was never written. See next item. signIn.emailwas not followed byawait authClient.getSession()—auth-service.ts:23. Skipping this guarantees the symptom:set-auth-jwtonly appears on/get-sessionresponses, not sign-in responses.- CORS does not expose
set-auth-jwt—index.tscors config must listset-auth-jwtinexposeHeaders. Without it the header is present in the response but the browser hides it from JS. request.tsdoes not inject the Bearer header — Network tab, inspect any request’s Request Headers.Authorization: Bearer ...should be present.
Symptom 2: Server restart locks everyone out
Should not happen. If it does:
- Is
BETTER_AUTH_SECRETin.envand unchanged across restarts? Changing it makes all JWKS private keys fail to decrypt. - Is the
jwkstable populated?SELECT count(*), max("created_at") FROM jwks;should be ≥ 1. - Does
/api/auth/jwksreturn a keys array when opened in the browser? If not, jose’s remote fetch will also fail.
Symptom 3: 401 when clicking “Authorize” on an MCP server
The cookie is gone. DevTools → Application → Cookies — check better-auth.session_token. Common causes:
- User cleared cache.
- Cookie blocked by a browser extension (enterprise environments).
- SameSite policy mismatch in the deployment (server on a different apex domain than the web app).
This route can only use the cookie path. See the “one exception” section.
Symptom 4: User updated email/name but the JWT still has the old value
Expected. JWT payload is a snapshot of session.user at issuance and only refreshes naturally after 30 days. Code that
needs fresh data must query PG, not trust fields on c.get("user") other than id. See
admin-schedule-service.ts:113-114 for the established pattern.
There is no first-class path to force-refresh one user’s JWT. Asking the user to sign out and back in is the fastest workaround.
Key parameters
| Item | Value | Note |
|---|---|---|
| Signing | better-auth ^1.5 jwt plugin | EdDSA by default; JWKS private key symmetric-encrypted with BETTER_AUTH_SECRET, persisted in PG |
| Verification | jose ^6.1 + createRemoteJWKSet | Local verification; JWKS fetched once, cached in memory |
| JWT lifetime | 30 days | auth.ts jwt plugin expirationTime: "30d" |
| JWKS endpoint | /api/auth/jwks | Public URL jose pulls from |
| JWKS cache policy | jose built-in: cooldown 30 s / cacheMaxAge 10 min | First request hits PG; subsequent requests hit jose’s in-memory cache |
| Client token storage | localStorage["authToken"] | Web only; desktop has its own stub, does not talk to server |
| Token source header | set-auth-jwt (only on /api/auth/get-session) | Login must explicitly call getSession() to trigger issuance |
| WS token transport | ?token=<jwt> query param | Browser WebSocket API does not allow custom headers |
| Immediate revocation | Not supported | JWT valid for 30 days; rotating BETTER_AUTH_SECRET is the nuclear option |
| Per-request DB on business routes | 0 | No session query, no user query (require-roles is a local exception) |
Further reading
- better-auth jwt plugin source
—
expirationTime,definePayload, and other customization knobs - jose · jwtVerify with JWKS —
verification API and
createRemoteJWKSetcache parameters - Realtime communication — surrounding context for the WS handshake auth
- BUA subsystem — the extension’s independent pairing-token handshake