Authentication Tokens and JWT

· 8 min read · Updated March 30, 2026 · intermediate
javascript security authentication jwt

Tokens are the standard way to keep users authenticated across requests in modern web apps. Unlike old-school sessions that rely on a server-side store, tokens let you build stateless services that scale horizontally without sharing memory. This tutorial covers how authentication tokens work, the anatomy of a JSON Web Token (JWT), and the security pitfalls you need to avoid.

How Authentication Tokens Work

When a user logs in with a username and password, the server checks those credentials and, if valid, issues a token. The client then sends that token with every subsequent request, typically in the Authorization header using the Bearer scheme:

Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1c2VyLTEyMyJ9.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

The server validates the token on each request. If the token is valid, the request proceeds. If it is missing, expired, or forged, the server returns a 401.

There are three main token strategies:

Session tokens are random opaque strings. The server maintains a session record (often in Redis) mapping each token to user data. Every request requires a database lookup. This is straightforward but introduces server-side state.

JWTs are self-contained tokens. The server signs a JSON payload; no database lookup is needed on each request. The token carries the user identity and claims inside itself.

Opaque tokens are random strings like session tokens, but used in OAuth 2.0 flows where an identity provider issues the token. The client cannot read or validate the token locally.

For most single-application JWTs, this article is what you need.

JWT Structure

A JWT is three base64url-encoded strings joined by dots: header.payload.signature.

The Header

{
  "alg": "HS256",
  "typ": "JWT"
}

The header declares which algorithm was used to sign the token and that this is a JWT. You will see HS256 (symmetric) or RS256 (asymmetric) most often.

The Payload

{
  "sub": "user-123",
  "role": "admin",
  "iat": 1743385600,
  "exp": 1743389200
}

The payload contains claims — statements about the user and the token itself.

Registered claims are predefined keys standardized in RFC 7519:

  • sub — the subject, usually the user ID
  • exp — expiration time as a Unix timestamp
  • iat — issued-at time
  • iss — the issuer (who created the token)
  • aud — the audience (who the token is intended for)

Private claims are custom keys you define for your app, like role, email, or plan.

Public claims are registered names that are safe to use but must be documented. Private claims are free-form but risk collisions if different systems use the same key for different things.

Important: The payload is base64-encoded, not encrypted. Anyone can decode it and read the claims. Never put secrets like passwords or API keys in the payload.

The Signature

HMACSHA256(
  base64url(header) + "." + base64url(payload),
  secret
)

The signature proves the payload was not tampered with. If an attacker changes a claim in the payload and re-encodes it, the signature no longer matches and verification fails.

Signing Algorithms: HS256 vs RS256

HS256 (HMAC with SHA-256) is symmetric. The same secret key signs and verifies the token. It is fast and simple, but every service that needs to verify tokens must have the same secret. If one service is compromised, all services sharing that secret are compromised.

RS256 (RSA with SHA-256) is asymmetric. The issuer signs with a private key; verifiers use the corresponding public key. The private key lives only on the auth server. Compromise of a verifier does not expose the signing key.

Use RS256 when you have multiple independent services that all need to verify tokens, or when you want to avoid distributing a shared secret.

For a small app with a single server, HS256 is fine. Use a long, random secret — not "secret" or "password".

Issuing and Verifying JWTs in Node.js

Install the jsonwebtoken package:

npm install jsonwebtoken

Signing a Token

import jwt from 'jsonwebtoken';

const SECRET = process.env.JWT_SECRET;

const token = jwt.sign(
  { sub: user.id, role: user.role },
  SECRET,
  { expiresIn: '1h', issuer: 'my-app' }
);

jwt.sign() takes the payload object, the secret, and an options object where you set the expiration and issuer.

Verifying a Token

import jwt from 'jsonwebtoken';

const SECRET = process.env.JWT_SECRET;

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

  const token = authHeader.slice(7);

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

app.use(authenticate);

jwt.verify() checks the signature, the exp claim, and the iss claim. It throws on any failure, so wrapping in try/catch is essential. Always return 401 for both expired and invalid tokens — leaking the reason to the client helps attackers.

Token Expiration and Refresh Tokens

Always set exp. A token without an expiration lives forever if stolen. Typical access token lifetimes are 15 minutes to 1 hour. Short lifetimes limit the damage window.

Since short-lived tokens are inconvenient for users, apps use a two-token system:

  • Access token — short-lived, stored in memory (a JavaScript variable). Used on every API request.
  • Refresh token — long-lived (days to weeks), stored in an httpOnly cookie. Used only to obtain a new access token.

When the access token expires, the client POSTs the refresh token to a /refresh endpoint and gets a new access token. This keeps the user logged in without re-entering passwords.

Refresh tokens can be rotated: each time you use one to get a new access token, you also issue a new refresh token and invalidate the old one. If a stolen refresh token is used, the old one becomes invalid and the attack is detected.

Where to Store Tokens in the Browser

Storing tokens in the browser is where most apps get security wrong.

localStorage is accessible to any JavaScript running on your page. If your app has an XSS vulnerability, attackers can read tokens from localStorage with localStorage.getItem('token') and exfiltrate them. localStorage has no built-in protection against XSS.

httpOnly cookies cannot be read by JavaScript. The browser sends them automatically with each request to the matching domain. This protects against XSS token theft, but cookies are vulnerable to CSRF unless you use the SameSite attribute.

The recommended approach for SPAs:

  • Store the JWT access token in memory (a JavaScript variable). It lives only during the page session and is never written to disk. No XSS risk for the token itself.
  • Store the refresh token in an httpOnly, Secure, SameSite=Strict cookie. XSS cannot read it. SameSite=Strict prevents cross-origin CSRF.
res.cookie('refreshToken', newRefreshToken, {
  httpOnly: true,
  secure: true,
  sameSite: 'strict',
  maxAge: 7 * 24 * 60 * 60 * 1000
});

Common JWT Security Attacks

Algorithm Confusion

If your server accepts multiple algorithms, an attacker can exploit the mismatch. They take a token signed with RS256 (using the public key), change the alg header to HS256, and sign it using the public key as the HMAC secret. Some servers blindly trust the alg header and accept the forged token.

Fix: Whitelist allowed algorithms on the server. If you use HS256, explicitly reject RS256 and vice versa. Never accept "alg": "none".

const payload = jwt.verify(token, SECRET, {
  algorithms: ['HS256'],
  issuer: 'my-app'
});

Weak Secrets

Using "secret" or "password123" as your HS256 secret makes brute-force attacks trivial, especially since the token is signed deterministically (the same payload always produces the same signature). Use a cryptographically random secret at least 256 bits long. Store it in an environment variable, not in source code.

No Expiration or Revocation

Without exp, a stolen token is valid forever. Without a revocation mechanism, you cannot invalidate a token when a user logs out or their account is compromised. Always set exp. For sensitive apps, implement a token blocklist in Redis or a database, keyed by the jti (JWT ID) claim.

Storing Tokens in localStorage

This is the most common JWT security mistake in production SPAs. XSS lets attacker scripts read localStorage. Unless you are certain your app has zero XSS vulnerabilities (unlikely), keep tokens out of localStorage.

Token Lifecycle

  1. Login — User submits credentials. Server validates, creates a JWT with claims, signs it, and returns it.
  2. Storage — Client keeps the access token in memory and the refresh token in an httpOnly cookie.
  3. Use — Client sends the access token in Authorization: Bearer <token> on every API request.
  4. Validation — Server extracts the token, verifies the signature, checks exp, checks iss, and proceeds or rejects.
  5. Refresh — When the access token expires, the client POSTs the refresh token to get a new access token (and optionally rotates the refresh token).
  6. Revocation — Tokens are invalidated by deleting them from storage, waiting for exp, or adding their jti to a blocklist.

Conclusion

Authentication tokens and JWTs let you build stateless, horizontally scalable APIs. A JWT is a signed JSON payload — base64-encoded, not encrypted — so never put secrets in the payload. Use HS256 for single-service apps with a strong secret, or RS256 for multi-service setups where you want to keep the signing key private.

Short-lived access tokens with refresh token rotation are the industry-standard pattern for balancing security and usability. Store the access token in memory and the refresh token in an httpOnly cookie. Always whitelist your signing algorithm and set exp on every token.

See Also