JWT Optimization
Zero-query authentication using JWTs signed with Better Auth's JWKS keyring. Eliminates database lookups for authenticated API requests.
Quickback automatically optimizes authenticated API requests using JWT tokens. After the first request (which uses session cookies), subsequent requests skip the database entirely by sending a signed JWT that encodes the full auth context.
How It Works
Request Flow
First request (no JWT cached):
Browser → Cookie auth → 2 DB queries → Response + set-auth-token header
Browser caches JWT in localStorage
Subsequent requests (JWT cached):
Browser → Bearer JWT → Signature check only → Response (0 DB queries)What's in the JWT
The JWT encodes everything needed for auth middleware:
| Claim | Description |
|---|---|
sub | User ID |
sid | Better Auth session the token was minted from — carried by scope mints and the rolling refresh too, so qbrev:sid revokes every token descended from a signed-out session |
orgId | Active organization ID |
role | Organization membership role (owner, admin, member) |
userRole | Global user role (when admin panel is enabled) |
ict | Identity proof time (unix seconds) — when orgId / role / userRole / tiers were last read from the database. Stamped only by the session-path mint and GET /auth/v1/token; carried verbatim by /scope/v1/enter, namespace mints and the rolling refresh, which drop those claims once ict is older than the 15-minute carry-forward cap (or absent). See Scopes |
email | User email |
name | User display name |
iat | Issued-at timestamp (unix seconds, the standard claim) |
iatMs | Issued-at in epoch milliseconds — Quickback ordering metadata, not a standard claim. Same clock sample as iat, set by the signer only. Lets revocation order a token against a marker written in the same second |
exp | Expiry — 180 seconds (3 minutes) after iat by default; set via auth.jwt.expiresIn |
Scope-bearing tokens also carry sct (proof timestamp, unix seconds) and its millisecond companion sctMs, plus a per-kind sctMs stamp (m<ms>) inside each scope.<kind> entry — a mint proves one kind and carries the others forward with the proof time they already had. See Scopes. Read iat / sct from external tooling; the millisecond values exist only so revocation can break ties inside a second.
Signing
JWTs are signed with Better Auth's jwt plugin keyring: an EdDSA (Ed25519) key pair the plugin stores in the jwks table, private key encrypted at rest with your BETTER_AUTH_SECRET. There is still only one secret to manage, and it is needed only to sign — verification uses the public key, so any worker (or external service) can verify a token from GET /auth/v1/jwks without holding the secret.
The signing behaviour is configurable under auth.jwt in quickback.config.ts: issuer / audience set the iss / aud claims every verifier pins (default quickback:<project name>), expiresIn sets the TTL, and revocationCheck adds sub-TTL revocation. The algorithm is always EdDSA — there is no knob.
auth: {
jwt: {
expiresIn: 180, // default — TTL in seconds
revocationCheck: 'none', // default — 'kv' for sub-TTL revocation
issuer: 'quickback:my-app', // default — quickback:<project name>
},
},This surface is opt-in by default
The custom JWT surface is emitted only when auth.jwt is present. Omit it and the generated output has no token endpoint and no JWT fast-path — cookie and signed session-token bearer auth still work. A project that declares authz.scopes does not need auth.jwt: it gets scope-token admission on its own, without the token endpoint or the identity fast-path. Declaring auth.jwt is what opts into the full credential lane. See Auth & JWT config.
src/lib/jwt.ts is still emitted when the project declares authz.scopes: scope tokens are signed JWTs, so the helper is required for minting and verifying them even with no credential lane.
Server-Side Validation
Every authenticated API request is validated server-side before it reaches your handler. There is no "trust the client" shortcut — the middleware runs full signature verification on each request.
Validation steps (in generated src/middleware/auth.ts):
- Extract the
Authorization: Bearer <token>header - Call
verifyJwt(token, env)fromsrc/lib/jwt.ts - Verify the EdDSA signature with jose against the keyring's public JWKS — cached per isolate, refetched at most once when a token names an unknown
kid(key rotation). OnlyEdDSAis accepted, so alg-confusion is rejected by construction, andiss/audare pinned to the compile-time constants - Check
expagainst the current time - On success: hydrate
AppContextfrom claims (sub,sid,orgId,role,userRole,email,name) — no DB query - On failure for a token this project minted (rotated key, expired, tampered, revoked — recognised by the pinned
iss, or the legacy HS256 shape):401withAUTH_INVALID_TOKEN(AUTH_EXPIREDwhenexphas passed) andWWW-Authenticate: Bearer error="invalid_token". No fall-through: a presented-and-bad credential is never treated as no credential, so clients can key "drop the cached token and re-mint" on 401 alone. A foreign JWT (an OAuth access token — same keyring, OAuth issuer) falls through to its own verifier — which is whyauth.jwt.issuermay never equal the OAuth issuer (compile error). A token that verifies but is not one this lane admits (a session user's scope token on a project withoutauth.jwt) is a good credential, not a bad one: it falls through to session auth - With no JWT at all: fall back to Better Auth's session cookie auth; if that succeeds, mint a fresh JWT and return it in the
set-auth-tokenresponse header
The Files worker (R2 uploads) runs the same verifier (src/lib/jwt-verify.ts) over the public keys it reads from the jwks table — no shared secret, no network round-trip to the auth API for file operations.
Token Lifecycle
Automatic Minting
When a request arrives without a JWT (or with an expired one), the auth middleware:
- Authenticates via session cookie (existing Better Auth flow)
- Signs a JWT with the full auth context
- Returns it in the
set-auth-tokenresponse header
The Quickback API client (quickback-client.ts) automatically captures this header and stores the JWT in localStorage.
Re-minting on Org Switch
When a user switches organizations, the JWT must be re-minted because orgId and role change. The auth UI handles this automatically:
// This happens automatically in the org switcher
await authClient.organization.setActive({ organizationSlug: slug });
// Fetch a fresh JWT with the new org context — Better Auth's jwt plugin
const res = await fetch('/auth/v1/token', { credentials: 'include' });
const { token } = await res.json();
localStorage.setItem('bearer_token', token);Revocation — revocationCheck: 'kv'
The fast-path is stateless by default: it verifies the signature and trusts the claims for the full TTL. A ban, a member removal, or a role demotion does not reach an outstanding JWT — that token keeps working until it expires. Bounding that window is a server-side concern, and there are exactly two knobs for it.
Lower the TTL. auth.jwt.expiresIn: 30 gives a hard 30-second ceiling. Browser SPAs auto-refresh via set-auth-token on every authed call, so a low TTL is invisible to logged-in users; only stale server-to-server bearer tokens feel it.
Or enable sub-TTL revocation. Set revocationCheck: 'kv' (Cloudflare runtime only):
auth: {
jwt: { revocationCheck: 'kv' },
},The fast-path then consults per-principal revocation timestamps in your project's existing KV binding (keys prefixed qbrev:, self-expiring) before trusting a token. A token is rejected when its mint timestamp (iat/iatMs, or the proof time of the scope kind being checked) is at or before the stored timestamp. Cost: 2+ edge-cached KV reads per bearer verify.
There is no same-second gap: markers are epoch milliseconds (written m<ms>), tokens carry millisecond companions, and the comparison denies a tie — so a token minted a few milliseconds before the revocation is rejected, while one legitimately re-minted after it in that same second is still accepted.
Revocation stamps are written automatically on:
- every session deletion — sign-out,
revoke-session(s),revoke-other-sessions, password reset (token and email-OTP flows),delete-user, the admin plugin'srevoke-user-session(s)andstop-impersonating, and any project code that deletes a session row through the Better Auth adapter. The stamp is written fromdatabaseHooks.session.delete.before, so it fires once per deleted row on every path, present and future - user ban
- the target of the admin plugin's
/admin/set-role,/admin/remove-user,/admin/update-user, and/admin/revoke-user-sessions - every
ctx.authwrite (ban, role change, account deletion) - org-member removal and member role change (Better Auth
organizationHooks) UPDATE/DELETEof scope-conferring relationship rows
The generated src/lib/jwt-revocation.ts exports revokeUserTokens, revokeMemberTokens, and revokeScopeTokens for explicit calls from your own action code.
Independently of revocation, a JWT never outlives the session it was minted from: both the session-path mint (set-auth-token) and GET /auth/v1/token clamp exp to min(now + expiresIn, session.expiresAt). A token minted in the last minutes of a session expires with it instead of running the full TTL.
Honest limits
The KV check revokes per principal, not per session, and stamps propagate cross-PoP in roughly 60 seconds — "immediate" means seconds, not instantaneous.
It fails closed for bearer auth: if the KV binding is missing or the read fails, the bearer is not authenticated — a signature is all that is known about it — and the caller gets a retryable 503 AUTH_REVOCATION_UNAVAILABLE. Recovery is a separate cookie-only request, not a fallback inside the same one: a request carrying Authorization has its cookie stripped before session auth (bearer is an alternative to the cookie, not a supplement), so during a KV outage every JWT-bearer request gets the 503 until the client retries on its session cookie. A revoked token behaves the same way, and a cookie request re-checks membership and re-mints transparently.
Operational consequence
Enabling revocationCheck: 'kv' ties bearer-token availability to KV. Alert on AUTH_REVOCATION_UNAVAILABLE — a sustained rate means the binding is gone (check that every deployment target declares it) or KV itself is degraded. If you want the TTL bound without that coupling, leave revocationCheck at 'none' and set expiresIn: 30.
Full knob-by-knob reference: Auth & JWT config → revocationCheck.
Client-side invalidation broadcast (best-effort)
Separately from revocationCheck, when realtime is configured the server also broadcasts an auth:token-invalidated event after a member role change, and the Account SPA drops its cached JWT in response.
Admin changes user role
→ organizationHooks.afterUpdateMemberRole fires
→ Broadcasts auth:token-invalidated via realtime
→ Client clears localStorage JWT
→ Next request uses session cookie → gets fresh JWTThis is a UX nicety, not an enforcement mechanism. The broadcast is fire-and-forget with a 2.5s timeout, it only reaches clients that are connected and listening, and it does nothing about a token held outside the browser. Do not rely on it to revoke access — that is what revocationCheck: 'kv' and a low expiresIn are for.
Expiry
JWTs expire 180 seconds (3 minutes) after issue by default — auth.jwt.expiresIn, in seconds. This is a safety net: in practice JWTs are re-minted well before expiry through the set-auth-token response header on session-fallback requests.
When a JWT expires or is invalid:
- The middleware silently falls back to session cookie auth
- A fresh JWT is minted and returned in the response header
- The client stores the new JWT for subsequent requests
Token Endpoint
GET /auth/v1/token — Better Auth's jwt plugin — mints a fresh JWT from the current session. Present only when auth.jwt is declared; the claims come from the compiler-emitted resolveIdentityClaims (sid, ict, orgId beside the raw membership role, userRole, tiers, email, name, pinned iss / aud), and the TTL is your auth.jwt.expiresIn — the same value the session-path refresh uses. There is no Quickback-generated /api/v1/token any more.
Session authentication is required — and enforced. The plugin gates the
endpoint on a Better Auth session (cookie or session-token bearer); a request
authenticated by a bearer JWT, an API key, or an OAuth access token gets 401.
An x-api-key stands in for its owner's session on exactly one Better Auth
endpoint — GET /auth/v1/get-session — and nowhere else under /auth/v1/*
(see API Keys).
This is deliberate: only the session path re-validates against Better Auth's
session store on every request, so bans, member removals, role demotions, and
sign-outs take effect immediately. If a JWT could mint its own replacement, a
stolen or revoked token could roll a short-TTL JWT forever without ever
re-validating — the TTL would bound nothing.
No org claim on purpose. The token carries the session's active organization. A client that must never ride the user's org membership into a scope (a guest or crew app) clears it first — authClient.organization.setActive({ organizationId: null }) — and mints; the token then carries no orgId / role. A foreign-tenant instance reached through /scope/v1/enter drops the org claims server-side regardless.
Request:
curl https://your-api.example.com/auth/v1/token \
--cookie "better-auth.session_token=..."Response:
{
"token": "eyJhbGciOiJIUzI1NiJ9..."
}Use this endpoint after operations that change auth context (like switching organizations) to get a JWT that reflects the new state.
Client Integration
Quickback API Client
The quickback-client.ts API client handles JWT auth automatically:
- Sends the cached JWT as
Authorization: Bearer <token>on every request - Captures refreshed JWTs from
set-auth-tokenresponse headers - Clears the JWT on
401responses (falls back to session cookie)
No additional client configuration is needed.
Custom API Calls
If you make direct fetch() calls to your API (outside the Quickback client), include the JWT:
const jwt = localStorage.getItem('bearer_token');
const res = await fetch('https://your-api.example.com/api/v1/things', {
headers: {
'Authorization': `Bearer ${jwt}`,
'Content-Type': 'application/json',
},
credentials: 'include', // Fallback to session cookie if no JWT
});
// Capture refreshed token
const newToken = res.headers.get('set-auth-token');
if (newToken) {
localStorage.setItem('bearer_token', newToken);
}Sign Out
Clear the JWT on sign-out to prevent stale tokens:
localStorage.removeItem('bearer_token');
await authClient.signOut();Security
Threat Model
| Threat | Mitigation |
|---|---|
| Token theft (XSS) | 180-second default expiry bounds the exposure window; expiresIn: 30 tightens it. httpOnly cookies protect the session. |
| Token replay | Short expiry + set-auth-token rotation on session fallback |
| Stale permissions | revocationCheck: 'kv' — server-side rejection within seconds of a ban, member removal, or role change |
| Token forgery | EdDSA signature verified against the keyring's public JWKS; only EdDSA is accepted (no alg confusion) |
| Timing attacks | Signature verification is constant-time (Web Crypto via jose) |
Key Points
- JWTs are a performance optimization, not a replacement for session auth
- Session cookies remain the source of truth — JWTs are derived from them
- If a JWT is lost, stolen, or cleared, the system gracefully falls back to cookie auth
- No additional secret is needed — the keyring's private key is encrypted at rest with your existing
BETTER_AUTH_SECRET, and verification needs only the public keys. Behaviour is configurable: seeauth.jwtforexpiresIn,revocationCheck,issuer, andaudience - The JWT contains no sensitive data beyond what's already in the auth context
Files Worker
The files worker (for Cloudflare R2 file uploads) also supports JWT authentication. When a request includes a JWT Authorization header, the worker verifies it directly instead of calling back to the auth API — eliminating a network round-trip for file operations.
Relation to Better Auth's JWT Plugin
Quickback's fast-path and Better Auth's jwt plugin are one system, not two token formats.
What the plugin owns
Whenever the project signs any JWT — an auth.jwt lane, authz.scopes, or agent auth — the compiler enables the plugin automatically. It owns:
- The keyring — the EdDSA key pair in the
jwkstable, private key encrypted at rest withBETTER_AUTH_SECRET. GET /auth/v1/jwks— the public keys. Any external service verifies Quickback tokens from here; nothing needs the secret.GET /auth/v1/token— mints an identity JWT for the current session (cookie or session-token bearer). Its claims come from the compiler-emittedresolveIdentityClaimsinsrc/lib/auth.ts:sid,ict,orgIdbeside the raw membershiprole,userRole,tiers,email,name, and the pinnediss/aud— the same shape the middleware mints on the session path. Present only whenauth.jwtis declared; otherwise the path is disabled and the keyring exists for scope and agent tokens alone.
What Quickback still mints
- The session-path refresh —
set-auth-tokenon every session-authenticated call. Same claims, same key, signed through the plugin's exported signer. The plugin's ownset-auth-jwtheader is disabled sogetSession()never doubles the member and tier lookups. - Scope tokens —
/scope/v1/enter, namespace resolvers, and sessionless scope-only mints carry ascopeclaim for principals that may have no Better Auth session at all. The plugin has no concept of those, so Quickback builds the payload and signs it with the plugin's key — one keyring, one verifier.
One verifier
src/lib/jwt-verify.ts — pure jose, no Better Auth import — verifies every token: EdDSA only, iss / aud pinned to the compile-time constants, JWKS cached per isolate and refetched at most once for an unknown kid. src/lib/jwt.ts wraps it for the API worker (loading the JWKS from the auth instance); the files worker uses it directly over the jwks table.
Related
- Auth & JWT config —
auth.jwtreference:expiresIn,revocationCheck,issuer,audience - Auth Overview — Setup and configuration
- Auth Security — Cookie security, rate limiting, CORS
- API Keys — Programmatic API access