← Dev log

security

JWT with jose, and why not jsonwebtoken

JWT with jose, and why not jsonwebtoken

jose is a JS/TS implementation of the JOSE spec family — JWS, JWE, JWK, JWT — framework-agnostic, working in Node, browsers, Deno and edge runtimes. I use it in the backend instead of the more commonly tutorialed jsonwebtoken.

jsonwebtoken is CommonJS-first and has had CVEs around algorithm confusion — signing with alg: none, or verifying an RS256 token with the public key treated as an HMAC secret if you don't lock the algorithm down. jose forces explicitness about the algorithm on both sign and verify, with no ambient trust in what the token's own header claims. It's native ESM with proper types, and works anywhere the stack might need an edge runtime, which jsonwebtoken can't.

import { SignJWT, jwtVerify } from "jose";

const secret = new TextEncoder().encode(process.env.JWT_SECRET);

const token = await new SignJWT({ sub: userId, role: "user" })
  .setProtectedHeader({ alg: "HS256" })
  .setIssuedAt()
  .setExpirationTime("15m")
  .sign(secret);

const { payload } = await jwtVerify(token, secret, {
  algorithms: ["HS256"], // pinned — never let the token dictate its own
});

Keys and secrets are Uint8Array, not raw strings — the biggest adjustment coming from jsonwebtoken, and passing a raw string where a Uint8Array is expected fails silently in confusing ways.

The pattern that actually mattered

Stateless tokens plus a stateful kill switch. JWTs are stateless by design — no DB lookup to validate a signature, which is the whole performance win — but that means no early revocation either. This bit me directly: a locked-out user could keep making authenticated requests until natural expiry, because nothing tracked whether a token should still be trusted. The fix wasn't a jose feature, it was architectural — a token_version on the user, embedded as a claim, checked against the current DB value on every request. jose's sign/verify are just primitives; the session lifecycle has to be built around them.

Access tokens stay short-lived; refresh tokens are longer-lived and httpOnly, out of reach of XSS. A current_refresh_jti on the user tracks and rotates the single valid refresh token per session and detects reuse — a replayed already-rotated refresh token is a classic sign of theft.