JWT is ubiquitous for stateless cross-service convenience but sloppy config is a top attack target. Modern systems also need third-party login or enterprise SSO so choosing the right federation protocol is mandatory architect knowledge.
JWT Secure Implementation Checklist
Never sign tokens client-side with symmetric HMAC secrets. Production uses RS256/ES256 asymmetric - public keys verify openly. Whitelist only allowed algorithms; compare iss/aud with timingSafeEqual to prevent timing oracles; track jti blacklists for revocation.
import { createSign, createVerify, randomBytes, timingSafeEqual } from "node:crypto";
export interface JwtConfig {
algorithm: "RS256";
privateKeyPem: string;
publicKeyPem: string;
issuer: string;
audience: string;
accessTokenTtlSec: number;
allowedAlgos: readonly string[];
}
export interface TokenPayload {
sub: string; scopes: readonly string[]; jti: string;
exp: number; nbf: number; iat: number; iss: string; aud: string;
}
const B64 = {
enc: (b: Buffer) => b.toString("base64url"),
dec: (s: string) => Buffer.from(s, "base64url"),
};
const DENIED = new Set<string>();
export function createJwt(cfg: JwtConfig, claims: Partial<TokenPayload> & { sub: string; scopes: readonly string[] }): string {
const now = Math.floor(Date.now() / 1000);
const header = { alg: cfg.algorithm, typ: "JWT", kid: "k1" };
const payload: TokenPayload = {
sub: claims.sub, scopes: claims.scopes,
jti: randomBytes(16).toString("hex"),
iat: now, nbf: now, exp: now + cfg.accessTokenTtlSec,
iss: cfg.issuer, aud: cfg.audience,
};
const input = `${B64.enc(Buffer.from(JSON.stringify(header)))}.${B64.enc(Buffer.from(JSON.stringify(payload)))}`;
const s = createSign("RSA-SHA256"); s.update(input);
return `${input}.${B64.enc(s.sign(cfg.privateKeyPem))}`;
}
export function verifyJwt(cfg: JwtConfig, token: string): TokenPayload {
const parts = token.split(".");
if (parts.length !== 3) throw new Error("malformed");
const [hB64, pB64, sB64] = parts;
const header = JSON.parse(B64.dec(hB64).toString());
if (!cfg.allowedAlgos.includes(header.alg) || header.alg === "none") throw new Error("alg not allowed");
const ver = createVerify("RSA-SHA256"); ver.update(`${hB64}.${pB64}`);
if (!ver.verify(cfg.publicKeyPem, B64.dec(sB64))) throw new Error("signature mismatch");
const p = JSON.parse(B64.dec(pB64).toString()) as TokenPayload;
if (!timingSafeEqual(Buffer.from(p.iss), Buffer.from(cfg.issuer))) throw new Error("iss");
if (p.aud !== cfg.audience) throw new Error("aud");
const now = Math.floor(Date.now() / 1000);
if (p.exp < now) throw new Error("expired");
if (p.nbf > now) throw new Error("not yet valid");
if (DENIED.has(p.jti)) throw new Error("revoked");
return p;
}
OAuth2 / OIDC / SAML Compared
OAuth2 is an authorization framework, NOT authentication. OpenID Connect adds id_token on top of OAuth2 to deliver real login. SAML 2.0 is classic enterprise federation with XML payloads - compatible but clunky on mobile.
| Feature | OAuth2 + PKCE | OIDC (over OAuth2) | SAML 2.0 |
|---|---|---|---|
| Nature | Delegated authorization | User authentication | Federated authN+authZ |
| Best fit | Mobile / SPA | Web/Mobile/APIs | Enterprise internal SSO |
| Credentials | access_token only | access_token + id_token (JWT) | SAML Assertion (XML) |
| Security | Requires PKCE against CSRF | Native nonce support | Signed XML, complex flows |
| Mobile UX | Great (app transitions) | Great | Poor (many redirects) |
| Integration complexity | Medium | Low | High (metadata exchange) |
Best Practices
If JWTs carry sensitive scope: short TTL (5-15 min) + refresh token rotation. SPAs should store refresh tokens in HttpOnly cookies to sidestep XSS. Standardize on OIDC for any third-party login scenario.