JSON Web Tokens (JWT) -- Structure, Security, and Common Pitfalls

Understand how JWTs work, their three-part structure, signing algorithms, and security best practices.

What is a JSON Web Token and Why Does It Exist?

A JSON Web Token (JWT) is a compact, URL-safe string that represents claims about an entity (usually a user) and is digitally signed. Instead of storing session data on the server and issuing a session ID, JWTs allow you to encode the user information directly into the token itself. The server can verify the token's authenticity without needing to look up data in a database.

JWTs emerged as a solution to the authentication challenges of modern distributed systems. Traditional session-based authentication works well for monolithic applications, but when you're building microservices, mobile apps, or single-page applications, managing sessions across multiple servers becomes complex. JWTs provide a stateless alternative: the token contains all necessary information and can be verified independently by any service that knows the secret key.

This stateless nature makes JWTs particularly useful for:

  • API authentication (mobile apps, third-party integrations)
  • Single Sign-On (SSO) implementations
  • Microservices architecture
  • Cross-Origin Resource Sharing (CORS) scenarios
  • Server-to-server communication

The Three-Part Structure: Header.Payload.Signature

A JWT consists of three parts separated by dots, each Base64URL encoded. Understanding this structure is fundamental to working with JWTs effectively.

The Header

The header is a JSON object that describes the token type and the algorithm used to sign it:

{
  "alg": "HS256",
  "typ": "JWT"
}
  • alg: Specifies the signing algorithm (HS256, RS256, ES256, etc.)
  • typ: Identifies the token type as JWT

This JSON is then Base64URL encoded. For the example above, the encoded header would be:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9

The Payload

The payload contains the claims (assertions) about the entity. Claims are name-value pairs that convey information about the subject. There are three types of claims:

Registered Claims (standard, optional but recommended):

  • iss (issuer): The principal that issued the JWT
  • sub (subject): The subject of the JWT (typically the user ID)
  • aud (audience): The recipients the JWT is intended for
  • exp (expiration time): Unix timestamp when the token expires
  • iat (issued at): Unix timestamp when the token was created
  • nbf (not before): Unix timestamp indicating when the token becomes valid
  • jti (JWT ID): Unique identifier for the token

Public Claims: Can be defined at will, but to avoid collisions, they should be namespaced or registered.

Private Claims: Custom claims agreed upon by parties using the JWT.

A typical payload looks like:

{
  "sub": "user123",
  "email": "[email protected]",
  "name": "John Doe",
  "role": "admin",
  "iat": 1676379600,
  "exp": 1676383200,
  "iss": "https://auth.example.com"
}

This is also Base64URL encoded, yielding something like:

eyJzdWIiOiJ1c2VyMTIzIiwiZW1haWwiOiJ1c2VyQGV4YW1wbGUuY29tIiwibmFtZSI6IkpvaG4gRG9lIiwicm9sZSI6ImFkbWluIiwiaWF0IjoxNjc2Mzc5NjAwLCJleHAiOjE2NzYzODMyMDAsImlzcyI6Imh0dHBzOi8vYXV0aC5leGFtcGxlLmNvbSJ9

The Signature

The signature is created by taking the encoded header and payload, concatenating them with a dot, and signing the result using the specified algorithm:

signature = HMACSHA256(
  base64UrlEncode(header) + "." + base64UrlEncode(payload),
  secret
)

The signature proves that the token hasn't been tampered with and was created by an entity that possesses the secret key. The complete JWT looks like:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyMTIzIiwiZW1haWwiOiJ1c2VyQGV4YW1wbGUuY29tIn0.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

Signing Algorithms: HS256 vs RS256

The algorithm you choose significantly impacts security and use cases. The two most common are HMAC and RSA-based signing.

HS256 (HMAC with SHA-256)

HS256 uses a symmetric key, meaning the same secret is used for both signing and verifying:

signature = HMACSHA256(header.payload, secret)

Pros:

  • Simple to implement
  • Fast
  • Good for scenarios where one service handles both token creation and verification

Cons:

  • The secret must be shared with every service that needs to verify tokens
  • Greater risk of secret exposure across multiple systems
  • Not suitable for public/private key infrastructure

Use case: Monolithic applications or when you have a limited, trusted set of services.

RS256 (RSA with SHA-256)

RS256 uses asymmetric cryptography with a public-private key pair:

signature = SHA256_WITH_RSA_ENCRYPTION(header.payload, privateKey)
verification = RSA_VERIFY(signature, publicKey)

Pros:

  • The private key never needs to be shared
  • Services only need the public key to verify tokens
  • Naturally suited for distributed systems and SSO scenarios
  • Better separation of concerns

Cons:

  • Slightly slower due to asymmetric cryptography overhead
  • Requires proper key management infrastructure

Use case: Microservices, distributed systems, or any scenario where many services need to verify tokens without possessing the signing secret.

Token Expiration and the Refresh Token Pattern

JWTs should always include an expiration time (exp claim). Tokens without expiration create security risks because a compromised token remains valid indefinitely.

{
  "sub": "user123",
  "iat": 1676379600,
  "exp": 1676383200
}

Here, the token expires 3600 seconds (1 hour) after issuance. When verifying a token, you must check if the current time is before the exp timestamp.

The Refresh Token Flow

Expiring tokens solve one problem but create another: users would need to re-authenticate every hour. The solution is the refresh token pattern:

  1. Issue a short-lived access token (15 minutes) and a long-lived refresh token (7 days)
  2. The access token is used for API requests
  3. When the access token expires, the client sends the refresh token to get a new access token
  4. The refresh token is longer-lived but is only sent occasionally
  5. Refresh tokens should be stored securely (HTTP-only cookies in browsers)
// Client requests access token
POST /auth/token
{
  "grant_type": "password",
  "username": "[email protected]",
  "password": "secret"
}

// Server responds with both tokens
{
  "access_token": "eyJhbGc...",
  "refresh_token": "eyJhbGc...",
  "expires_in": 900
}

// When access token expires, client uses refresh token
POST /auth/refresh
{
  "refresh_token": "eyJhbGc..."
}

// Server issues new access token
{
  "access_token": "eyJhbGc...",
  "expires_in": 900
}

JWT vs Session Cookies: When to Choose Each

Both JWTs and session cookies authenticate users, but they take different approaches.

Session Cookies

Session cookies store a session ID on the client. The server maintains a session store (database or in-memory) with all session data:

Client: sends session_id = "abc123"
Server: looks up session_id in database, retrieves user data

Pros:

  • Easy to invalidate (delete from database immediately)
  • Server has full control over session lifecycle
  • Built-in CSRF protection considerations
  • Logout is instant

Cons:

  • Requires a shared session store in distributed systems
  • Database lookups for every request
  • Not ideal for stateless microservices

JSON Web Tokens

JWTs encode all necessary information in the token itself:

Client: sends JWT with user data embedded
Server: verifies signature, uses claims directly

Pros:

  • Stateless authentication
  • No database lookup needed
  • Scales easily across microservices
  • Better for APIs and mobile apps

Cons:

  • Logout is not instant (token remains valid until expiration)
  • Larger payload than session ID
  • Can't revoke without a blacklist/database
  • More complex to implement correctly

When to use sessions: Traditional web applications, especially those with CSRF concerns and simple architectures.

When to use JWTs: APIs, microservices, mobile apps, single-page applications, and distributed systems.

Common Security Mistakes and How to Avoid Them

1. Storing JWTs in localStorage

Many developers store JWTs in localStorage for easy access from JavaScript. This is vulnerable to Cross-Site Scripting (XSS) attacks:

// VULNERABLE: XSS attack can steal the token
localStorage.setItem('token', jwt);

Solution: Store JWTs in HTTP-only cookies that cannot be accessed by JavaScript:

// SECURE: HTTP-only cookie, XSS can't access it
res.setHeader('Set-Cookie', `token=${jwt}; HttpOnly; Secure; SameSite=Strict`);

2. Not Validating Token Signature

Some developers decode JWTs without verifying the signature. Base64URL decoding is trivial; verification is what matters:

// VULNERABLE: No signature verification
const payload = JSON.parse(atob(token.split('.')[1]));

// SECURE: Always verify signature
const decoded = jwt.verify(token, secret);

3. Algorithm Confusion Attack

If your verification code allows any algorithm, an attacker could manipulate the JWT:

// VULNERABLE: Accepts any algorithm
jwt.verify(token, secret, { algorithms: ['HS256', 'RS256', 'none'] });

// SECURE: Explicitly specify expected algorithm
jwt.verify(token, secret, { algorithms: ['HS256'] });

An attacker could change the algorithm to "none" and create a valid-looking token without a signature.

4. Using a Weak Secret

HS256 relies on the secrecy of the key. A weak secret is easily cracked:

// VULNERABLE: Weak secret
const secret = 'password123';

// SECURE: Long, random secret (at least 32 bytes for HS256)
const secret = crypto.randomBytes(32).toString('hex');

5. Not Validating Claims

JWTs contain claims, but you should validate them server-side:

// Always validate essential claims
const decoded = jwt.verify(token, secret);

if (decoded.iss !== 'https://trusted-issuer.com') {
  throw new Error('Invalid issuer');
}

if (decoded.aud !== 'my-app') {
  throw new Error('Invalid audience');
}

if (decoded.exp < Date.now() / 1000) {
  throw new Error('Token expired');
}

When Should You Actually Use JWTs?

JWTs are powerful but not a universal solution.

Good use cases for JWT:

  • Stateless API authentication
  • Mobile app authentication
  • Microservices communication
  • Third-party service integrations
  • Single Sign-On (SSO) systems

Bad use cases for JWT:

  • Traditional server-rendered web applications (sessions are simpler)
  • When you need instant logout capability
  • When you need to revoke access immediately
  • Storing large amounts of user data (tokens get large)

Conclusion

JWTs are a modern, efficient approach to authentication that works well in distributed and API-driven systems. Understanding their three-part structure, choosing the right signing algorithm, implementing proper expiration with refresh tokens, and avoiding common security pitfalls are essential for building secure applications.

When deciding between JWTs and other authentication methods, consider your architecture, security requirements, and the specific needs of your application. Neither JWTs nor sessions are universally better; each has appropriate use cases.

Related Tools