The JWT plugin provides endpoints to retrieve a JWT token and a JWKS endpoint for token verification. It is intended for service-to-service authentication and APIs that require JWT tokens rather than session cookies.
This plugin is not a replacement for sessions. For API requests that need to read session tokens as Bearer tokens, the Bearer plugin handles that use case.
Installation
Add the plugin to your auth config
import { betterAuth } from "better-auth"
import { jwt } from "better-auth/plugins"
export const auth = betterAuth({
plugins: [
jwt()
]
})
Migrate the database
Run the migration or generate the schema to add the JWKS table.Add the client plugin
import { createAuthClient } from "better-auth/client"
import { jwtClient } from "better-auth/client/plugins"
export const authClient = createAuthClient({
plugins: [
jwtClient()
]
})
Get a JWT token
Using the client plugin (recommended)
import { authClient } from "@/lib/auth-client"
const { data, error } = await authClient.token()
if (data) {
const jwtToken = data.token
// Pass to external services in Authorization: Bearer <token>
}
When you call getSession, a JWT is returned in the set-auth-jwt response header:
await authClient.getSession({
fetchOptions: {
onSuccess: (ctx) => {
const jwt = ctx.response.headers.get("set-auth-jwt")
},
},
})
Direct HTTP request
await fetch("/api/auth/token", {
headers: {
Authorization: `Bearer ${sessionToken}`,
},
})
// Returns: { "token": "ey..." }
JWKS endpoint
The JWKS endpoint is available at /api/auth/jwks and publishes the public key used to sign tokens.
{
"keys": [
{
"crv": "Ed25519",
"x": "bDHiLTt7u-VIU7rfmcltcFhaHKLVvWFy-_csKZARUEU",
"kty": "OKP",
"kid": "c5c7995d-0037-4553-8aee-b5b620b89b23"
}
]
}
The public key can be cached indefinitely. When a JWT with a different kid is received, fetch the JWKS again.
Verifying tokens
With remote JWKS (recommended)
import { jwtVerify, createRemoteJWKSet } from "jose"
async function validateToken(token: string) {
const JWKS = createRemoteJWKSet(
new URL("https://your-domain.com/api/auth/jwks")
)
const { payload } = await jwtVerify(token, JWKS, {
issuer: "https://your-domain.com",
audience: "https://your-domain.com",
})
return payload
}
With local JWKS
import { jwtVerify, createLocalJWKSet } from "jose"
const storedJWKS = {
keys: [{ /* keys fetched from /api/auth/jwks */ }],
}
async function validateToken(token: string) {
const JWKS = createLocalJWKSet({ keys: storedJWKS.keys })
const { payload } = await jwtVerify(token, JWKS, {
issuer: "https://your-domain.com",
audience: "https://your-domain.com",
})
return payload
}
Configuration
Signing algorithm
The default algorithm is EdDSA with the Ed25519 curve. Other supported algorithms:
jwt({
jwks: {
keyPairConfig: { alg: "EdDSA", crv: "Ed25519" }, // default
// keyPairConfig: { alg: "ES256" },
// keyPairConfig: { alg: "RSA256", modulusLength: 2048 },
// keyPairConfig: { alg: "PS256" },
// keyPairConfig: { alg: "ES512" },
},
})
Customize the JWT payload
By default the full user object is added to the JWT payload. Restrict it:
jwt({
jwt: {
definePayload: ({ user }) => ({
id: user.id,
email: user.email,
role: user.role,
}),
},
})
Issuer, audience, and expiration
jwt({
jwt: {
issuer: "https://example.com",
audience: "https://example.com",
expirationTime: "1h", // default is 15 minutes
getSubject: (session) => session.user.email, // default is user id
},
})
Key rotation
jwt({
jwks: {
rotationInterval: 60 * 60 * 24 * 30, // rotate every 30 days
gracePeriod: 60 * 60 * 24 * 30, // keep old key valid for 30 days
},
})
Custom JWKS path
jwt({
jwks: {
jwksPath: "/.well-known/jwks.json",
},
})
When using a custom path, configure the client to match:
jwtClient({
jwks: {
jwksPath: "/.well-known/jwks.json",
},
})
Remote JWKS URL
Disables the local /jwks endpoint and uses an external URL instead:
jwt({
jwks: {
remoteUrl: "https://example.com/.well-known/jwks.json",
keyPairConfig: { alg: "ES256" }, // required when using remoteUrl
},
})
Custom adapter
Store JWKS in a location other than your primary database (e.g., Redis):
jwt({
adapter: {
getJwks: async (ctx) => {
return await yourStorage.getAllKeys()
},
createJwk: async (ctx, webKey) => {
return await yourStorage.createKey(webKey)
},
},
})
OAuth provider mode
If you are using the OIDC or OAuth provider plugins alongside JWT, disable the conflicting /token endpoint:
betterAuth({
disabledPaths: ["/token"],
plugins: [
jwt({
disableSettingJwtHeader: true,
}),
],
})
Schema
The JWT plugin adds a jwks table to the database:
| Field | Type | Description |
|---|
id | string | Unique identifier for the key pair |
publicKey | string | The public part of the key |
privateKey | string | The private part of the key (AES256 encrypted by default) |
createdAt | Date | When the key was created |
expiresAt | Date | When the key expires (optional) |
Private keys are encrypted with AES256 GCM by default. To disable encryption (not recommended): jwt({ jwks: { disablePrivateKeyEncryption: true } })