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
cryptomodule - Choose between HS256 and an asymmetric algorithm such as RS256
On this page
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.
| Claim | Name | Meaning |
|---|---|---|
iss | Issuer | Who created the token (your auth service) |
sub | Subject | Who the token is about, usually a user id |
aud | Audience | Which service the token is meant for |
exp | Expiration | Unix time in seconds after which the token must be rejected |
nbf | Not before | Unix time before which it must be rejected |
iat | Issued at | Unix time the token was created |
jti | JWT ID | Unique 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).
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.
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 expiredRead 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
| Algorithm | Key | Who can create tokens | Who can verify them | Typical use |
|---|---|---|---|---|
| HS256 | One shared secret | Anyone with the secret | Anyone with the secret | One service issues and checks its own tokens |
| RS256 | RSA private/public pair | Only the private key holder | Anyone with the public key | Auth service issues, many services verify |
| ES256 | Elliptic-curve pair | Only the private key holder | Anyone with the public key | Same 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:
expset fromDate.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.
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.