
The core idea behind JWTs is that state information isn't stored on the server — it lives inside the token itself, which the client receives and sends back with every request. This means any server instance can verify the token's authenticity using just the secret key and the token's contents, without needing shared session storage. That's what makes JWT ideal for distributed, scalable systems. However, this self-contained, stateless nature is simultaneously its greatest strength and its most dangerous characteristic. Once a token falls into the wrong hands, there's nothing the server can do about it until the token expires.
TL;DR – The Most Important Security Steps
- Never store JWTs in LocalStorage! Use HttpOnly, Secure, and SameSite cookies to protect against XSS.
- Short expiration + Refresh Token: Keep access tokens to a maximum of 15–60 minutes, and handle long-term access with a refresh token stored separately in a database.
- Use asymmetric signing (RS256): It's more secure and allows token verification without sharing the private key.
- Implement fingerprinting: Bind the token to the client's IP address and User-Agent to minimize abuse.
- Don't trust the algorithm: Always enforce server-side which algorithms you accept, and disable the
nonetype.
The Anatomy of a JWT
Before diving into security considerations, it's worth understanding exactly what we're dealing with. A JWT consists of three parts, separated by dots (.): the header, the payload, and the signature. All three parts are Base64URL-encoded, which means their contents — at least for the header and payload — can be freely decoded and read. The signature's job is not to encrypt the data, but to protect its integrity.
The header typically contains two things: the token type (always JWT) and the algorithm used for signing, such as HS256 (HMAC-SHA256) or RS256 (RSA-SHA256). The payload contains what are called claims — these can be standardized fields like iss (issuer), sub (subject), exp (expiration), iat (issued at), or any custom data you choose to include. The signature is derived by combining the Base64URL-encoded header and payload, then signing that with the secret key using the chosen algorithm.
This three-part structure also defines JWT's fundamental security model. The signature guarantees that the token's contents haven't been tampered with in transit, but says nothing about who has read it.
The Most Common JWT Vulnerabilities
Before covering defense strategies, we need to understand the attack vectors that threaten JWTs in practice. Without this knowledge, security measures are nothing more than pieces of an incomplete puzzle.
One of the most notorious — and perhaps most shocking — vulnerabilities is the "none" algorithm attack. The JWT specification originally allowed the algorithm field in the header to be set to none, indicating that the token is unsigned. Many early and poorly implemented libraries accepted this and authenticated such tokens without any verification. An attacker could simply modify the token's payload (for example, granting themselves elevated privileges), set the algorithm to none, drop the signature, and pass it off as a valid token.
The algorithm confusion attack (also known as algorithm substitution) is a more sophisticated variant that primarily targets systems using asymmetric key pairs (RS256). If a server accepts both RS256 and HS256, an attacker may try to use the public key — which, by definition, is not secret — as the HS256 secret key to sign a token. A misconfigured server may accept this as valid.
Brute force and dictionary attacks are also a serious threat, especially when developers use weak, predictable secret keys like secret, password, or the project name. Since the JWT format is public, an attacker who has obtained a valid token can attempt to guess the key offline, without putting any load on the server.
Token theft is another risk that shouldn't be overlooked. If a JWT is stored in the wrong place on the client — more on that shortly — a malicious script exploiting an XSS (Cross-Site Scripting) vulnerability can simply read the token and send it to a remote server. From the server's perspective, that request looks completely legitimate, since the token is valid.
Basic Security Measures
The first and most important rule: never transmit JWTs over an unencrypted channel. All JWTs must be sent over HTTPS with a valid TLS certificate. This ensures that data traveling between the client and server cannot be intercepted. Tokens sent over plain HTTP are easy pickings for a man-in-the-middle attack, and nothing in that communication channel prevents the token from being captured.
The security of your secret key is critical. For HMAC-based signing (e.g., HS256), the key must be cryptographically strong, randomly generated, and at least 256 bits long. Never use a manually chosen, short, or human-memorable key. The key must never be hardcoded in your source code. A secret key that ends up in a version control system (e.g., Git) should be considered compromised and rotated immediately. Instead, use environment variables or secret management systems (e.g., HashiCorp Vault, AWS Secrets Manager).
Where possible, prefer asymmetric key pairs (RS256 or ES256) over symmetric HS256. With asymmetric signing, a private key is used to sign the token, and the corresponding public key is used to verify it. This allows different services to verify tokens without having access to the signing key — a particularly valuable property in a microservices architecture.
Never store sensitive data in the JWT payload. Passwords, credit card numbers, social security numbers, or other confidential information must not be placed in the token. The payload is Base64URL-encoded, making it trivially decodable — anyone with access to the token can read its contents. Only data that can be considered public should go into the token, such as a user ID or role/permission information.
Verifying the signature and the expiration time is not optional — it is mandatory on every single request. Checking the exp (expiration) claim ensures that expired tokens cannot be used to access the system. The iat (issued at) claim can be used to determine whether a token is older than an acceptable threshold, protecting against the reuse of old, potentially compromised tokens.
Storing the Token on the Client
Where to store a JWT in the browser is one of the most debated topics in the web security community, and the decision has far-reaching consequences.
| Storage Method | XSS Protection | CSRF Protection | JS Access |
|---|---|---|---|
| LocalStorage | ❌ None | ✅ Automatic | ✅ Yes |
| HttpOnly Cookie | ✅ Full | ⚠️ Requires SameSite | ❌ No |
localStorage is a convenient and widely used storage location, but it carries a serious security risk. Data stored in localStorage is accessible to any JavaScript code running on the page, which means that an XSS vulnerability would allow an attacker's script to simply read the token and send it to an arbitrary server. Since eliminating XSS entirely is extremely difficult, storing tokens in localStorage represents an unacceptable risk.
The safer alternative is a cookie marked with the HttpOnly flag. The browser automatically sends such cookies with every request to the relevant domain, but they are inaccessible to JavaScript, providing full protection against XSS-based token theft. The Secure flag ensures the cookie is only transmitted over HTTPS, while SameSite=Strict or SameSite=Lax protects against CSRF (Cross-Site Request Forgery) attacks. This approach isn't perfect — CSRF protection still needs to be handled separately — but overall it's significantly more secure than localStorage.
Advanced Security Measures
Even with basic measures in place, a structural weakness remains with JWTs: once a token has been issued, revoking it is extremely difficult. The token stays valid until it expires, even if the user has logged out or the token has been compromised. This problem can be addressed with several different strategies.
One of the most effective approaches is combining short expiration times with a refresh token mechanism. Set the access token to expire after a few minutes or at most an hour or two. When the access token expires, the client can request a new one from a dedicated server endpoint using a longer-lived refresh token. Refresh tokens must be stored and tracked on the server (in a database), which makes it possible to revoke them. If a user logs out or suspicious activity is detected, the refresh token is deleted, causing the next refresh attempt to fail.
A token denylist (or blocklist) is another approach. When a token needs to be invalidated (e.g., on logout), the token's identifier (jti claim) is recorded in a fast-access store (e.g., Redis). Every request must then check whether the token's jti value appears on the list. This approach restores the flexibility of session-based systems but partially gives up one of the key advantages of stateless JWTs. The size of the denylist can be kept manageable by keeping access token lifetimes short.
Client fingerprinting adds an additional security layer that makes it harder to misuse stolen tokens. The idea is to record certain characteristics of the client at token issuance time — such as a combination of the network address and the User-Agent string — and embed this as a hash inside the token. On every request, the fingerprint is recomputed and compared against the one stored in the token. If there's a mismatch, the request is rejected. This method doesn't provide complete protection, since both IP address and User-Agent can be spoofed, but it significantly raises the complexity of the attack.
Example Code
Below is an Express.js-based implementation that combines a short-lived access token with fingerprint-based verification. The logic can naturally be extended and adapted to the specific needs of your application.
The full source code is also available in our GitHub repository:
https://github.com/stacklegend/jwt-improve-security
Creating the Fingerprint
The fingerprint is generated using a cryptographic hash function combined with a salt. The salt prevents precomputed (rainbow table) attacks and makes the fingerprint unique to the specific application.
const createFingerprint = (req) => {
const salt = '3d4bd49fadee0613cec5a145a0173876';
const addr = req.headers['x-forwarded-for'] || req.socket.remoteAddress;
const userAgent = req.get('user-agent');
return crypto
.createHash('sha256')
.update(salt + addr + userAgent)
.digest('hex');
};
Issuing and Verifying the Token
const crypto = require('crypto');
const router = require('express').Router();
const jwt = require('jsonwebtoken');
const secret = process.env.JWT_SECRET;
const expiresIn = '15m';
const createFingerprint = (req) => {
const salt = process.env.FINGERPRINT_SALT;
const addr = req.headers['x-forwarded-for'] || req.socket.remoteAddress;
const userAgent = req.get('user-agent');
return crypto
.createHash('sha256')
.update(salt + addr + userAgent)
.digest('hex');
};
router.post('/getToken', function (req, res) {
const userId = req.body.userId;
const fingerprint = createFingerprint(req);
const data = { userId, fingerprint };
const token = jwt.sign(data, secret, { expiresIn });
res.json({ token });
});
router.post('/checkToken', function (req, res) {
const token = req.headers['authorization']?.split(' ')[1];
const fingerprint = createFingerprint(req);
if (!token) return res.sendStatus(401);
try {
const data = jwt.verify(token, secret);
if (!data || String(data.fingerprint) !== String(fingerprint)) {
return res.sendStatus(403);
}
res.json({ data });
} catch (err) {
return res.sendStatus(403);
}
});
module.exports = router;
Generating a Secure Secret Key
In a production environment, never type your secret key by hand. You can easily generate a sufficiently strong key in Node.js with:
const crypto = require('crypto');
console.log(crypto.randomBytes(64).toString('hex'));
The resulting 512-bit, randomly generated key should be read from an environment variable at server startup and must never be committed to a version control system.
The Importance of Key Rotation
Key rotation — the practice of regularly replacing your secret keys — is a critical security measure that many developers overlook. Even if a key hasn't been compromised, the longer it's in use, the greater the chance it could be involved in some security incident somewhere. Implementing key rotation for JWTs is non-trivial, since tokens currently in circulation that were signed with the old key still need to be accepted until they expire.
This problem is elegantly solved using the kid (key ID) claim. The ID of the key used to sign the token is included in the token's header. The server looks up the appropriate verification key from a key registry (JWKS, JSON Web Key Set). This way, multiple keys can be valid simultaneously — newer tokens are signed with the new key, while older ones can still be verified with the old key until they expire.
JWT in the OAuth 2.0 and OpenID Connect Ecosystem
In real-world applications, JWTs are rarely used in isolation. Most commonly, you'll encounter them within the OAuth 2.0 and OpenID Connect (OIDC) frameworks. OAuth 2.0 is an authorization framework that allows an application to obtain limited access to a user's resources on their behalf, without ever receiving their password. OpenID Connect builds on top of this, adding an identity layer that enables authentication as well.
In these contexts, JWTs serve as the carriers for access tokens, ID tokens, and refresh tokens. The access token grants access to protected resources, the ID token verifies the user's identity, and the refresh token enables renewal of the other two. The standardized claim fields defined by OIDC (e.g., sub, iss, aud) provide a consistent, interoperable authentication experience supported by a wide range of libraries and identity providers (e.g., Auth0, Keycloak, Firebase).
Whenever possible, it's advisable to rely on a well-established, audited identity provider for JWT management rather than rolling your own implementation. A custom implementation is risky — any of the vulnerabilities described above can easily surface from a single moment of inattention.
Logging and Anomaly Detection
Even the most secure system isn't bulletproof, which is why continuous monitoring is essential. Authentication events — both successful and failed attempts — should be logged and the data analyzed. An unusually high number of failed authentication attempts from a given IP address may indicate a brute force attack. If a user appears to be active from multiple geographically distant locations simultaneously, that could be a sign of token theft.
Anomaly detection can be automated — for example, a simple rule-based system can block IPs, notify users, or trigger forced re-authentication. These measures provide operational protection on top of the technical security layers, and they are the primary means of detecting real-world incidents.
Conclusion
JWTs are a powerful and flexible tool for handling authentication and authorization in modern web applications, especially in distributed, microservice-based systems where server-side sessions aren't practical. But that flexibility comes with responsibility. The self-contained, portable nature of JWTs means that full compliance with all security requirements rests entirely on the shoulders of the implementing developer.
A strong, randomly generated key, exclusive use of HTTPS, short expiration times, a refresh token mechanism, client fingerprint-based verification, and an appropriate storage strategy together form a defensive layer that makes successful token exploitation significantly harder. No single measure is sufficient on its own — security is always layered, and each layer compensates for the weaknesses of the others.