Authentication
Stateless Bearer JWT — a one-shot cookie-to-JWT exchange, then local JWKS verification on every business route. Three server entry points (h() / requireAuth / raw verifyJwt) cover JSON CRUD, streaming + multipart, and WebSocket; one cookie-only exception remains for top-level browser navigation that triggers OAuth.
What this doc answers
The auth contract between client and server, plus the key design decisions and what was rejected. After reading you should be able to answer:
- How does the client obtain a JWT after login, where is it stored, when is it used?
- Why does the server not query the
sessiontable? - Three entry points (
h()/requireAuth/verifyJwt) — what goes where, why aren’t they unified? - What happens if
BETTER_AUTH_SECRETchanges or thejwkstable is wiped?
Cookie session management inside /api/auth/* is better-auth’s internal concern and is out of scope.
This doc describes the contract the project builds on top of better-auth.
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) |
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.
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