DEV Community

arenasbob2024-cell
arenasbob2024-cell

Posted on • Originally published at viadreams.cc

JWT Tokens Explained: A Practical Guide for Web Developers

If you have built a login system, called a third-party API, or worked with OAuth, you have encountered JSON Web Tokens. JWTs are everywhere in modern web development, yet many developers use them without fully understanding what they are, how they work, or what can go wrong.

This guide covers JWT from the ground up: structure, signing algorithms, practical Node.js examples, when to choose JWT over sessions, and the security mistakes that actually matter in production.

What Is a JWT?

A JSON Web Token is a compact, URL-safe string that carries a set of claims between two parties. It is defined in RFC 7519. The token is self-contained, meaning the server can verify it without looking anything up in a database.

A typical JWT looks like this:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkFsaWNlIiwiaWF0IjoxNzA5MTIzNDU2fQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
Enter fullscreen mode Exit fullscreen mode

Three parts separated by dots. That is the entire token. You can paste it into a JWT decoder to see what is inside without any special software.

JWT Structure: Header, Payload, Signature

Every JWT has exactly three segments, each Base64URL-encoded.

Header

The header declares the token type and the signing algorithm:

{
  "alg": "HS256",
  "typ": "JWT"
}
Enter fullscreen mode Exit fullscreen mode

The alg field is critical. It tells the verifier which algorithm was used to create the signature. Common values include HS256, RS256, ES256, and none (which you should never accept in production, more on that later).

Payload

The payload carries the claims, which are statements about the user and additional metadata:

{
  "sub": "user-8842",
  "name": "Alice Chen",
  "email": "alice@example.com",
  "role": "admin",
  "iat": 1709123456,
  "exp": 1709127056
}
Enter fullscreen mode Exit fullscreen mode

Claims fall into three categories:

Registered claims are standardized names defined in the spec:

Claim Full Name Purpose
sub Subject Identifies the principal (usually a user ID)
iss Issuer Who created the token
aud Audience Who the token is intended for
exp Expiration Unix timestamp after which the token is invalid
iat Issued At When the token was created
nbf Not Before Token is not valid before this time
jti JWT ID Unique identifier to prevent replay attacks

Public claims are custom names registered in the IANA JWT Claims Registry or namespaced to avoid collisions.

Private claims are whatever you and your application agree on, like role, team_id, or permissions.

Signature

The signature prevents tampering. For HMAC-SHA256, it works like this:

HMACSHA256(
  base64UrlEncode(header) + "." + base64UrlEncode(payload),
  secret
)
Enter fullscreen mode Exit fullscreen mode

If anyone modifies a single character in the header or payload, the signature will not match and the token is rejected. The signature does not encrypt the payload. Anyone can decode it. The signature only guarantees integrity and authenticity.

Signing Algorithms: HS256 vs RS256

The two most common algorithms serve different architectural needs.

HS256 (HMAC with SHA-256)

Symmetric signing. The same secret key creates and verifies the token.

const jwt = require('jsonwebtoken');

const SECRET = process.env.JWT_SECRET; // at least 256 bits

// Sign
const token = jwt.sign(
  { sub: 'user-8842', role: 'admin' },
  SECRET,
  { expiresIn: '1h' }
);

// Verify
try {
  const decoded = jwt.verify(token, SECRET);
  console.log(decoded);
  // { sub: 'user-8842', role: 'admin', iat: 1709123456, exp: 1709127056 }
} catch (err) {
  console.error('Invalid token:', err.message);
}
Enter fullscreen mode Exit fullscreen mode

Use HS256 when: a single service both issues and verifies tokens. It is simpler and faster.

Risk: every service that needs to verify the token must have the secret. If you share the secret across multiple microservices, a compromise in any one of them leaks the signing key.

RS256 (RSA with SHA-256)

Asymmetric signing. A private key signs the token, and a public key verifies it.

const fs = require('fs');
const jwt = require('jsonwebtoken');

const privateKey = fs.readFileSync('./private.pem');
const publicKey = fs.readFileSync('./public.pem');

// Sign (only the auth service has the private key)
const token = jwt.sign(
  { sub: 'user-8842', role: 'admin' },
  privateKey,
  { algorithm: 'RS256', expiresIn: '1h' }
);

// Verify (any service can have the public key)
try {
  const decoded = jwt.verify(token, publicKey, { algorithms: ['RS256'] });
  console.log(decoded);
} catch (err) {
  console.error('Invalid token:', err.message);
}
Enter fullscreen mode Exit fullscreen mode

Use RS256 when: multiple services need to verify tokens but only one service should issue them. This is the standard approach for microservices and when publishing a JWKS endpoint.

Quick Comparison

HS256 RS256
Key type Shared secret Private/public key pair
Speed Faster Slower (RSA math)
Key distribution Secret must be shared Only public key is shared
Best for Single service Microservices, third-party verification

Decoding a JWT Without a Library

Since the payload is just Base64URL-encoded JSON, you can decode it anywhere. Here is a plain JavaScript function:

function decodeJwtPayload(token) {
  const base64Url = token.split('.')[1];
  const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
  const json = Buffer.from(base64, 'base64').toString('utf-8');
  return JSON.parse(json);
}

const payload = decodeJwtPayload(
  'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1c2VyLTg4NDIiLCJyb2xlIjoiYWRtaW4ifQ.abc123'
);
console.log(payload);
// { sub: 'user-8842', role: 'admin' }
Enter fullscreen mode Exit fullscreen mode

This only decodes the payload; it does not verify the signature. For quick inspection during development, an online JWT decoder tool is even faster: paste the token, see the header, payload, and signature status instantly.

JWT vs Sessions: When to Use Which

This is one of the most debated topics in web authentication. Here is a practical breakdown.

Server-side sessions

The server stores session data (in memory, Redis, or a database) and gives the client a session ID cookie.

Advantages: immediate revocation (delete the session), small cookie size, no sensitive data on the client.

Disadvantages: requires server-side storage that scales with active users, sticky sessions or shared stores needed in load-balanced setups.

JWT-based authentication

The server issues a signed token. The client stores it (usually in an httpOnly cookie) and sends it with each request. The server verifies the signature and reads the claims without any database lookup.

Advantages: stateless verification, works naturally across microservices, no shared session store needed.

Disadvantages: cannot be revoked instantly (the token is valid until it expires), larger payload than a session ID, requires careful security practices.

The Practical Answer

Use server-side sessions when you need instant revocation (banking, admin panels) or your app runs on a single server. Use JWTs when you have a microservices architecture, need cross-domain authentication, or are building a public API. Many production systems use both: short-lived JWTs for API access, plus a revocable refresh token stored server-side.

Security Best Practices

JWT-related vulnerabilities are well-documented and mostly preventable. Here are the ones that matter.

1. Always Validate the Algorithm

The infamous alg: none attack works because some libraries accept whatever algorithm the token header declares. Always specify the expected algorithm on the verification side:

// WRONG - accepts whatever the token says
jwt.verify(token, secret);

// RIGHT - explicitly allow only HS256
jwt.verify(token, secret, { algorithms: ['HS256'] });
Enter fullscreen mode Exit fullscreen mode

This single line prevents an entire class of attacks.

2. Set Short Expiration Times

Access tokens should be short-lived. Fifteen minutes is a reasonable default for access tokens. Use refresh tokens (stored securely, server-side revocable) to issue new access tokens.

const accessToken = jwt.sign(payload, secret, { expiresIn: '15m' });
const refreshToken = jwt.sign({ sub: userId }, refreshSecret, { expiresIn: '7d' });
Enter fullscreen mode Exit fullscreen mode

3. Use Strong Secrets

For HS256, the secret must be at least 256 bits (32 bytes) of cryptographic randomness. A short password is not enough.

# Generate a proper secret
node -e "console.log(require('crypto').randomBytes(64).toString('hex'))"
Enter fullscreen mode Exit fullscreen mode

4. Store Tokens Properly

Storage XSS Risk CSRF Risk Recommendation
localStorage High None Avoid for auth tokens
httpOnly cookie None Moderate Preferred, add CSRF protection
Memory (JS variable) None None Good, but lost on refresh

The safest approach for browser-based apps: store the access token in an httpOnly, Secure, SameSite=Strict cookie.

5. Never Put Secrets in the Payload

The JWT payload is encoded, not encrypted. Anyone with the token can decode and read it. Never include passwords, API keys, credit card numbers, or other sensitive data in JWT claims.

6. Validate All Relevant Claims

Beyond signature verification, check exp, iss, aud, and any custom claims your application depends on:

const decoded = jwt.verify(token, publicKey, {
  algorithms: ['RS256'],
  issuer: 'https://auth.myapp.com',
  audience: 'https://api.myapp.com',
  clockTolerance: 30 // allow 30 seconds of clock skew
});
Enter fullscreen mode Exit fullscreen mode

7. Plan for Token Revocation

Pure JWTs cannot be revoked before expiration. If you need revocation (user logs out, permissions change, account compromised), consider:

  • Short-lived tokens + refresh rotation: most common pattern
  • Token blocklist: check a fast store (Redis) for revoked JTIs on each request
  • Token versioning: store a version counter per user; reject tokens with an old version

Debugging JWTs in Practice

When something goes wrong with JWT authentication, here is a systematic approach:

  1. Decode the token to inspect the header and payload. Use a JWT decoder or the decodeJwtPayload function above.

  2. Check expiration. The exp claim is a Unix timestamp. Compare it to the current time. Clock skew between servers is a common cause of mysterious failures.

  3. Verify the algorithm matches. If the server expects RS256 but the token was signed with HS256, verification will fail even if the payload looks correct.

  4. Inspect the issuer and audience. Mismatched iss or aud claims cause silent rejections in well-configured libraries.

  5. Check for token size issues. JWTs in Authorization headers count toward HTTP header size limits. If you have stuffed too many claims into the payload, proxies or load balancers may truncate or reject the request.

A full walkthrough of JWT internals, common pitfalls, and debugging workflows is available in this in-depth JWT guide.

Complete Example: Auth Middleware in Express

Here is a minimal but production-ready pattern for JWT authentication in an Express app:

const express = require('express');
const jwt = require('jsonwebtoken');

const app = express();
const SECRET = process.env.JWT_SECRET;

// Middleware
function authenticate(req, res, next) {
  const authHeader = req.headers.authorization;
  if (!authHeader || !authHeader.startsWith('Bearer ')) {
    return res.status(401).json({ error: 'Missing token' });
  }

  const token = authHeader.split(' ')[1];

  try {
    const decoded = jwt.verify(token, SECRET, {
      algorithms: ['HS256'],
      issuer: 'my-app'
    });
    req.user = decoded;
    next();
  } catch (err) {
    if (err.name === 'TokenExpiredError') {
      return res.status(401).json({ error: 'Token expired' });
    }
    return res.status(403).json({ error: 'Invalid token' });
  }
}

// Login route
app.post('/login', (req, res) => {
  // ... validate credentials ...
  const token = jwt.sign(
    { sub: user.id, role: user.role, iss: 'my-app' },
    SECRET,
    { expiresIn: '15m' }
  );
  res.json({ token });
});

// Protected route
app.get('/dashboard', authenticate, (req, res) => {
  res.json({ message: `Welcome, user ${req.user.sub}` });
});

app.listen(3000);
Enter fullscreen mode Exit fullscreen mode

Key Takeaways

  • A JWT is three Base64URL-encoded segments: header, payload, and signature. The payload is readable by anyone; the signature prevents tampering.
  • Use HS256 for single-service setups and RS256 when multiple services need to verify tokens independently.
  • Always pin the expected algorithm in your verification code to prevent algorithm confusion attacks.
  • Store tokens in httpOnly cookies, keep access tokens short-lived, and plan for revocation from the start.
  • When debugging, decode the token first and check exp, alg, iss, and aud before digging into application logic.

JWTs are a powerful primitive, but they are not magic. Treat them as you would any security-critical component: understand the spec, validate rigorously, and keep the attack surface small.

Top comments (0)