DEV Community

Cover image for Stop Storing JWTs in Local Storage: 3 Critical API Auth Mistakes
Balamurugan pandian
Balamurugan pandian

Posted on

Stop Storing JWTs in Local Storage: 3 Critical API Auth Mistakes

Authentication is one of the easiest things to mess up when building modern APIs. Many engineering teams implement JSON Web Tokens (JWTs) to build stateless authentication systems, but end up introducing critical vulnerabilities into their applications.

Here are three common JWT security mistakes developers make in production and how to fix them.

1. Storing JWTs in LocalStorage

The most widespread architectural mistake on the modern web is storing authentication tokens inside browser localStorage or sessionStorage.

// DANGEROUS: Storing sensitive access tokens in LocalStorage
localStorage.setItem('accessToken', token);
Enter fullscreen mode Exit fullscreen mode

The Vulnerability

localStorage is completely accessible to any JavaScript running on your domain. If your application suffers from a Cross-Site Scripting (XSS) vulnerability, an attacker can execute malicious scripts to read localStorage and exfiltrate your users' authentication tokens.

A single rogue dependency in your node_modules bundle or an unsanitized user input field can expose every active session in your application.

The Fix: HttpOnly Cookies

Store access tokens inside HttpOnly cookies instead. When a cookie has the HttpOnly flag enabled, client side JavaScript physically cannot read or extract it.

// SECURE: Setting an HttpOnly, Secure cookie in Express.js
res.cookie('token', jwtToken, {
  httpOnly: true, // Prevents client side JS access
  secure: true,   // Ensures cookie is sent over HTTPS only
  sameSite: 'strict', // Protects against Cross-Site Request Forgery (CSRF)
  maxAge: 15 * 60 * 1000 // Short expiration (15 minutes)
});
Enter fullscreen mode Exit fullscreen mode

The browser automatically attaches this cookie to every outgoing request to your API domain, keeping the token invisible to malicious scripts.

2. Using decode() Instead of verify()

A JWT consists of three base64 encoded parts: the header, the payload, and the signature. Anyone can decode a JWT payload without a secret key.

// DANGEROUS: Decoding the payload without verifying the signature
const decoded = jwt.decode(token);
const userId = decoded.userId;
Enter fullscreen mode Exit fullscreen mode

The Vulnerability

The decode() method simply parses the base64 string. It does not check whether the token was altered by an attacker. If a user modifies their payload to change their role from user to admin, jwt.decode() will accept the forged payload without hesitation.

The Fix: Cryptographic Verification

You must always use jwt.verify() with a secure, server side secret key before trusting any data inside the payload.

// SECURE: Verifying token signature with your secret key
try {
  const verifiedPayload = jwt.verify(token, process.env.JWT_SECRET);
  req.user = verifiedPayload;
} catch (error) {
  res.status(401).json({ message: 'Invalid or tampered token' });
}
Enter fullscreen mode Exit fullscreen mode

If an attacker tampers with a single character in the payload, the signature check fails and the request is rejected immediately.

3. Weak Signing Secrets

When using symmetric algorithms like HMAC SHA-256 (HS256), the security of your authentication relies entirely on the strength of your secret key.

// DANGEROUS: Weak secret keys vulnerable to brute force attacks
const token = jwt.sign(payload, "my_super_secret_key_123");
Enter fullscreen mode Exit fullscreen mode

The Vulnerability

Because JWTs are stored on the client side, an attacker who obtains a valid token can run offline brute force attacks against it. Tools like Hashcat can test millions of potential secret keys per second against your token signature. If your secret is simple, an attacker will crack it in seconds, allowing them to forge valid administrative tokens at will.

The Fix: High Entropy Secrets

Always generate long, cryptographically secure random secrets for signing production tokens. You can easily generate a strong 256-bit secret key in Node.js using the built in crypto module:

node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
Enter fullscreen mode Exit fullscreen mode

Store this value safely inside an environment variable and never commit it to source control.

The Verdict

API security requires defense in depth. Stop storing tokens in localStorage, never trust unverified payloads, and use cryptographically strong secret keys.

How does your team currently store authentication tokens on the frontend? Let us discuss in the comments below.

Top comments (0)