Session-Centric JWT (SCJWT) plugin for Better Auth. It issues a signed, device-fingerprinted JWT that points at a database session row. The database remains the source of truth; the JWT is a tamper-proof pointer with a bounded lifetime.
See why in the docs
bun add better-auth-scjwt
# peer dependencies (if not already installed)
bun add better-auth josePeer dependencies: better-auth >=1.0.0, jose >=5.0.0
import { betterAuth } from "better-auth";
import { scjwt } from "better-auth-scjwt";
export const auth = betterAuth({
// database adapter required — sessions are stored in DB
database: /* your adapter */,
plugins: [
scjwt({
jwtSecret: process.env.JWT_SECRET!,
issuer: "https://api.example.com",
}),
],
});A Better Auth database adapter is required. SCJWT stores the canonical session state in the database, so stateless/no-database setups are not supported and fail fast with:
[scjwt] database adapter is required; stateless mode is not supported.After a successful /sign-in/* or /sign-up/* flow, the plugin:
- Signs an HS256 JWT bound to the new session row
- Strips native Better Auth session cookies from the response
- Delivers the JWT via cookie (default) or response header
import { createAuthClient } from "better-auth/client";
import { scjwtClient } from "better-auth-scjwt/client";
export const authClient = createAuthClient({
baseURL: "https://api.example.com",
plugins: [scjwtClient()],
});The client plugin provides $InferServerPlugin type inference only. Token refresh is handled server-side (see Sliding session).
| Option | Type | Default | Description |
|---|---|---|---|
jwtSecret |
string |
— | Required. HS256 signing key |
issuer |
string |
— | Required. Issuer URL (iss claim) |
expiresInSeconds |
number |
3600 |
JWT and session validity window (seconds) |
cookieName |
string |
"auth-token" |
HTTP-only cookie name when tokenPlacement is "cookie" |
tokenPlacement |
"cookie" | "header" |
"cookie" |
How the JWT is delivered and read |
slidingSession |
boolean |
false |
Re-sign JWT for active users before expiry (see below) |
cookie (default) — JWT is set as an HTTP-only cookie (auth-token by default). The gateway reads it from the Cookie header on each request.
header — JWT is returned in the set-auth-token response header on issuance/refresh. Clients send it back via Authorization: Bearer <token>.
Sliding refresh is disabled by default (slidingSession: false). When disabled, sessions end at JWT exp or when the database row is revoked—whichever applies first.
Enable it to keep actively used sessions alive:
scjwt({
jwtSecret: process.env.JWT_SECRET!,
issuer: "https://api.example.com",
slidingSession: true,
})When enabled, requests within the last 20% of the token lifetime trigger:
- Extension of the database session
expiresAt - Re-signing of the JWT
- Delivery of the new token on the response (
onResponse)
Better Auth's built-in session.updateAge refresh does not update SCJWT tokens. If you use slidingSession: true, disable the native session refresh to avoid conflicting behavior:
betterAuth({
session: {
disableSessionRefresh: true,
},
plugins: [scjwt({ /* ... */, slidingSession: true })],
});Strict v1 payload (no extra claims):
| Claim | Description |
|---|---|
iss |
Issuer URL from plugin options |
sub |
user:{userId} |
fp |
SHA-256 hex fingerprint of { ip, ua, platform } |
iat / exp |
Issued-at and expiry (Unix seconds) |
sid |
Database session row primary key |
- Effective expiry — Session validity uses
min(JWT exp, database expiresAt). At issuance, JWT lifetime is capped to the session row'sexpiresAt; at validation,loadSessionIntoContextrejects when past effective expiry ("Session has expired."). - Database as source of truth — Revoking or deleting the session row invalidates the JWT immediately on the next gateway request (
401,"Session not found."). SCJWT does not maintain a separate revocation list. - Revocation via Better Auth — When Better Auth deletes the session row, SCJWT is dead. Verified core paths (Better Auth v1.6.14):
signOut,revokeSession,revokeOtherSessions, andchangePasswordwithrevokeOtherSessions: true. Full audit:docs/REVOKE_AUDIT.md. - Host configuration matters —
changePassworddoes not delete session rows by default; other devices keep valid SCJWTs untilexpunless you passrevokeOtherSessions: true. For password reset, enableemailAndPassword.revokeSessionsOnPasswordResetif all sessions should end. - Client token clearing — On
signOut,revokeSessions, andrevokeSessionwhen the current session is revoked, SCJWT clears the cookie (Max-Age=0) or removesset-auth-token.revokeOtherSessionsdoes not clear the current client's token (other devices' JWTs are invalidated server-side only). Header-mode clients should still drop any locally cached Bearer token. - Device binding — Each token embeds a fingerprint (
fp) derived from client IP, User-Agent, andSec-CH-UA-Platform. A mismatch is treated as compromise: the session row is deleted and the request returns401. - Fail-closed — Invalid, expired, or mismatched tokens reject the request; missing tokens fall through to standard Better Auth handling.
- Opaque payload — No user profile data in the JWT; only the session pointer and fingerprint.
bun install
bun test
bun run buildTests live in test/ at the project root. Integration tests use Better Auth's test-utils plugin via a test-only auth instance.
- Issuance paths: JWT delivery hooks run after
/sign-in/*and/sign-up/*. OAuth, magic-link, and other session-creating flows are not covered yet. - Reverse proxies: Fingerprint uses the request IP as seen by Better Auth (
getIp). Clients behind proxies may need consistent IP extraction configuration.