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.
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.
Related
- Auth Overview — Setup and configuration
- Auth Security — Cookie security, rate limiting, CORS
- API Keys — Programmatic API access