API & Token Security

JWT Security: Common Pitfalls and How to Fix Them

JSON Web Tokens carry identity and claims in a signed envelope. Most JWT breaches come from five well-understood mistakes: weak algorithms, missing verification, no expiry, leaked secrets and over-powerful claims.

<p>JWT (JSON Web Token) security means creating, signing, verifying and retiring tokens so that a leaked or forged token cannot unlock accounts or bypass authorization. A JWT is a base64-encoded envelope of claims signed with a secret or private key; whoever controls the key controls every identity the token asserts. Most real-world JWT breaches trace back to the same handful of implementation mistakes — all of them fixable with a few disciplined rules.</p> <h2>The Five Pitfalls Behind Most JWT Breaches</h2> <h3>1. Accepting the algorithm from the token itself</h3> <p>The header declares which algorithm signed the token. If your verification code trusts that declaration, an attacker can switch the header to <code>alg: none</code> or from RS256 (asymmetric) to HS256 (symmetric) — signing the token with the <em>public</em> key your code then treats as the shared secret. <strong>Fix: hard-code the expected algorithm server-side and reject everything else.</strong></p> <h3>2. Skipping signature or claim verification</h3> <p>The <code>jwt.io</code> debugger shows a green &quot;verified&quot; badge the moment the secret matches — but your code must verify signature, expiry (<code>exp</code>), not-before (<code>nbf</code>), issuer (<code>iss</code>) and audience (<code>aud</code>) every time. A token with no <code>exp</code>, or a verifier that only decodes without verifying, is an open door that never closes. <strong>Fix: verify all of them, and reject tokens missing <code>exp</code>.</strong></p> <h3>3. Weak or leaked signing secrets</h3> <p>A 16-character signing secret can be brute-forced offline from any sample token in hours on cheap hardware. Secrets committed to git, printed in logs or generated at install time with weak entropy are the usual source. <strong>Fix: 256-bit random secrets from a proper CSPRNG, stored in a secrets manager, rotatable without redeploying.</strong></p> <h3>4. Long-lived, non-revocable tokens</h3> <p>Stateless JWTs cannot be individually revoked — a token valid for 30 days is a 30-day liability if it leaks from a browser, log file or mobile backup. <strong>Fix: short access-token lifetimes (5-15 minutes) plus a refresh-token flow you can revoke; keep a denylist of revoked <code>jti</code>s for the access-token window.</strong></p> <h3>5: Trusting claims for authorization</h3> <p>The token tells you who the user is; it must not be the only thing deciding what they may do. A <code>role: admin</code> claim that your API honors blindly is a privilege-escalation waiting for the day a token-forging bug appears. <strong>Fix: re-check sensitive permissions server-side per request, per resource.</strong></p> <h2>Step 1: Verify Every Token the Same Way, Every Time</h2> <p>Centralize verification in middleware — never decode tokens ad-hoc in controllers:</p> <pre><code class="language-php">// Laravel example — one gate, one policy public function handle($request, $next) { $token = $request-&gt;bearerToken(); $claims = JWT::decode($token, new Key(config('jwt.secret'), 'HS256')); // algorithm FIXED if ($claims-&gt;iss !== config('app.url') || $claims-&gt;aud !== 'api') abort(401); if (!isset($claims-&gt;exp) || $claims-&gt;exp &lt; time()) abort(401); $request-&gt;attributes-&gt;set('jwt_claims', $claims); return $next($request); } </code></pre> <h2>Step 2: Issue Minimal, Short-Lived Tokens</h2> <p>Keep access tokens at 5-15 minutes with the smallest claim set that works (subject, issued-at, expiry, token id). Put everything else behind the database: roles, scopes and profile data live server-side, keyed on the <code>sub</code> claim, so a stale or leaked token grants as little as possible.</p> <h2>Step 3: Rotate Secrets and Revoke Refresh Tokens</h2> <p>Rotate the signing secret on a schedule and support multiple accepted keys during the transition window (issue with the new key, accept the old one until every token expires). Store refresh tokens hashed, tie them to device metadata, and revoke the whole family on suspicious activity — that is the revocation story access tokens cannot give you.</p> <h2>Step 4: Never Put Secrets in the Token</h2> <p>The payload is base64, not encryption — anyone holding the token reads every claim. No passwords, API keys, sensitive personal data or internal identifiers that leak infrastructure details. Treat the payload like a postcard, and assume every token will eventually appear in a log file or a client's localStorage dump.</p> <h2>How 2MNY Detects Exposed JWT Infrastructure</h2> <p>Our scanner looks for the outward signs: JWTs in scripts and responses where they should not be (for example tokens embedded in client-side code or debug endpoints), JWT-related debug panels left enabled, and endpoints accepting unsigned or <code>alg: none</code> tokens. See what else the <a href="https://2mny.com/free-security-scan">full scan</a> checks, or read how we handle <a href="https://2mny.com/kb/live-secret-verification">live secret verification</a> — we never touch credentials, we only report that they exist.</p> <h2>Frequently Asked Questions</h2> <h3>Q: Is JWT more secure than session cookies?</h3> <p>A: Neither is universally safer — they move the risk. Cookies get browser-grade protections (HttpOnly, SameSite, CSRF tokens) but need server-side sessions; JWTs are stateless and mobile-friendly but are hard to revoke and easy to leak from logs and storage. The secure pattern for web apps is cookie sessions plus server-side revocation; use JWTs where statelessness is a genuine requirement.</p> <h3>Q: What does the alg:none attack actually do?</h3> <p>A: The attacker edits the token header to claim no signature is used and modifies the payload (for example <code>role: admin</code>). A verifier that reads the algorithm from the token accepts the forged payload. It is fully prevented by fixing the expected algorithm in your verification code and rejecting tokens whose header disagrees.</p> <h3>Q: How long should a JWT live?</h3> <p>A: Access tokens: 5-15 minutes. Refresh tokens: days to weeks, stored hashed and revocable. If your access tokens live longer than your incident-response time, a leaked token outlives your ability to respond to it.</p> <h3>Q: Where should the signing secret live?</h3> <p>A: In a secrets manager or environment-level secret store — not in source code, not in the CI config, not in container images. Generate 256+ bits from a CSPRNG. If a secret may have leaked, rotate immediately and accept the old key only for the remaining lifetime of currently valid tokens.</p>

Was this article helpful?

Check if your site has this issue

Our free scan covers this and 40 more checks — no signup to start.

Start Free Scan