Create a JWT in Node.js

jwt.sign({ sub: 'user_8231' }, secret, { algorithm: 'HS256', expiresIn: '1h' })

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’s exp lands 3 seconds after its iat.
  • jose’s setExpirationTime(3600) means 1970. A number is the exp value itself. Pass '1h' or a Date.
  • 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.

Try it in the JWT Encoder

A JSON object naming the algorithm. It's signed as it stands, with the "alg" of the algorithm chosen below.

The claims to sign, as a JSON object. The registered ones — iss, sub, aud, iat, nbf, exp — are explained below.

The secret never leaves your browser: the signature is worked out here, in the page, and nothing is sent anywhere. In real use a server signs the token and keeps the secret, because anyone who holds it can make tokens that pass for yours.

Algorithm

A life stamps iat from your clock and exp that far ahead. The payload keeps any iat it already carries.

Signed at 2026-10-15T09:00:00Z, the moment the example on this page was signed, so the token below is the same one.

The token

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyXzgyMzEiLCJuYW1lIjoiWm_DqyIsImFkbWluIjpmYWxzZSwiaWF0IjoxNzkyMDU0ODAwLCJleHAiOjE3OTIwNTg0MDB9.7Qm3NsIXv2LPNH2RE_5_jQdv-EywdRnbt-sExdv6RNc

headerpayloadsignature

38 bytes of secret, signed as HS256. HMAC with SHA: one shared secret makes the signature and checks it, so anyone who can check this token can also forge it.

Written in from the clock
  • Issued at iat

    1792054800 · 2026-10-15T09:00:00.000Z

  • Expires exp

    1792058400 · 2026-10-15T10:00:00.000Z

Claims
ClaimIn this token
sub

Subject is who the token is about, usually a user id

user_8231
iat

Issued at is when the token was made

17920548002026-10-15T09:00:00.000Z
exp

Expires is when the token stops being accepted

17920584002026-10-15T10:00:00.000Z

Runs in your browser. Nothing you enter is sent anywhere.