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 session table?
  • Three entry points (h() / requireAuth / verifyJwt) — what goes where, why aren’t they unified?
  • What happens if BETTER_AUTH_SECRET changes or the jwks table 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

ItemValueNote
Signingbetter-auth ^1.5 jwt pluginEdDSA by default; JWKS private key symmetric-encrypted with BETTER_AUTH_SECRET, persisted in PG
Verificationjose ^6.1 + createRemoteJWKSetLocal verification; JWKS fetched once, cached in memory
JWT lifetime30 daysauth.ts jwt plugin expirationTime: "30d"
JWKS endpoint/api/auth/jwksPublic URL jose pulls from
JWKS cache policyjose built-in: cooldown 30 s / cacheMaxAge 10 minFirst request hits PG; subsequent requests hit jose’s in-memory cache
Client token storagelocalStorage["authToken"]Web only; desktop has its own stub, does not talk to server
Token source headerset-auth-jwt (only on /api/auth/get-session)Login must explicitly call getSession() to trigger issuance
WS token transport?token=<jwt> query paramBrowser WebSocket API does not allow custom headers
Immediate revocationNot supportedJWT valid for 30 days; rotating BETTER_AUTH_SECRET is the nuclear option
Per-request DB on business routes0No 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.

sequenceDiagram participant C as Client participant BA as better-auth participant DB as PostgreSQL participant API as Business route rect rgba(251, 191, 36, 0.18) Note over C,API: ① Sign-in · sets cookie · creates session row C->>BA: POST /sign-in/email BA->>DB: INSERT session BA-->>C: 200 · Set-Cookie end rect rgba(244, 114, 182, 0.18) Note over C,API: ② getSession · triggers JWT issuance C->>BA: POST /get-session BA->>DB: SELECT session BA->>DB: SELECT jwks · jose initial fetch BA-->>C: 200 · Header set-auth-jwt C->>C: localStorage authToken written end rect rgba(52, 211, 153, 0.18) Note over C,API: ③ Business routes · trust Bearer JWT · skip DB C->>API: GET /api/tasks · Authorization Bearer API->>API: jose.jwtVerify(jwt, JWKS) API-->>C: 200 · ok(data) end

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:

Auth entry points Three call sites · one shared primitive Route shape JSON CRUD 12 routes Streaming / multipart SSE · file upload WebSocket upgrade ws.ts Wrapper h({ auth: true }) + body zod · + ok() wrap requireAuth middleware · sets RequestContext direct call no c · synthesize header from query Primitive verifyJwt(headers) lib/auth-jwt.ts → jose.jwtVerify + JWKS cached in memory

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 pointWhen to useWhere 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 cHandler 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 Response object (SSE, file download) — cannot use h(). h() would treat the Response as plain data and JSON-serialize it.
  • Body is multipart/form-data — cannot use h()’s body: schema option. h() internally calls c.req.json(), which throws on multipart.
  • WS upgrade callback has no c accessible (it runs inside upgradeWebSocket’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.

ApproachPer-request DBImmediate revocationStatelessWhy rejected
Cookie + DB session (original)1 PG query · session tableYesNoCross-origin cookie unreliable under some browser / SameSite configs
Bearer + session token (bearer() plugin)1 PG query · session tableYesNo (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)NoYes(selected) — trades immediate revocation for zero DB queries + cross-service verifiability

Concrete reasons for picking the current approach:

  1. Business routes account for ≥ 95% of request volume. Most handlers only use user.id, which the JWT payload already carries.
  2. Immediate revocation is not a hard requirement in an internal-tool context. Acceptable that deleting a session row leaves the JWT usable for up to 30 days.
  3. Rotating BETTER_AUTH_SECRET invalidates every JWKS private key (they’re symmetrically encrypted by the secret) and forces every JWT to be re-issued — a nuclear option is available.
  4. 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 by jti. Not built today.
  • No user-state caching in Redis. The require-roles middleware queries user_role once 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.ts is a local stub storing session in localStorage and never reaches the server. Out of scope here.
  • BUA extension has its own handshake. The /ws/browser endpoint uses pairingToken, not JWT. See BUA subsystem.

Troubleshooting

Symptom 1: After login, every API call returns 401

Check in this order:

  1. No authToken in localStorage — DevTools → Application → Local Storage. Empty means the token was never written. See next item.
  2. signIn.email was not followed by await authClient.getSession()auth-service.ts:23. Skipping this guarantees the symptom: set-auth-jwt only appears on /get-session responses, not sign-in responses.
  3. CORS does not expose set-auth-jwtindex.ts cors config must list set-auth-jwt in exposeHeaders. Without it the header is present in the response but the browser hides it from JS.
  4. request.ts does 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:

  1. Is BETTER_AUTH_SECRET in .env and unchanged across restarts? Changing it makes all JWKS private keys fail to decrypt.
  2. Is the jwks table populated? SELECT count(*), max("created_at") FROM jwks; should be ≥ 1.
  3. Does /api/auth/jwks return 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:

  1. User cleared cache.
  2. Cookie blocked by a browser extension (enterprise environments).
  3. 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

Was this page helpful?