Understanding JWT Tokens: Structure, Security, and Best Practices

JSON Web Tokens (JWT) are everywhere — used for authentication, API authorization, and session management. But they are also widely misunderstood and frequently misimplemented.

What Is a JWT?

A JWT is a compact, URL-safe token consisting of three Base64URL-encoded parts separated by dots:

header.payload.signature

Header declares the token type and signing algorithm:

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

Payload contains the claims — statements about the subject:

{
  "sub": "1234567890",
  "name": "Alice",
  "iat": 1516239022,
  "exp": 1516242622
}

Signature verifies the token has not been tampered with:

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

Standard Claims

ClaimMeaning
issIssuer — who created the token
subSubject — who the token is about
audAudience — who the token is intended for
expExpiration time (Unix timestamp)
iatIssued at time
jtiJWT ID — unique identifier for the token

Always validate exp, iss, and aud on the server side.

HS256 vs RS256

HS256 (HMAC-SHA256) uses a shared secret — both the issuer and verifier need the same key. Simple to set up but requires secure key distribution. Appropriate for single-service authentication where the same server issues and verifies tokens.

RS256 (RSA-SHA256) uses an asymmetric key pair. The private key signs, the public key verifies. The public key can be published as a JWKS endpoint. Required for multi-service architectures where different services verify tokens they did not issue.

Common Security Pitfalls

1. The "none" Algorithm Attack

Some early JWT libraries accepted "alg": "none" which skips signature verification entirely. Always explicitly specify allowed algorithms and reject none.

// WRONG — vulnerable to algorithm substitution
jwt.verify(token, secret);

// RIGHT — explicitly allow only expected algorithm
jwt.verify(token, secret, { algorithms: ['HS256'] });

2. Storing JWTs in localStorage

localStorage is accessible via JavaScript and vulnerable to XSS. Prefer httpOnly cookies which are not accessible to JavaScript.

3. Not Validating the Audience

If you have multiple services accepting the same JWT, a token issued for Service A can be replayed against Service B. Always set and validate aud.

4. Long Expiration Times

A JWT cannot be invalidated before its exp without a server-side revocation list — which defeats the stateless advantage. Keep access tokens short-lived (15 minutes) and use refresh tokens for long sessions.

Refresh Token Pattern

1. Client logs in → server issues:
   - access_token (15m expiry, httpOnly cookie)
   - refresh_token (7d expiry, httpOnly cookie, rotated)

2. Access token expires → client sends refresh_token
   → server validates, issues new access_token + rotates refresh_token

3. Logout → server invalidates refresh_token in database

Decoding Without a Library

function decodeJwt(token) {
  const [, payload] = token.split('.');
  return JSON.parse(atob(payload.replace(/-/g, '+').replace(/_/g, '/')));
}

This decodes without verifying — useful for debugging but never use for security decisions.

Summary

  • Use short-lived access tokens + refresh token rotation
  • Store in httpOnly cookies, not localStorage
  • Always validate exp, iss, aud
  • Use RS256 for multi-service architectures
  • Never trust alg: none