Core
Core JWT
Core JWT utilities - claims, signing, verification, keystores, JWKS, and refresh token rotation.
Core JWT
Low-level JWT utilities from @keyloom/core/jwt for signing, verification, keystore management, and refresh rotation.
Types
import type {
JwtHeader,
JwtClaims,
JwtConfig,
Jwk,
Keystore,
RotationPolicy,
} from "@keyloom/core/jwt";JwtHeader:{ alg: 'EdDSA' | 'ES256'; kid: string; typ: 'JWT' }JwtClaims:iss: stringissueraud?: string | string[]audiencesub: stringsubject (user id)iat: numberissued at (sec)exp: numberexpires at (sec)- optional:
sid,org,role, plus custom claims
JwtConfig:alg,issuer,audience?,accessTTL,refreshTTL,clockSkewSec,includeOrgRoleInAccess
Signing
import { createSigner, sign } from "@keyloom/core/jwt";
// Using a signer (preferred)
const signer = createSigner(privateKey, publicKey, "kid123", "EdDSA");
const token = await signer.sign({ iss, sub: userId, iat, exp });
// Direct sign
const token2 = await sign(
{ alg: "EdDSA", kid: "kid123", typ: "JWT" },
{ iss, sub, iat, exp },
privateKey
);Verification
import { verify, verifyFull } from "@keyloom/core/jwt";
// JWKS array (public keys) is required
const { header, claims } = await verify(token, jwks);
// Full verification with timing and issuer/audience checks
const out = await verifyFull(token, jwks, {
clockSkewSec: 60,
expectedIssuer: "https://app.example.com",
expectedAudience: "my-audience",
});Keystore and JWKS
import {
newKeystore,
rotateKeys,
exportPublicKeys,
exportJwks,
genKeyPair,
} from "@keyloom/core/jwt";
// Create and rotate a keystore
let ks = await newKeystore({ alg: "EdDSA" });
ks = await rotateKeys(ks, { rotationDays: 30, overlapDays: 7 });
// Export public keys for verification
const jwks = exportPublicKeys(ks); // JWK[]
// Generate a standalone keypair
const { privateKey, publicKey, kid } = await genKeyPair("EdDSA");- Store private keys securely server side.
- Expose only the public JWKS via a route like
/api/auth/jwks.
Refresh tokens
import { newRefreshToken, TokenRotator } from "@keyloom/core/jwt";
// Create a refresh token record for a user
const rec = await newRefreshToken({ userId: "u_1", ttl: "30d" });
// Rotate on use (pseudocode)
const rotator = new TokenRotator(store); // store implements persistence
const { next, revoke } = await rotator.rotate(rec);- Refresh tokens are opaque strings with hashed storage helpers available in tests/utilities.
- Rotation prevents reuse attacks by invalidating the previous token.
Errors
import { JWT_ERRORS, JwtError } from "@keyloom/core/jwt";Common codes include:
JWT_MALFORMED,JWT_EXPIRED,JWT_INVALID_ISSUER,JWT_INVALID_AUDIENCE
Handle with try/catch and map to 401 or 403 depending on context.
Claims helpers
import { validateJwtClaims, newAccessClaims } from "@keyloom/core/jwt";newAccessClaims- helper to build standard claimsvalidateJwtClaims- validate issuer, audience, timing
Guidance
- Prefer EdDSA where possible
- Use short access TTLs and rolling sessions for UX
- Rotate keys regularly and keep an overlap window for old tokens
- Never expose private keys
Prerequisites
- Node.js 18+ or 20+
- WebCrypto available (Node 18+ provides global crypto)
- If exposing JWKS: a public route like
/api/auth/jwks - If rotating keys: persistent storage for private keys
API reference
| Function | Parameters | Returns | Throws |
|---|---|---|---|
createSigner(privateKey, publicKey, kid, alg) | CryptoKey, CryptoKey, string, 'EdDSA' | 'ES256' | { sign(claims): Promise<string> } | Errors from WebCrypto export/sign |
sign(header, claims, privateKey) | JwtHeader, JwtClaims, CryptoKey | Promise<string> | JWT_ERRORS on invalid input |
verify(token, jwks) | string, Jwk[] | Promise<{ header: JwtHeader; claims: JwtClaims }> | JWT_MALFORMED, signature errors |
verifyFull(token, jwks, opts) | string, Jwk[], { clockSkewSec?, expectedIssuer?, expectedAudience? } | same as above | JWT_EXPIRED, JWT_NOT_BEFORE, JWT_INVALID_ISSUER, JWT_INVALID_AUDIENCE |
newKeystore(opts) | { alg: 'EdDSA' | 'ES256' } | Promise<Keystore> | WebCrypto errors |
rotateKeys(keystore, policy) | Keystore, { rotationDays: number; overlapDays: number } | Promise<Keystore> | if keystore invalid |
exportPublicKeys(keystore) | Keystore | Jwk[] | - |
exportJwks(keystore) | Keystore | { keys: Jwk[] } | - |
genKeyPair(alg) | 'EdDSA' | 'ES256' | Promise<{ privateKey: CryptoKey; publicKey: CryptoKey; kid: string }> | WebCrypto errors |
newRefreshToken({ userId, ttl }) | { userId: string; ttl: string } | Promise<RefreshTokenRecord> | - |
TokenRotator(store) | { findByJti, save, revoke } like interface | { rotate(rec): Promise<{ next, revoke }>} | - |
Runnable example: key rotation and JWKS
import { newKeystore, rotateKeys, exportJwks } from "@keyloom/core/jwt";
let keystorePromise: Promise<any> | null = null;
async function getKeystore() {
if (!keystorePromise) {
// In production, load from secure storage (KMS/DB). This demo keeps it in memory.
keystorePromise = newKeystore({ alg: "EdDSA" });
}
return keystorePromise;
}
export async function GET() {
const ks = await getKeystore();
// Optional: rotate on schedule
// const next = await rotateKeys(ks, { rotationDays: 30, overlapDays: 7 });
// await save(next)
const jwks = exportJwks(ks);
return Response.json(jwks, {
headers: { "cache-control": "public, max-age=300" },
});
}Runnable example: verify with issuer and audience
import { verifyFull, exportPublicKeys } from "@keyloom/core/jwt";
async function check(token: string, keystore: any) {
const jwks = exportPublicKeys(keystore);
try {
const { claims } = await verifyFull(token, jwks, {
clockSkewSec: 60,
expectedIssuer: "https://app.example.com",
expectedAudience: ["web", "mobile"],
});
return claims;
} catch (err: any) {
if (err.code === "JWT_EXPIRED") return null;
throw err;
}
}Runnable example: refresh rotation workflow
import { TokenRotator, newRefreshToken } from "@keyloom/core/jwt";
type RefreshRow = {
jti: string;
userId: string;
tokenHash: string;
expiresAt: Date;
parentJti?: string | null;
};
const memory: Map<string, RefreshRow> = new Map();
const store = {
async findByJti(jti: string) {
return memory.get(jti) ?? null;
},
async save(row: RefreshRow) {
memory.set(row.jti, row);
},
async revoke(jti: string) {
const r = memory.get(jti);
if (r) memory.delete(jti);
},
};
// Issue initial token
const rec = await newRefreshToken({ userId: "usr_42", ttl: "30d" });
await store.save({
jti: rec.jti,
userId: rec.userId,
tokenHash: rec.tokenHash,
expiresAt: rec.expiresAt,
});
// On refresh endpoint
const rotator = new TokenRotator(store as any);
const result = await rotator.rotate(rec);
await store.save(result.next as any); // persist new row
await store.revoke(rec.jti); // revoke oldError handling and troubleshooting
try {
await verifyFull(token, jwks, { expectedIssuer: env.JWT_ISSUER });
} catch (err: any) {
switch (err.code) {
case "JWT_MALFORMED":
case "JWT_EXPIRED":
case "JWT_NOT_BEFORE":
case "JWT_INVALID_ISSUER":
case "JWT_INVALID_AUDIENCE":
// map to 401 / 403 as needed
break;
default:
// 500 or generic error
}
}Common issues:
- Malformed tokens: ensure you pass
Authorization: Bearer <token>or cookies set by your auth flow - Clock skew: increase
clockSkewSecin distributed systems - Key not found: ensure JWKS contains the
kidpresent in the token header - Rotation window: keep old public keys available during overlapDays
Performance considerations
- Cache JWKS on the server for 5-10 minutes; avoid per-request network fetch
- Limit keystore rotations to a daily or weekly job; keep overlap to tolerate in-flight tokens
- Verify only once per request boundary; pass claims through downstream logic
Security notes
- Store private keys in KMS or encrypted DB with strict access control
- Never ship private keys to the client; expose only
/api/auth/jwks - Prefer EdDSA for smaller keys and fast verification
- Validate
issandaudwhen tokens can come from multiple issuers
See also
- Configuration: /docs/core/config
- Security: /docs/security/overview
- Server endpoints: /docs/server/overview
Next steps
- Integrate with Next.js handler: /docs/nextjs/overview
- Use JWT on the server: /docs/nextjs/jwt
How is this guide?