How to Decode a JWT in Node.js

Decode JWT header and payload in Node.js safely, and when to verify signatures instead of only decoding.

By Mariana Soto · Published 2026-07-04 · Updated 2026-08-22

Decoding a JWT in Node.js is straightforward — but decoding alone does not authenticate a request. Anyone can Base64URL-decode a token and read its claims; only signature verification proves the token was issued by a party you trust. Use decode for inspection and debugging; use verify for trust.

This guide walks through manual decoding (educational), library-based verification (production), and how Cyberway fits into a local development workflow.

Split and Base64URL-decode manually

A JWT is header.payload.signature. Each of the first two parts is JSON encoded as Base64URL (URL-safe Base64 without padding).

function base64UrlDecode(str) {
  const padded = str + "=".repeat((4 - (str.length % 4)) % 4);
  const base64 = padded.replace(/-/g, "+").replace(/_/g, "/");
  return Buffer.from(base64, "base64").toString("utf8");
}

function decodeJwt(token) {
  const parts = token.split(".");
  if (parts.length !== 3) {
    throw new Error("Invalid JWT structure");
  }
  const [headerPart, payloadPart] = parts;
  return {
    header: JSON.parse(base64UrlDecode(headerPart)),
    payload: JSON.parse(base64UrlDecode(payloadPart)),
  };
}

const { header, payload } = decodeJwt(token);
console.log(header);  // { alg: 'HS256', typ: 'JWT' }
console.log(payload); // { sub: '...', exp: ... }

This teaches the format but must not replace verification in production. An attacker can craft any payload and append a garbage signature — your decode function will happily parse it.

Why not hand-roll crypto in production

Manual decoding skips critical checks:

  • Signature validation — the core of authentication
  • Algorithm allowlisting — prevents alg: none and algorithm confusion
  • Time claimsexp, nbf enforcement
  • Issuer and audience — prevents cross-service token reuse

Padding edge cases, Unicode in claims, and timing-safe comparison are easy to get wrong. Use a maintained library.

Verify with jose (recommended)

jose is the library Cyberway uses internally. For HS256 verification:

import { jwtVerify, decodeJwt } from "jose";

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

// Inspect without verifying (debug only)
const claims = decodeJwt(token);
console.log(claims.exp, claims.sub);

// Production path — verify signature and claims
const { payload, protectedHeader } = await jwtVerify(token, secret, {
  algorithms: ["HS256"],
  issuer: "https://auth.example.com",
  audience: "https://api.example.com",
});

console.log(payload.sub);
console.log(protectedHeader.alg);

For RS256, pass a CryptoKey or import a PEM public key instead of a shared secret. Always pass algorithms explicitly — never trust the header's alg alone.

Error handling

Wrap verification and map errors to HTTP responses:

import { jwtVerify, errors } from "jose";

try {
  const { payload } = await jwtVerify(token, secret, {
    algorithms: ["HS256"],
  });
  req.user = payload;
} catch (err) {
  if (err instanceof errors.JWTExpired) {
    return res.status(401).json({ error: "token_expired" });
  }
  if (err instanceof errors.JWTClaimValidationFailed) {
    return res.status(401).json({ error: "invalid_claims" });
  }
  return res.status(401).json({ error: "invalid_token" });
}

Log the error type server-side; return generic messages to clients to avoid leaking validation details.

Testing fixtures with Cyberway

A practical workflow:

  1. Open the Cyberway JWT Generator and create a token with known claims and a test secret.
  2. Copy the token into your Node test or REPL.
  3. Run jwtVerify with the same secret and confirm claims match.
  4. Paste the token into the JWT Decoder to visually compare what Cyberway shows vs what your code reads.

Generate tokens with deliberate flaws (expired exp, weak secret) to test your error paths. Because Cyberway runs client-side, test secrets never leave your machine during fixture creation.

Security checklist before production

  • [ ] Verify signature on every protected route
  • [ ] Allowlist algorithms (HS256 only, or RS256 only — not both blindly)
  • [ ] Validate exp and nbf (library default in jwtVerify)
  • [ ] Validate iss and aud when multiple services share an auth issuer
  • [ ] Reject tokens with unexpected claim shapes (schema validation)
  • [ ] Never log full tokens in production — they are credentials

See RFC 7515 for JWS structure and common JWT security mistakes for pitfalls.

Express middleware example

A minimal Express middleware pattern:

import { jwtVerify } from "jose";

export function requireAuth(secret) {
  return async (req, res, next) => {
    const header = req.headers.authorization;
    if (!header?.startsWith("Bearer ")) {
      return res.status(401).json({ error: "missing_token" });
    }
    const token = header.slice(7);
    try {
      const { payload } = await jwtVerify(token, secret, {
        algorithms: ["HS256"],
      });
      req.auth = payload;
      next();
    } catch {
      return res.status(401).json({ error: "invalid_token" });
    }
  };
}

Mount this only on protected routes — public health checks should not parse JWTs at all.

Unit testing tips

  • Generate fixtures with Cyberway JWT Generator and commit only the public parts (expected claims JSON), not secrets.
  • Test expired tokens by setting exp to Math.floor(Date.now() / 1000) - 10 at test runtime.
  • Test algorithm rejection by passing a token signed with RS256 to an HS256-only verifier.

When to decode vs verify in Node

Use decodeJwt from jose only in development tooling, admin scripts, or log redaction pipelines where you need claim visibility without trusting the input. Every HTTP middleware, WebSocket auth hook, and background job consumer that gates access must call jwtVerify. If you are unsure which path a file uses, search for decode without Verify — that is a common audit finding.

Continue with the Complete JWT Guide for claims, algorithms, and lifecycle design.

Try the Cyberway JWT Decoder or JWT Generator — both run entirely in your browser.