JWTs are everywhere because they are convenient. That same convenience hides a set of mistakes that are easy to make and hard to notice, because the app works fine right up until someone forges a token.
Decode is not verify
The most dangerous JWT bug is calling decode() when you meant verify(). Decode reads the token's claims without checking the signature, which means it trusts a token anyone could have written. If your auth trusts decoded claims, your auth is theater.
The weak-secret problem
A JWT is only as strong as its signing secret. A short secret, a guessable one, or the classic fallback process.env.JWT_SECRET || "secret" means an attacker can sign their own valid tokens. Use a long, random secret, and never ship a default.
If a user can change their own token's contents and your server believes it, you do not have authentication.
Where you store the token matters
Putting a JWT in localStorage makes it readable by any script on the page, which turns a single cross-site scripting bug into full account takeover. HTTP-only cookies with the Secure and SameSite flags are the safer default.
Expiry and revocation
Tokens with no expiry live forever. A leaked token you cannot revoke is a permanent backdoor. Set short lifetimes, refresh deliberately, and have a way to invalidate. Auth is the one area where "it works" and "it is safe" are very different claims, so scan for these specific patterns before you ship.