Concept
The beginner framing: after a user logs in, a server needs a way to recognize them on every subsequent request without asking for their password again, JWTs are one common way to do that, by handing the client a self-contained, verifiable token.
The precise mental model: a JWT (JSON Web Token) is three base64url-encoded segments joined by dots, header.payload.signature. The header names the signing algorithm, the payload holds arbitrary claims (like a user ID), and the signature is a cryptographic proof that the header and payload haven't been tampered with since they were signed.
// A JWT is just three dot-separated segments:
// eyJhbGciOiJIUzI1NiJ9.eyJ1c2VySWQiOjQyfQ.dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk
// header payload signature
const [headerB64, payloadB64] = token.split(".");
console.log(JSON.parse(Buffer.from(payloadB64, "base64url").toString()));
// { userId: 42 }, anyone can decode and read this, no key neededJWTs are signed, not encrypted, this matters
This is the single most important fact about JWTs, and the one most people get wrong the first time: the payload is plainly readable by anyone who has the token, since it's just base64-encoded, not encrypted. The signature proves the payload hasn't been altered since signing, it does nothing to hide the payload's contents. Never put secrets (passwords, raw credit card numbers, anything sensitive) directly in a JWT payload.
Signing algorithms: symmetric vs. asymmetric
const jwt = require("jsonwebtoken"); // illustrative, a common third-party library
// HMAC (HS256), symmetric: the SAME secret both signs and verifies
const token1 = jwt.sign({ userId: 42 }, "shared-secret", { algorithm: "HS256" });
jwt.verify(token1, "shared-secret"); // same secret required here too
// RSA/ECDSA (RS256/ES256), asymmetric: PRIVATE key signs, PUBLIC key verifies
const token2 = jwt.sign({ userId: 42 }, privateKey, { algorithm: "RS256" });
jwt.verify(token2, publicKey); // different key, can be distributed freelyWith HMAC, any service that can verify a token can also forge one, since it holds the same secret used to sign. With RSA/ECDSA, the private key (kept secret, held only by the issuing service) signs, while the public key (safe to distribute widely) only verifies, useful when multiple downstream microservices need to verify tokens but shouldn't be able to mint new ones themselves.
Access + refresh token pattern
// On login:
const accessToken = jwt.sign({ userId }, secret, { expiresIn: "15m" }); // SHORT-lived
const refreshToken = jwt.sign({ userId }, refreshSecret, { expiresIn: "7d" }); // LONGER-lived
// Client stores both. Access token sent on every request.
// When access token expires, client uses refresh token to get a NEW access token,
// without forcing the user to log in again.The access token is short-lived (minutes), limiting how long a stolen token remains useful. The refresh token is longer-lived and used only to obtain new access tokens, typically stored more securely (e.g. an httpOnly cookie) and often checked against a server-side store so it can be revoked, unlike a bare JWT.
Try It
Predict the security implication before checking the solution.
// A developer puts the user's plaintext role AND their raw session-signing
// key directly in the JWT payload, reasoning "it's signed, so it's secure":
const token = jwt.sign({ userId: 42, role: "admin", internalSigningKey: "sk_live_..." }, secret);What's wrong with this, given that the token is signed?
Solution
Being signed only protects against tampering, it does nothing to keep the payload confidential. Anyone who obtains this token (a user inspecting their own browser storage, a network intermediary if the connection isn't secured, a logging system that happens to log request headers) can trivially base64-decode the payload and read internalSigningKey in plaintext. Signing proves authenticity, not secrecy, sensitive values like signing keys should never be placed in a JWT payload at all, signed or not.
Implement It Yourself
Build a minimal HMAC-style sign/verify pair using Node's built-in crypto module, to see what a JWT library does under the hood:
const crypto = require("crypto");
function base64url(input) {
return Buffer.from(input).toString("base64url");
}
function sign(payload, secret) {
const header = base64url(JSON.stringify({ alg: "HS256", typ: "JWT" }));
const body = base64url(JSON.stringify(payload));
const signature = crypto
.createHmac("sha256", secret)
.
This is the essential mechanism: sign the header+payload with a secret, and on verification, recompute the signature and compare, any tampering with the header or payload produces a mismatched signature.
Under the Hood
JWT secrets and signing keys are exactly the kind of sensitive configuration that belongs in environment variables, loaded via --env-file rather than hardcoded (see Environment, Config & Process Management), never committed to source control. Broader authentication risks beyond JWT mechanics specifically, like protecting these secrets, rate-limiting login attempts, and validating tokens defensively, are covered in Security.
Common Mistakes
1. Assuming a JWT's payload is confidential because it's signed
jwt.sign({ userId, ssn: "123-45-6789" }, secret); // ❌ SSN is plainly readable by anyone with the tokenSigning proves the payload wasn't tampered with, it says nothing about who can read it. Treat JWT payloads as fully public.
2. Storing passwords with a fast, general-purpose hash
const hash = crypto.createHash("sha256").update(password).digest("hex"); // ❌ NOT for passwordsSHA-256 is fast, which is exactly the wrong property for password hashing, an attacker with a leaked hash database can brute-force billions of SHA-256 guesses per second on modern hardware. Password hashing needs to be deliberately slow and tunable.
3. Never expiring or rotating tokens
jwt.sign({ userId }, secret); // ❌ no expiresIn, token is valid FOREVERA JWT with no expiration, if ever leaked, remains valid indefinitely with no way to revoke it (JWTs are stateless by design). Always set a reasonable expiresIn, and pair long-lived access with a genuinely revocable refresh mechanism.
Best Practices
- Use
bcryptorargon2for password hashing, never a general-purpose fast hash like SHA-256/MD5, both are deliberately slow and include a tunable cost factor that can be increased as hardware gets faster. - Keep access tokens short-lived (minutes, not days) and use a refresh-token flow for renewing them, so a leaked access token has a small window of usefulness.
- Never put sensitive data in a JWT payload, only non-sensitive claims needed for authorization decisions (user ID, role, etc.).
- Store signing secrets and keys in environment variables, never hardcoded or committed to source control (see Environment, Config & Process Management).
Performance Tips
- bcrypt/argon2's deliberate slowness is a feature for password hashing but means it should never be used for anything performance-sensitive or called excessively, hash once at signup/login, not repeatedly in a hot path.
- Verifying a JWT's signature (especially HMAC) is cheap and fast, this is part of why JWTs are attractive for stateless auth checks on every request, compared to a database lookup per request for session validation.
