Guide 3 min read Updated

Verify JWT in Node.js (jsonwebtoken & jose)

Verify JWT signatures in Node.js with jsonwebtoken and jose. Sign and verify HS256 and RS256 tokens, validate iss/aud/exp claims, and verify JWTs against a JWKS endpoint with full code examples.

Node.js has two production-grade JWT libraries: jsonwebtoken (the long-standing default for HS256/RS256) and jose (the modern choice that supports every JOSE algorithm including EdDSA, plus built-in JWKS verification). This page covers both: signing, verifying with claim validation, and verifying against a JWKS endpoint.

Install

# jsonwebtoken — HS256, RS256, simple synchronous API
npm install jsonwebtoken

# jose — all JOSE algorithms, JWKS, JWE, maintained
npm install jose

Sign and verify an HS256 token (jsonwebtoken)

const jwt = require("jsonwebtoken");

// 32+ bytes of cryptographically random data — never a password
const secret = process.env.JWT_SECRET;

// Sign
const token = jwt.sign(
  { sub: "user_123", role: "admin" },
  secret,
  {
    algorithm: "HS256",
    expiresIn: "15m",
    issuer: "https://auth.example.com",
    audience: "https://api.example.com",
  }
);

// Verify — hardcode the algorithm, validate iss + aud + exp
const payload = jwt.verify(token, secret, {
  algorithms: ["HS256"],                    // never read from token header
  issuer: "https://auth.example.com",
  audience: "https://api.example.com",
  clockTolerance: 60,                       // 60s skew tolerance for exp/nbf
});

console.log(payload.sub); // "user_123"

Sign and verify an RS256 token (jose)

import { SignJWT, jwtVerify, importPKCS8, importSPKI } from "jose";

const privateKey = await importPKCS8(process.env.RSA_PRIVATE_PEM, "RS256");
const publicKey = await importSPKI(process.env.RSA_PUBLIC_PEM, "RS256");

// Sign
const token = await new SignJWT({ sub: "user_123", role: "admin" })
  .setProtectedHeader({ alg: "RS256", kid: "rsa-key-v1" })
  .setIssuedAt()
  .setIssuer("https://auth.example.com")
  .setAudience("https://api.example.com")
  .setExpirationTime("15m")
  .sign(privateKey);

// Verify
const { payload } = await jwtVerify(token, publicKey, {
  issuer: "https://auth.example.com",
  audience: "https://api.example.com",
});

Verify a JWT against a JWKS endpoint (jose)

When you validate tokens issued by Auth0, Okta, AWS Cognito, Google, or Microsoft Entra ID, the public keys live at a JWKS URL. jose handles fetching and caching for you:

import { jwtVerify, createRemoteJWKSet } from "jose";

const JWKS_URL = new URL("https://YOUR_DOMAIN.auth0.com/.well-known/jwks.json");
const JWKS = createRemoteJWKSet(JWKS_URL);   // jose caches keys automatically

async function verifyAccessToken(token) {
  const { payload, protectedHeader } = await jwtVerify(token, JWKS, {
    issuer: `https://YOUR_DOMAIN.auth0.com/`,
    audience: "https://api.example.com",
  });
  return payload;
}

createRemoteJWKSet caches the JWKS response and only refetches when a token’s kid is not in the cache or the cached set is stale. Do not fetch the JWKS yourself on every request — that adds latency and makes the issuer a synchronous dependency for every authenticated request.

Validation order

Verify in this exact order — reading claims before signature verification is a security bug:

  1. Structure — is it three dot-separated base64url segments?
  2. Signature — does it verify against the expected key and hardcoded algorithm?
  3. Claimsexp (not expired), nbf (active), iss (expected issuer), aud (expected audience).
  4. Authorization — do the claims permit this action?

Both jsonwebtoken and jose perform steps 1–2 on every verify call. Steps 3–4 require you to pass issuer and audience explicitly — neither library guesses them.


Continue reading