jwt.sign(claims, secret, { algorithm: 'HS256', expiresIn: '1h' }) from the jsonwebtoken package signs a JWT and adds two claims: iat, the current time in seconds, and exp, an hour later. The jose package, which also runs in browsers, Deno, Bun and Cloudflare Workers, builds the same token step by step. The snippets are ES modules, for the top-level await:
import jwt from 'jsonwebtoken'; // npm install jsonwebtoken
import { SignJWT, jwtVerify } from 'jose'; // npm install jose
const secret = process.env.JWT_SECRET; // 'quackyard-demo-secret-for-hs256-tokens' on this page
const claims = { sub: 'user_8231', name: 'Zoë', admin: false };
const token = jwt.sign(claims, secret, { algorithm: 'HS256', expiresIn: '1h' });
const sameToken = await new SignJWT(claims)
.setProtectedHeader({ alg: 'HS256', typ: 'JWT' })
.setIssuedAt()
.setExpirationTime('1h')
.sign(new TextEncoder().encode(secret));
Signed at 2026-10-15 09:00:00 UTC, both calls return this token (jsonwebtoken 9.0.3, jose 6.2.12):
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyXzgyMzEiLCJuYW1lIjoiWm_DqyIsImFkbWluIjpmYWxzZSwiaWF0IjoxNzkyMDU0ODAwLCJleHAiOjE3OTIwNTg0MDB9.7Qm3NsIXv2LPNH2RE_5_jQdv-EywdRnbt-sExdv6RNc
The encoder below starts on the same header, claims, secret and moment, with a one-hour life, and shows the same token. The signature only matches while the secret, iat and exp do: change the secret, or press Use my clock so the times move, and it changes, as it should. Drop typ: 'JWT' from jose’s header and the header becomes {"alg":"HS256"}, so the token differs but carries the same claims.
Read it back
The JWT Decoder reads this token, at 09:00 UTC, as header {"alg":"HS256","typ":"JWT"}, iat 2026-10-15T09:00:00Z, exp 10:00:00Z, and “Valid now: it expires in 1 hour.” Decoding doesn’t check the signature. Verifying does, here at 09:00 UTC:
jwt.verify(token, secret, { algorithms: ['HS256'] });
// { sub: 'user_8231', name: 'Zoë', admin: false, iat: 1792054800, exp: 1792058400 }
const { payload, protectedHeader } = await jwtVerify(sameToken, new TextEncoder().encode(secret), { algorithms: ['HS256'] });
// payload is the same object; protectedHeader is { alg: 'HS256', typ: 'JWT' }
From 10:00 UTC on, they throw TokenExpiredError: jwt expired and JWTExpired: "exp" claim timestamp check failed. A wrong secret gives JsonWebTokenError: invalid signature. Always pass algorithms, so the token’s own header can’t pick one (RFC 8725).
Gotchas
expiresIn: '3600'is 3.6 seconds. A number means seconds and'1h'a duration, but a string without a unit is milliseconds: that token’sexplands 3 seconds after itsiat.- jose’s
setExpirationTime(3600)means 1970. A number is theexpvalue itself. Pass'1h'or aDate. - jose wants bytes. A string secret throws
TypeError: Key for the HS256 algorithm must be one of type CryptoKey, KeyObject, JSON Web Key, or Uint8Array. - Neither rejects a short secret. RFC 7518 §3.2 requires an HS256 key of at least 256 bits. Generate one with
crypto.randomBytes(32).toString('base64url')and keep it out of the code.
HS256, RS256 or ES256
HS256 uses one shared secret to sign and to verify, so anything that can check a token can also mint one. It fits when the same backend issues and checks its own tokens. RS256 (RSA) and ES256 (ECDSA on P-256) sign with a private key and verify with the public one, so other services, partners or a JWKS endpoint can check tokens without being able to make them; that’s the OpenID Connect pattern. Between the two, ES256 has smaller keys and signatures: 86 base64url characters against 342 for RS256 with a 2048-bit key, and 43 for HS256. ECDSA signatures are randomized, so the same claims signed twice give two different tokens, and both verify.
import { generateKeyPairSync } from 'node:crypto';
import { generateKeyPair } from 'jose';
const rsa = generateKeyPairSync('rsa', { modulusLength: 2048 });
const rs256 = jwt.sign(claims, rsa.privateKey, { algorithm: 'RS256', expiresIn: '1h' });
jwt.verify(rs256, rsa.publicKey, { algorithms: ['RS256'] });
const ec = await generateKeyPair('ES256');
const es256 = await new SignJWT(claims)
.setProtectedHeader({ alg: 'ES256', kid: 'key-2026-10' })
.setIssuedAt()
.setExpirationTime('1h')
.sign(ec.privateKey);
await jwtVerify(es256, ec.publicKey, { algorithms: ['ES256'] });
The encoder below signs HS256, HS384 and HS512 only. Asymmetric tokens need the private key, which belongs on your server.