Ayesha manages a small online shop. Each morning she checks yesterday's paid orders and marks parcels ready to ship. Her dashboard talks to a small Node.js API, and every request has to answer one question first: is this really Ayesha? The usual answer is a session stored on the server, but that means a database lookup on every request, and it breaks when the API grows a second server. A JSON Web Token, a JWT, answers the same question without the lookup. The token carries who the caller is, and the server checks the token's signature instead of asking a database. This post builds that check with nothing but Node.js built-in crypto, and shows where it breaks if you use it carelessly.
What I would check before using it
My judgement, from building small APIs for years, is this: a JWT is the right call when several services need to check who is calling without sharing a database, and the wrong call when you need to cancel access instantly. The first check is revocation. A token stays valid until it expires, and there is no way to cancel it early, so a stolen token keeps working until its expiry time passes. The honest fix is a short lifetime, fifteen minutes to an hour. If you need a logout button that works everywhere at once, use sessions, because a JWT on its own cannot do that.
The second check is what goes inside the token. The payload is only encoded, not encrypted, so anyone who gets the token can read it. Keep it to an id, a name, and a role, and never put a password or anything else sensitive in there.
The third check is the secret. The whole scheme rests on one secret string that signs every token. It must be long and random, it must live in an environment variable rather than in the code, and every service that verifies tokens must use the same one. A short secret such as "secret" can be guessed offline by trying candidates against a captured token.
The fourth check is size. The token travels on every request, usually in an Authorization header, so keep the payload small.
The verdict, in short: use a JWT for short-lived access tokens in this shop API, with a one-hour expiry and a payload holding only an id, a name, and a role. Do not use it where instant logout matters.
A worked example that is not a toy
The worked example is the shop's login flow. Ayesha logs in, the API issues a token that says she is user-1042, a shop manager, valid for one hour. Her dashboard then sends that token with every request, and the API verifies it before returning any orders. The token has three parts, separated by dots: a header saying which algorithm signed it, a payload with the claims, and a signature. Both functions below were run with Node.js built-in crypto only, no packages installed.
const crypto = require('node:crypto'); const SECRET = 'demo-secret-do-not-use-in-production'; function base64url(input) { return Buffer.from(input).toString('base64url'); } function signToken(payload, secret) { const header = { alg: 'HS256', typ: 'JWT' }; const encodedHeader = base64url(JSON.stringify(header)); const encodedPayload = base64url(JSON.stringify(payload)); const data = encodedHeader + '.' + encodedPayload; const signature = crypto.createHmac('sha256', secret).update(data).digest('base64url'); return data + '.' + signature; } function verifyToken(token, secret) { const parts = token.split('.'); if (parts.length !== 3) throw new Error('malformed token: expected three parts'); const [encodedHeader, encodedPayload, signature] = parts; const data = encodedHeader + '.' + encodedPayload; const expected = crypto.createHmac('sha256', secret).update(data).digest(); const actual = Buffer.from(signature, 'base64url'); if (actual.length !== expected.length || !crypto.timingSafeEqual(actual, expected)) { throw new Error('invalid signature'); } const payload = JSON.parse(Buffer.from(encodedPayload, 'base64url').toString('utf8')); if (payload.exp && Math.floor(Date.now() / 1000) > payload.exp) { throw new Error('token expired'); } return payload; } Issuing the token at login and verifying it on the next request gave this real output:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyLTEwNDIiLCJuYW1lIjoiQXllc2hhIiwicm9sZSI6InNob3AtbWFuYWdlciIsImlhdCI6MTc5MzkxMjI4NywiZXhwIjoxNzkzOTE1ODg3fQ.BW4Av2PoMZMpM82x_3g4F7VNWODbf4u2z9t4hZig8f8 Decoded payload: {"sub":"user-1042","name":"Ayesha","role":"shop-manager","iat":1793912287,"exp":1793915887} Access granted for Ayesha (role: shop-manager) The header and payload are readable once decoded; the signature is what makes the token trustworthy. The iat and exp numbers are seconds since 1 January 1970, from the real run, so yours will differ.
Now the part the tidy tutorials skip: something goes wrong. A curious user copies the token, changes one character in the payload, and sends the edited token back. The signature was computed over the original payload, so it no longer matches the edited one, and the check refuses it:
Rejected: invalid signature That is the whole security of the scheme in one line. The signature covers the header and the payload together, so any change to either part breaks it. The same check also catches a token verified with the wrong secret, which fails with the same message, and an expired token, which fails differently:
Rejected: token expired The fix is already in the function above, and it is a matter of order: verify the signature first, then read the payload, then check the expiry before trusting anything it says. A version of the check that skips the expiry test is worse than useless, because it looks secure while accepting tokens that should be dead. Running the expired token through such a version proved it:
Accepted anyway: {"sub":"user-1042","name":"Ayesha","role":"shop-manager","iat":1793905087,"exp":1793908687} The token had expired an hour before the check, and the careless verifier let it through. The lesson is that a JWT is not a magic string. It is a signature plus an expiry date, and both have to be checked every time.
Common mistakes and how to fix them
1. Trusting the payload before verifying the signature. The payload is only base64url encoded, so anyone can decode it without the secret. If your code reads the role from the payload without checking the signature, an attacker can set the role to "admin" in their own token. The fix: verify the signature first, then read the payload.
2. Using plain base64 instead of base64url. Node's Buffer.toString('base64') produces +, /, and = padding, which break inside a URL or a header, and encoding the same payload with plain base64 gave a string ending in ==. The fix is to use the base64url encoding for tokens.
3. Skipping the expiry check. A verifier that never looks at exp accepts tokens forever, as the run above showed. The fix is the exp comparison in the function above, run on every request.
4. A weak secret, or a different secret in each service. Verifying the real token with a different secret failed with "invalid signature", which is also what happens when two services drift apart. Worse, a short secret such as "secret" can be guessed offline against a captured token. The fix is one long random secret, at least 32 characters, in an environment variable, shared by every service that verifies tokens.
Where this leaves you
A JWT is a signature check, not encryption. The signature proves nobody changed the token, and the expiry date limits how long a stolen one stays useful. Keep tokens short-lived, keep payloads small and free of secrets, and keep the signing secret long, random, and out of the code. The test is simple. Take the token from the run above, change one character, and watch it get rejected, then check that your own verifier also rejects an expired token. The next step is to move the secret into an environment variable, add a login route that issues the token, and put the verify function in front of the orders route.
Further reading
- RFC 7519, JSON Web Token (JWT)
- RFC 8725, JSON Web Token Best Current Practices
- Node.js crypto documentation
Originally published at https://practicaldevblog.blogspot.com/2026/11/jwt-authentication-explained-token-you.html