Building Secure JWT Authentication: Architecture, Internals, and Best Practices

Building Secure JWT Authentication: Architecture, Internals, and Best Practices

A rigorous guide to JSON Web Tokens (JWT) security — covering stateless authorization, base64url structures, HS256 vs RS256, signature verification math, algorithm none exploits, and key confusion vulnerabilities.

In the early days of web applications, managing user sessions was straightforward. When a user logged in, the server created a session record in memory or a database, associated it with a random session ID, and sent that ID to the browser in a cookie. For every subsequent request, the browser sent the cookie, and the server looked up the session ID. While highly secure, this stateful model does not scale to modern, distributed cloud architectures where requests are routed across thousands of microservices. To scale, we transitioned to stateless authentication using **JSON Web Tokens (JWT)**.

JSON Web Tokens allow a server to encode user session data directly into an encrypted or signed token that is stored by the client. The server does not need to store any session records; it simply validates the token's cryptographic signature to verify its authenticity. However, this stateless design makes JWTs vulnerable to severe security exploits if misconfigured. Attackers can bypass authentication using the "alg none" vulnerability, perform key confusion attacks, or brute-force weak signing keys. This guide will walk you through JWT architecture, the mathematics of token validation, common security exploits, and how to implement a secure token pipeline from scratch.


1. Stateless Session Management: Why We Use Tokens

1.1 The Stateful session Bottleneck

In a stateful session model, the server must keep a record of every active session. If you have 10 million active users, your database or Redis cache must store 10 million session keys. Furthermore, every single API request requires a database look-up to verify the session status. In a microservices architecture where a single user action might trigger requests across 20 separate backend services, routing each request through a central session lookup creates a massive bottleneck. This bottleneck limits throughput and increases latency across your application stack.

Stateless authentication solves this by moving session storage from the server to the client. The server generates a token containing the user's metadata (user ID, roles, expiration time) and signs it using a private key. The browser stores this token (usually in local storage or a secure cookie) and attaches it to the Authorization header of every API request. The backend microservices can verify the token's signature locally without querying a central session database. This decouples authentication from database scale, allowing microservices to scale independently.

1.2 Authentication vs Authorization

It is critical to distinguish between authentication (proving who you are) and authorization (proving what you are allowed to do). JWTs are primarily designed for **authorization**. When a user logs in (authentication), the server returns a JWT. For all subsequent requests, the microservices inspect the claims (roles, permissions) inside the JWT to decide if the user is authorized to access the requested resource. This decentralized authorization is what makes microservice APIs highly efficient.

Common Misconception — JWTs Encrypt Your Data: A common misconception is that JWTs are encrypted and secure from public eyes. In reality, standard JWTs are **signed, not encrypted**. The header and payload are simply encoded using base64url, which is a reversible encoding, not encryption. Anyone who intercepts the token can read your user IDs, roles, and emails. Never store sensitive, private data (like passwords, social security numbers, or API keys) inside a standard JWT payload.


2. The Anatomy of a JWT: Header, Payload, and Signature

2.1 The Three-Part Token Structure

A JSON Web Token consists of three distinct parts separated by dots (.): (1) **Header**: contains metadata about the token, such as the signing algorithm used (e.g. HS256, RS256) and the token type (JWT). (2) **Payload**: contains the **claims** — the statement of user metadata (e.g., sub for subject/user ID, exp for expiration timestamp, and custom roles). (3) **Signature**: the cryptographic hash generated by combining the header, payload, and a secret key, protecting the token from tampering. We can represent this structure as:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9
.TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ

2.2 Base64url Encoding

To make tokens safe to transmit over HTTP headers and URLs, each section is encoded using **base64url**. This encoding is a variant of standard base64 that replaces character + with -, character / with _, and removes trailing padding equals signs (=). This prevents URL parsers from misinterpreting the token as path parameters or query variables.


3. Signing Algorithms: Symmetric HS256 vs Asymmetric RS256

3.1 HMAC-SHA256 (HS256)

The **HS256** algorithm uses a symmetric key model. The same secret key is used to both *generate* the signature (sign) and *validate* the signature (verify). It is highly efficient and simple to configure, making it the default choice for single, unified applications. However, in a distributed microservices environment, using HS256 requires distributing the shared secret key to every microservice that needs to validate tokens. If a single microservice is compromised, the attacker steals the secret key and can generate valid, forged admin tokens for the entire system.

3.2 RSA-SHA256 (RS256)

The **RS256** algorithm uses an asymmetric key model (public/private key pair). The authentication server uses a private key (kept secret) to sign the token. All other microservices use the matching public key (shared openly) to verify the signature. If a microservice is compromised, the attacker only gets the public key, which cannot be used to forge new signatures. This makes RS256 significantly more secure and scalable for multi-tenant, cloud-native deployments, as the private key remains locked on a single secure authorization node.


4. The Signature Verification Math

4.1 Signature Generation and Comparison

To verify a token's signature, the receiver splits the token into its three parts using the dot separator. It extracts the raw header string and payload string. To check for tampering, the receiver recalculates the signature using the algorithm declared in the header and compares it to the signature attached to the token. In the symmetric HS256 model, this recalculation can be formalized as:

$$ \text{Sig}_{\text{calc}} = \text{HMAC-SHA256}(\text{base64url}(\text{Header}) + \text{"."} + \text{base64url}(\text{Payload}), \, \text{SecretKey}) $$
$$ \text{Is Valid } \iff \text{base64url}(\text{Sig}_{\text{calc}}) == \text{Signature}_{\text{token}} $$

If the computed signature matches the token signature exactly, the database knows that the header and payload have not been altered in transit, and that the token was generated by a party that holds the secret key.

sequenceDiagram participant Client as Client Browser participant Auth as Auth Server (Private Key) participant API as API Microservice (Public Key) Client->>Auth: POST /login (Credentials) Auth-->>Client: JWT Token (Signed with Private Key) Client->>API: GET /data (Authorization Header: Bearer JWT) Note over API: Recalculate signature using Public Key API-->>Client: HTTP 200 OK (Validated)

Mermaid Diagram: The asymmetric token lifecycle under RS256 signing.


5. The Alg None Vulnerability: Bypassing Signature Verification

5.1 The Blind Trust Exploit

One of the most famous security exploits in early JWT implementations was the **"alg none"** vulnerability. The JWT specification allows for unsigned tokens by setting the algorithm header field to none: {"alg": "none", "typ": "JWT"}. This was intended for debugging or internal communication over secure channels. However, early validation libraries were designed to read the header first, see "alg": "none", and skip signature verification entirely, treating the token as valid.

An attacker could exploit this by taking a valid user token, changing the payload to {"user": "admin"}, changing the algorithm header to none, removing the signature part (but keeping the trailing dot: header.payload.), and sending it to the server. The server, trusting the header, would parse the payload and grant admin access without checking anything. Modern JWT libraries block this by requiring developers to explicitly specify which algorithms are permitted, rejecting none by default.


6. Asymmetric Key Confusion Attack

6.1 The Public Key Signature Hack

The **Asymmetric Key Confusion Attack** occurs when a server is configured to use RS256 (asymmetric) for validation, but the validation library is tricked into treating the public key as a symmetric HS256 secret. To verify an RS256 signature, the library expects a public key. However, if the library is not strict about algorithm types, an attacker can modify the token's header from RS256 to HS256. The attacker then takes Bob's public key (which is public and easily obtainable) and uses it to sign a modified payload using HS256 (symmetric HMAC).

When the server receives this token, it parses the header: "Ah, the algorithm is HS256". It looks up the key configuration, retrieves the public key, and passes it to the HMAC function as the secret key. The server calculates the HMAC signature using the public key and compares it to the attacker's signature. Since the attacker signed the token using the same public key and HMAC-SHA256, the signatures match, and the server validates the forged token. Protect against this by forcing your validation library to only accept the specific expected algorithm (e.g. RS256) rather than reading it from the user-provided header.

6.2 Mechanics of the Confusion Attack

To understand the vulnerability, we must look at how cryptographic libraries handle keys. In asymmetric RS256, the public key is represented in a format like PEM (Privacy-Enhanced Mail):

-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA0Y...
-----END PUBLIC KEY-----

A standard JWT library uses this public key as a string or buffer. If the algorithm is changed to HS256, the library treats this entire string buffer, including the header and footer comments, as a raw binary array of bytes acting as the HMAC shared secret. An attacker performs this attack offline by loading the public key file and executing:

$$ \text{Signature}_{\text{forged}} = \text{HMAC-SHA256}(\text{Header}_{\text{HS256}} + \text{"."} + \text{Payload}_{\text{Admin}}, \, \text{PublicKey}_{\text{String}}) $$

When the attacker sends the forged token, the vulnerable backend executes the exact same HMAC-SHA256 function using the public key string, matching the computed signature to the forged one. This completely compromises authorization security. You can defend your system against key confusion by implementing a strict verification schema. For instance, in Node.js, always restrict the allowed algorithms array parameter:

# Secure verification config restricting validation to RS256 exclusively
jwt.verify(token, publicKey, { algorithms: ['RS256'] }, (err, decoded) => {
    if (err) {
        console.error("Token invalid or validation algorithm hijacked!");
    }
});

Pitfall — Exposing Key Discovery Endpoints: Many modern federated identity platforms expose public keys at standard endpoints (e.g. /.well-known/jwks.json). While this makes key rotation simple, it also means attackers can easily download your public keys to perform offline confusion attacks. Always ensure your code enforces strict algorithm restrictions for all keys loaded from JWKS endpoints.


7. Advanced: Token Revocation and Refresh Token Rotation

7.1 Revoking the Irrevocable

The major architectural trade-off of stateless tokens is the **revocation problem**. Once a JWT is issued, it is valid until its expiration timestamp (exp) is reached. If a user logs out, closes their account, or has their device stolen, there is no built-in way to cancel the token. The server does not check a database; it simply checks the signature, meaning the stolen token remains valid. To balance safety and scale, we must implement a revocation strategy. Setting a short lifespan for the access token guarantees that even if a token is stolen, the window of vulnerability is extremely narrow and will automatically close once the expiration time passes.

The standard approach is using **Refresh Token Rotation**. We issue two tokens: a short-lived **Access Token** (valid for 10-15 minutes) and a long-lived **Refresh Token** (stored in a database and sent in a secure cookie). When the access token expires, the client sends the refresh token to the authorization server to fetch a new access token. If the refresh token is rotated (invalidated and replaced on every use), the server can detect reuse attacks (if a leaked refresh token is reused, the server invalidates the entire session family, protecting the user from ongoing extraction). Additionally, when a user logs out, we can store the access token's unique ID (jti) in a Redis cache with a Time-To-Live (TTL) matching the token's remaining lifespan, ensuring the token is blocked immediately without bloating the database long-term.


8. Advanced: JWT Side-Channel and Brute-Force Attacks

8.1 Brute-Forcing HS256 Keys

Because the signature of an HS256 token is generated using a symmetric secret, an attacker who captures a valid JWT can attempt to brute-force the secret key offline on their own hardware. Since the verification only requires hashing, tools like Hashcat can execute billions of HMAC-SHA256 calculations per second on modern GPUs. If your secret key is a weak string (e.g. my-secret-key-123), the attacker will crack it within minutes, allowing them to sign arbitrary tokens. Always generate strong, random keys (at least 256 bits long) using cryptographic entropy, and rotate them regularly.


9. Complete Express/NodeJS JWT Implementation from Scratch

9.1 Custom Token Generator and Validator

The following Node.js code implements a basic JWT library from scratch, demonstrating base64url encoding, HS256 signature generation, and verification checks manually, without third-party library dependencies:

const crypto = require('crypto');
 
const base64url = (str, encoding = 'utf-8') => {
    return Buffer.from(str, encoding)
        .toString('base64')
        .replace(/=/g, '')
        .replace(/\+/g, '-')
        .replace(/\//g, '_');
};
 
const signHS256 = (header, payload, secret) => {
    const hBase64 = base64url(JSON.stringify(header));
    const pBase64 = base64url(JSON.stringify(payload));
    const input = `${hBase64}.${pBase64}`;
    
    return crypto.createHmac('sha256', secret)
        .update(input)
        .digest('base64')
        .replace(/=/g, '')
        .replace(/\+/g, '-')
        .replace(/\//g, '_');
};
 
const verifyJWT = (token, secret) => {
    const [hBase64, pBase64, sigBase64] = token.split('.');
    if (!hBase64 || !pBase64 || !sigBase64) return null;
    
    const header = JSON.parse(Buffer.from(hBase64, 'base64').toString('utf-8'));
    const payload = JSON.parse(Buffer.from(pBase64, 'base64').toString('utf-8'));
    
    # 1. Block alg none attacks
    if (header.alg === 'none') return null;
    
    # 2. Verify signature
    const expectedSig = signHS256(header, payload, secret);
    if (sigBase64 !== expectedSig) return null;
    
    # 3. Check expiration
    if (payload.exp && Date.now() / 1000 > payload.exp) return null;
    
    return payload;
};

10. Interactive: JWT Signature Verification Simulator

Select the action and click Run. Watch how the simulator encodes the token, and how the server detects and blocks tampered payloads or alg-none attempts:

Status: Ready
Select action and click Run...
Log output displays here...
Client Browser user: guest Server Check Expected: HS256

Verification Latency: DB Lookup vs Local JWT Check

The chart below compares the average verification latency (in milliseconds) for 1000 sequential API requests between database-backed stateful sessions and local JWT signature checks:


12. Frequently Asked Questions

Q1: How do I store a JWT securely in the browser to prevent theft?

Never store JWTs in local storage or session storage, as they are accessible to any Javascript running on the page, making them vulnerable to Cross-Site Scripting (XSS) extraction attacks. The most secure place is in an **HttpOnly, Secure cookie** with the `SameSite=Strict` flag. This prevents client-side scripts from reading the token while automatically attaching it to requests, defending against both XSS and CSRF.

Q2: How does the Key Confusion attack work and how do I prevent it?

A key confusion attack occurs when an attacker changes the token algorithm from RS256 (asymmetric) to HS256 (symmetric) and signs it using the server's public key. The server treats the public key as the symmetric HMAC secret, validating the signature. Prevent this by configuring your verification code to explicitly restrict algorithms to RS256, ignoring user headers.

Q3: What is the purpose of Refresh Token Rotation?

Refresh Token Rotation invalidates and replaces a refresh token every time it is used to fetch a new access token. If an attacker steals a refresh token and attempts to use it, the server will detect that the old refresh token is being reused, assume a breach occurred, and instantly invalidate all tokens associated with that user session.

Q4: Why does setting the JWT expiration time too long create security risks?

Because stateless tokens cannot be revoked easily, a leaked token remains valid until its expiration (exp) timestamp is reached. If the expiration is set to 30 days, an attacker who steals the token has admin access for 30 days. Access tokens should always be short-lived (e.g. 15 minutes) to minimize the damage window.

Q5: What is the differences between JWS, JWE, and JWT?

JWT is the general token specification. **JWS (JSON Web Signature)** represents tokens that are signed (meaning the payload is public but protected from tampering, which is the default standard). **JWE (JSON Web Encryption)** represents tokens whose payload is encrypted, protecting data confidentiality from public eyes.

Q6: Can I use JWTs for logout operations in a purely stateless setup?

In a purely stateless setup, logout is handled by the browser discarding the token. However, if the token was intercepted, it remains valid on the backend. To support true logout, you must implement a lightweight **blacklist database** (like Redis) containing blacklisted token IDs (jti) that are checked on each request, balancing stateless scale with security control.

Q7: Why is base64url encoding used instead of standard base64?

Standard base64 contains characters like +, /, and =, which have special meanings inside HTTP query strings, cookie headers, and URL paths. Base64url replaces these characters with URL-safe variants (- and _) and strips the padding, preventing URL routing errors.

Q8: How do I test my JWT verification code?

Verify: (1) valid tokens successfully authenticate, (2) tokens with expired timestamps are rejected, (3) modifying a single character in the payload triggers a signature mismatch, (4) sending an alg: none token fails validation, and (5) key confusion attempts (converting RS256 to HS256) are blocked.

Post a Comment

Previous Post Next Post