Work in progress: under construction · noindex

Tutorial · Advanced

JWT Authentication explained: secure APIs with tokens

Node.js Security Reading time ~11 min Updated: July 2026

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.

Important

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.

JWT (shortened)
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9   ← Header (Base64url)
.eyJzdWIiOjQyLCJlbWFpbCI6ImFAYi5kZSIsImlhdCI6MTc1MTkwMDAwMCwiZXhwIjoxNzUxOTAwOTAwfQ   ← Payload
.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c   ← Signature
Tool tip

On jwt.io you can paste a token and immediately see the header and payload decoded.

The flow: from login to a protected route

  1. The user sends email + password to /auth/login.
  2. The server checks the data and, on success, issues an access token (short-lived) and a refresh token (long-lived).
  3. For every protected request, the client sends the access token in the header: Authorization: Bearer <token>.
  4. The server verifies the signature and grants (or denies) access.
  5. 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.

bash
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

javascript — auth.js
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

javascript — auth.js
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

javascript — auth.js
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

javascript — auth.js
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 tokenRefresh token
Lifetimeshort (5–15 min)long (days to weeks)
Purposeauthorise API requestsfetch a new access token
Storagein the app's memoryhttpOnly cookie
If stolenexpires quicklyrotate & revoke
In summary

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.