JWT Optimization
Zero-query authentication using HMAC-signed JWTs. 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 |
orgId | Active organization ID |
role | Organization membership role (owner, admin, member) |
userRole | Global user role (when admin panel is enabled) |
email | User email |
name | User display name |
iat | Issued-at timestamp |
exp | Expiry (15 minutes from issue) |
Signing
JWTs are signed with HMAC-SHA256 using your BETTER_AUTH_SECRET. No additional configuration is needed — this uses the same secret you already set for Better Auth.
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, BETTER_AUTH_SECRET)fromsrc/lib/jwt.ts - Decode header + payload, recompute the HMAC-SHA256 signature, and compare via
crypto.subtle.verify()(constant-time — no timing-attack surface) - Check
expagainst the current time - On success: hydrate
AppContextfrom claims (sub,orgId,role,userRole,email,name) — no DB query - On failure (bad signature, expired, malformed): silently 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) performs the same verification using the 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 fresh JWT with new org context
const res = await fetch('/api/v1/token', {
method: 'POST',
credentials: 'include',
});
const { token } = await res.json();
localStorage.setItem('bearer_token', token);Invalidation on Role Changes
When an admin changes a user's role or removes them from an organization, the server broadcasts an auth:token-invalidated event via WebSocket. The client automatically clears the cached JWT, forcing the next request to fall back to session auth and mint a fresh token.
Admin changes user role
→ databaseHooks fires on member update
→ Broadcasts auth:token-invalidated via realtime
→ Client clears localStorage JWT
→ Next request uses session cookie → gets fresh JWTExpiry
JWTs expire after 15 minutes. 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
POST /api/v1/token mints a fresh JWT from the current session. This endpoint goes through normal auth middleware, so the user must have a valid session cookie.
Request:
curl -X POST https://your-api.example.com/api/v1/token \
-H "Content-Type: application/json" \
--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) | 15-minute expiry limits exposure window. httpOnly cookies protect the session. |
| Token replay | Short expiry + set-auth-token rotation on session fallback |
| Stale permissions | WebSocket auth:token-invalidated broadcast on role changes |
| Token forgery | HMAC-SHA256 signature verified with BETTER_AUTH_SECRET |
| Timing attacks | crypto.subtle.verify() provides constant-time comparison |
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 secrets or configuration needed — uses your existing
BETTER_AUTH_SECRET - 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 JWT fast-path (documented above) is a custom HMAC-SHA256 implementation, separate from Better Auth's jwt plugin. The distinction matters if you're comparing options.
What Quickback ships by default
- Custom HMAC-SHA256 JWTs minted by the compiled API at
POST /api/v1/token - Payload includes
sub,orgId,role,userRole,email,name— everything the middleware needs to reconstructAppContextwith zero DB queries - Signed with
BETTER_AUTH_SECRET - Used by the generated auth middleware as a fast-path ahead of
getSession()
What Better Auth's JWT plugin gives you (opt-in)
Enable it with plugins: ["jwt"] in your auth config:
auth: defineAuth("better-auth", {
plugins: ["jwt"],
}),You get:
- JWKS endpoint at
/auth/v1/jwksfor asymmetric public-key verification by external services - BA's own
/auth/v1/tokenendpoint (asymmetric-signed, EdDSA by default) - Standard OIDC-style claims via BA's
definePayloadhook
Why Quickback doesn't use BA's plugin internally
Better Auth's definePayload hook only receives { user }. It cannot embed the active organization context (orgId, org-member role) that Quickback's Firewall and Access layers need. Without that context in the JWT, the middleware would have to do a DB lookup per request — defeating the whole point of the fast-path.
When to enable BA's JWT plugin anyway
Turn it on if external services need to verify tokens without sharing BETTER_AUTH_SECRET — e.g. a third-party worker that wants to confirm a user's identity via JWKS. The BA-issued tokens won't have Quickback's org context, so they're useful for identity assertions, not for hitting the Quickback API directly.
Enabling BA's plugin does not change how the compiled API authenticates requests — the middleware keeps using the custom HMAC JWT regardless.
Related
- Auth Overview — Setup and configuration
- Auth Security — Cookie security, rate limiting, CORS
- API Keys — Programmatic API access