Skip to main content
PProfileMatch

JWT Explained: Structure, Signing and Verification with Node.js

Backend DevelopmentLesson 1 of 46 min read

What you'll learn

  • Split a JWT into header, payload and signature and decode each part by hand
  • Explain why base64url encoding is not encryption
  • Compute and verify an HS256 signature using Node's crypto module
  • Choose between HS256 and an asymmetric algorithm such as RS256

On this page

  1. Three parts, two dots
  2. Signing by hand with Node's crypto
  3. Running it: decode, verify, tamper, expire
  4. Symmetric or asymmetric signing
  5. Common mistakes and the symptoms they cause
  6. How this shows up in real projects and interviews

Paste a JWT from any app you use into a free online decoder, and the decoder instantly shows the user id, the role and the expiry time. It never asked for a password or a key. That surprises many developers who assumed the long string was encrypted, and it is the single most important fact about JWTs: the payload is readable by anyone who holds the token. What the token gives you is not secrecy. It gives you tamper-evidence, because anyone who changes the contents invalidates the signature. This lesson builds one from scratch so that you can see exactly where that guarantee comes from.

Three parts, two dots

A JSON Web Token (RFC 7519) is a compact string of three base64url-encoded parts separated by dots: header.payload.signature. The header says how the token is signed ({"alg": "HS256", "typ": "JWT"}). The payload carries claims: statements about the subject. The signature is a cryptographic checksum over the first two parts, computed with a key.

Base64url is an encoding for turning bytes into URL-safe text (letters, digits, - and _, no padding). It exists so that JSON can travel in headers and URLs. It is reversible by anyone with no key, which is why it must never be mistaken for encryption. A JWT that is signed, which is what people almost always mean, is formally called a JWS; there is also an encrypted variant (JWE) that is rarely used in everyday web logins.

ClaimNameMeaning
issIssuerWho created the token (your auth service)
subSubjectWho the token is about, usually a user id
audAudienceWhich service the token is meant for
expExpirationUnix time in seconds after which the token must be rejected
nbfNot beforeUnix time before which it must be rejected
iatIssued atUnix time the token was created
jtiJWT IDUnique id, useful for revocation lists

Signing by hand with Node's crypto

HS256 means HMAC with SHA-256: a keyed hash that anyone holding the same secret can recompute. The signer hashes base64url(header) + "." + base64url(payload) with the secret and appends the result. The verifier repeats the computation and compares. The module below does exactly that and nothing else, in about 40 lines. It is for learning; in production use a maintained library (see the callout below).

jwt-lib.mjs
import { createHmac, timingSafeEqual } from "node:crypto";

const toB64Url = (text) => Buffer.from(text).toString("base64url");
const fromB64Url = (part) => JSON.parse(Buffer.from(part, "base64url").toString("utf8"));

export function sign(payload, secret) {
  const header = { alg: "HS256", typ: "JWT" };
  const signingInput = `${toB64Url(JSON.stringify(header))}.${toB64Url(JSON.stringify(payload))}`;
  const signature = createHmac("sha256", secret).update(signingInput).digest("base64url");
  return `${signingInput}.${signature}`;
}

export function verify(token, secret, { now = Math.floor(Date.now() / 1000) } = {}) {
  const parts = token.split(".");
  if (parts.length !== 3) throw new Error("malformed token");
  const [headerPart, payloadPart, signaturePart] = parts;

  const header = fromB64Url(headerPart);
  if (header.alg !== "HS256") throw new Error(`unsupported alg: ${header.alg}`);

  const expected = createHmac("sha256", secret)
    .update(`${headerPart}.${payloadPart}`)
    .digest();
  const received = Buffer.from(signaturePart, "base64url");
  if (received.length !== expected.length || !timingSafeEqual(received, expected)) {
    throw new Error("bad signature");
  }

  const payload = fromB64Url(payloadPart);
  if (typeof payload.exp !== "number" || payload.exp <= now) throw new Error("token expired");
  return payload;
}

export const decodeUnverified = (token) => {
  const [h, p] = token.split(".");
  return { header: fromB64Url(h), payload: fromB64Url(p) };
};

Three details matter. The verifier checks header.alg itself instead of letting the token decide how to verify it (the final lesson shows why). It compares signatures with timingSafeEqual, which takes the same time regardless of where two byte strings first differ, so an attacker cannot learn a signature byte by byte from response timing. And it refuses tokens that carry no exp, so nothing is valid forever by accident.

Running it: decode, verify, tamper, expire

The demo signs a token for one pass holder, decodes it with no key, verifies it, and then tries three ways to cheat. Time is passed in as a parameter (now), so the run is repeatable; the timestamps are Unix seconds on 5 October 2026.

demo.mjs
import { sign, verify, decodeUnverified } from "./jwt-lib.mjs";

const SECRET = "demo-only-secret-change-me-0123456789"; // never hard-code a real one
const payload = { sub: "member_2041", role: "member", iat: 1791165600, exp: 1791166500 };

const token = sign(payload, SECRET);
console.log(token);

console.log("--- anyone can read the parts, no key needed");
console.log(decodeUnverified(token));

console.log("--- verification");
console.log("valid, at t+100s:", verify(token, SECRET, { now: 1791165700 }));

const [h, , s] = token.split(".");
const forgedBody = Buffer.from(JSON.stringify({ ...payload, role: "admin" })).toString("base64url");
for (const [label, candidate, key, now] of [
  ["role edited, old signature", `${h}.${forgedBody}.${s}`, SECRET, 1791165700],
  ["wrong secret", token, "some-other-secret", 1791165700],
  ["after exp, at t+1000s", token, SECRET, 1791166600],
]) {
  try {
    verify(candidate, key, { now });
  } catch (err) {
    console.log(`${label}: rejected -> ${err.message}`);
  }
}

Output

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJtZW1iZXJfMjA0MSIsInJvbGUiOiJtZW1iZXIiLCJpYXQiOjE3OTExNjU2MDAsImV4cCI6MTc5MTE2NjUwMH0.HiCEAT36RTRsLMbedLhFUQdMeaeJdnrBh1MNpk09PfQ
--- anyone can read the parts, no key needed
{
  header: { alg: 'HS256', typ: 'JWT' },
  payload: {
    sub: 'member_2041',
    role: 'member',
    iat: 1791165600,
    exp: 1791166500
  }
}
--- verification
valid, at t+100s: {
  sub: 'member_2041',
  role: 'member',
  iat: 1791165600,
  exp: 1791166500
}
role edited, old signature: rejected -> bad signature
wrong secret: rejected -> bad signature
after exp, at t+1000s: rejected -> token expired

Read the output in order. The printed token is deterministic: the same header, payload and secret always give the same string, which is easy to check by pasting it into any decoder (I also confirmed that Python's PyJWT library decodes it with the same secret and rejects any other). The decoded parts needed no key. Then the three attacks fail for different reasons. Editing the role to admin while reusing the old signature fails because the signature no longer matches the new payload. Presenting the real token to a verifier with a different secret fails because the HMAC differs. And the genuine token stops working after exp.

Symmetric or asymmetric signing

AlgorithmKeyWho can create tokensWho can verify themTypical use
HS256One shared secretAnyone with the secretAnyone with the secretOne service issues and checks its own tokens
RS256RSA private/public pairOnly the private key holderAnyone with the public keyAuth service issues, many services verify
ES256Elliptic-curve pairOnly the private key holderAnyone with the public keySame as RS256 with smaller keys and signatures

With HS256, every service that can verify a token can also forge one, because verification and signing use the same secret. That is acceptable inside one application but is a poor fit once several teams or vendors verify tokens. An asymmetric algorithm lets you publish the public key (often as a JWKS endpoint) and keep the private key in one place. Whatever you choose, the secret or private key must be long and random (for HS256, at least 32 random bytes), generated by a CSPRNG and loaded from the environment or a secrets manager, never committed to the repository.

Common mistakes and the symptoms they cause

  • Treating the payload as private. Symptom: a security review finds an email address, a phone number or an internal flag readable by anyone who views the token in browser dev tools or a log file. Put only what the receiver needs, such as a user id and a role.
  • Reading claims without verifying. Symptom: a user edits their own token in dev tools, sets "role": "admin", and the API believes it because it only base64-decoded the payload.
  • Comparing signatures with `===`. Symptom: nothing visible, until someone measures response times. Use a constant-time comparison.
  • No `exp` claim. Symptom: a leaked token from last year still works today.
  • A short, guessable secret such as `secret` or `changeme`. Symptom: an attacker who obtains one token can test guesses offline at high speed and, once successful, sign any token they like. Use a long random secret.
  • Seconds versus milliseconds. Symptom: exp set from Date.now() without dividing by 1000 produces a token that appears valid for tens of thousands of years, or one that is immediately expired when the unit is mixed up the other way.

How this shows up in real projects and interviews

Most real projects use a library and never write this code, but you will read it constantly: in a debugging session when a "401 invalid signature" appears, in an incident review, or in an interview. A common interview question is "is a JWT encrypted?" and the strong answer is: no, it is signed, so it is tamper-evident but readable. Follow-ups include "HS256 versus RS256" and "what happens if someone steals a token?", which leads directly to the next lesson about short lifetimes and refresh tokens: JWT Login Flow: Access Tokens, Refresh Tokens and Rotation.

Try it yourself

Extend jwt-lib.mjs so verify accepts an optional issuer and audience and rejects mismatches with clear error messages. Then write tests in a script that print accepted or rejected -> <reason> for: a valid token, a wrong issuer, a wrong audience, a token with its payload edited, and a token missing its exp.

Show a hint

The aud claim may be a string or an array, so normalise with [].concat(payload.aud ?? []) and use includes. Check the signature first, and only read claims from the payload after the signature has passed.

Key takeaways

  • A JWT is base64url(header).base64url(payload).signature; the payload is readable by anyone, so never put secrets in it.
  • HS256 signs with HMAC-SHA-256 over the first two parts; verification recomputes it and compares in constant time.
  • Decoding is not verifying: only act on claims from a token whose signature, algorithm and expiry were checked.
  • HS256 lets every verifier forge tokens; RS256 or ES256 lets many services verify with a public key while one service signs.
  • Use long random secrets from the environment, always include exp, and prefer a maintained library in production.

Test yourself

A trick from this lesson's ride: A signed JWT is a postcard with a wax seal: anyone can read it, but tampering shows. Four more tricks like it wait along the route, each followed by a question.

Ride this lesson

Frequently asked questions

Is a JWT encrypted?

Usually not. The common kind of JWT is signed (a JWS): the header and payload are only base64url-encoded, so anyone with the token can read them, but any change breaks the signature. An encrypted variant (JWE) exists, but if you need confidentiality you should keep sensitive data out of the token and on the server instead.

How does a JWT signature work?

For HS256, the signer computes an HMAC-SHA-256 of base64url(header).base64url(payload) using a secret key and appends it as the third part. The verifier recomputes the HMAC with the same secret and compares it to the received signature. With RS256 or ES256, the issuer signs with a private key and anyone with the public key can verify.

What is the difference between HS256 and RS256?

HS256 is symmetric: one shared secret both signs and verifies, so every verifier can also forge tokens. RS256 is asymmetric: only the holder of the private key can sign, and any service with the public key can verify. Use RS256 or ES256 when several services or third parties need to verify tokens.