Tutorial · Advanced
JWT Authentication explained: secure APIs with tokens
JSON Web Tokens (JWT) are the standard for securing APIs statelessly. This tutorial explains the structure and login flow and shows a secure implementation with Node.js – with a short-lived access token and a long-lived refresh token.
Why JWT?
With classic session authentication, the server stores a session for every logged-in user – that costs memory and makes it harder to scale across multiple servers. A JWT turns this around: after login the client receives a signed token that it sends with every request. The server doesn't have to store anything – it only verifies the signature.
A JWT is therefore self-contained (it holds all the necessary information) and tamper-proof: without the secret signing key, the content can't be changed unnoticed.
Signed does not mean encrypted. The header and payload of a standard JWT are only Base64url-encoded and readable by anyone. So never put passwords or secrets in the payload.
The structure of a JWT
A JWT consists of three parts separated by dots: header.payload.signature.
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9 ← Header (Base64url)
.eyJzdWIiOjQyLCJlbWFpbCI6ImFAYi5kZSIsImlhdCI6MTc1MTkwMDAwMCwiZXhwIjoxNzUxOTAwOTAwfQ ← Payload
.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c ← Signature
- Header – which algorithm was used to sign, e.g.
{ "alg": "HS256", "typ": "JWT" }. - Payload – the "claims", i.e. the data. Common fields:
sub(subject/user ID),iat(issued at),exp(expiry). - Signature – a hash of header, payload and secret. If an attacker changes the payload, the signature no longer matches and the server rejects the token.
On jwt.io you can paste a token and immediately see the header and payload decoded.
The flow: from login to a protected route
- The user sends email + password to
/auth/login. - The server checks the data and, on success, issues an access token (short-lived) and a refresh token (long-lived).
- For every protected request, the client sends the access token in the header:
Authorization: Bearer <token>. - The server verifies the signature and grants (or denies) access.
- When the access token expires, the client fetches a new one using the refresh token – without logging in again.
Implementation with Node.js & Express
We use jsonwebtoken (currently version 9.0.3) to sign/verify and
bcrypt to securely hash passwords. cookie-parser reads the
refresh cookie.
npm install express jsonwebtoken bcrypt cookie-parser
# Generate two strong, SEPARATE secrets and set them as environment variables:
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
Skeleton & registration
const express = require("express");
const jwt = require("jsonwebtoken");
const bcrypt = require("bcrypt");
const cookieParser = require("cookie-parser");
const app = express();
app.use(express.json());
app.use(cookieParser());
// NEVER hard-code – always load from the environment:
const ACCESS_SECRET = process.env.ACCESS_SECRET;
const REFRESH_SECRET = process.env.REFRESH_SECRET;
// Demo in-memory "database"
const users = []; // { id, email, passwordHash }
// Access token: short-lived (15 minutes)
function createAccessToken(user) {
return jwt.sign(
{ sub: user.id, email: user.email },
ACCESS_SECRET,
{ expiresIn: "15m" }
);
}
// Registration – the password is hashed, never stored in clear text
app.post("/auth/register", async (req, res) => {
const { email, password } = req.body;
if (!email || !password) {
return res.status(400).json({ error: "email and password required" });
}
const passwordHash = await bcrypt.hash(password, 12); // 12 = cost factor
const user = { id: users.length + 1, email, passwordHash };
users.push(user);
res.status(201).json({ id: user.id, email: user.email });
});
Login: issuing tokens
app.post("/auth/login", async (req, res) => {
const { email, password } = req.body;
const user = users.find(u => u.email === email);
// Same error message for "user not found" and "wrong password",
// so attackers can't tell which emails exist.
if (!user || !(await bcrypt.compare(password, user.passwordHash))) {
return res.status(401).json({ error: "Ungültige Anmeldedaten" });
}
const accessToken = createAccessToken(user);
const refreshToken = jwt.sign(
{ sub: user.id },
REFRESH_SECRET,
{ expiresIn: "7d" }
);
// Refresh token as an httpOnly cookie: invisible to JavaScript (protects against XSS)
res.cookie("refreshToken", refreshToken, {
httpOnly: true,
secure: true, // HTTPS only
sameSite: "strict", // protects against CSRF
maxAge: 7 * 24 * 60 * 60 * 1000
});
// The client gets the access token in the body (keeps it e.g. in memory)
res.json({ accessToken });
});
Middleware & protected route
function auth(req, res, next) {
const header = req.headers.authorization || "";
const token = header.startsWith("Bearer ") ? header.slice(7) : null;
if (!token) {
return res.status(401).json({ error: "No token" });
}
try {
// Pin the algorithm – prevents "alg" confusion attacks
const payload = jwt.verify(token, ACCESS_SECRET, { algorithms: ["HS256"] });
req.user = payload;
next();
} catch (err) {
return res.status(401).json({ error: "Token invalid or expired" });
}
}
// Only reachable with a valid access token
app.get("/me", auth, (req, res) => {
res.json({ id: req.user.sub, email: req.user.email });
});
Refresh: a new access token without logging in
app.post("/auth/refresh", (req, res) => {
const token = req.cookies.refreshToken;
if (!token) {
return res.status(401).json({ error: "No refresh token" });
}
try {
const payload = jwt.verify(token, REFRESH_SECRET, { algorithms: ["HS256"] });
const user = users.find(u => u.id === payload.sub);
if (!user) throw new Error("unbekannt");
res.json({ accessToken: createAccessToken(user) });
} catch (err) {
return res.status(401).json({ error: "Refresh token invalid" });
}
});
app.listen(3000, () => console.log("Auth-Server auf http://localhost:3000"));
Security: what matters
| Access token | Refresh token | |
|---|---|---|
| Lifetime | short (5–15 min) | long (days to weeks) |
| Purpose | authorise API requests | fetch a new access token |
| Storage | in the app's memory | httpOnly cookie |
| If stolen | expires quickly | rotate & revoke |
- Keep the access token short (5–15 min). That limits the damage if one is leaked.
- Deliver the refresh token as an
httpOnly,Secure,SameSitecookie – so JavaScript can't reach it (protection against XSS and CSRF). - Separate secrets for the access and refresh tokens, always from environment variables / a secret manager – never in the code.
- Pin the algorithm: always pass
algorithms: ["HS256"]toverify. Otherwise you risk "alg" confusion attacks (e.g.alg: none). - Refresh token rotation: issue a new one on every use and invalidate the old one. If an already-used token shows up again, revoke the entire token family.
- Minimal payload: include only what's necessary – no sensitive data.
JWT makes authentication stateless and scalable. The pattern "short-lived access token + long-lived, well-protected refresh token" combines convenience with security – as long as secrets and algorithm are handled properly.
Did this tutorial help you?
This content is free. I'd really appreciate a coffee. ☕