JWT Security Pitfalls: What Often Goes Wrong with Tokens
AI generated
OWASP
0x00
Security · JWT · API Authentication · PHP
JWT Security Pitfalls: What Often Goes Wrong with Tokens
From alg:none to insecure client storage

JSON Web Tokens are the modern standard for stateless authentication, yet flawed implementations open the door to attackers. This article walks through the most common pitfalls such as algorithm confusion, unprotected payload data, and insecure storage, and delivers concrete, tested PHP solutions for robust API authentication.

14 min. read JWT · OWASP API Security · PHP 8.4 firebase/php-jwt · lcobucci/jwt

1. Why JWT security is so often underestimated

JSON Web Tokens have become inseparable from modern APIs: they enable stateless authentication, work across server boundaries, and can be validated without a central session database. Those exact properties, though, also make JWTs prone to a recurring set of implementation mistakes that show up far more systematically than with almost any other authentication technique. It's no accident that the OWASP API Security Top 10 dedicates an entire category to token management: Broken Authentication has remained one of the most frequently exploited vulnerability classes in production APIs for years.

The core problem rarely lies in the JWT specification itself, but in how developers implement it. Libraries like firebase/php-jwt or lcobucci/jwt remove a lot of complexity, yet even small configuration mistakes such as a missing algorithm allowlist parameter or an access token with an overly long lifetime can compromise an entire authentication system. This article walks through the six most common pitfalls systematically and shows how to reliably avoid them in PHP-based APIs without giving up the benefits that make JWT attractive as a format in the first place.

2. Understanding JWT structure: what "not encrypted" really means

A JWT consists of three base64url-encoded parts separated by dots: header, payload, and signature. The crucial realization that many developers only reach after a security incident: base64url encoding is not encryption, it's plain text encoding. Anyone who intercepts a JWT or copies it out of the browser DevTools network tab can decode the header and payload in seconds, for example via jwt.io or a simple base64_decode() call in PHP. The signature only protects against undetected tampering with the content, not against reading it.

This mix-up regularly leads to sensitive data such as internal user IDs from third-party systems, unencrypted email addresses with extra context, permission details, or in the worst case password hashes ending up in the payload. If a token genuinely needs to carry confidential data, the right tool is a JWE (JSON Web Encryption) rather than a signed JWS, or better yet, keeping the token deliberately lean: just a subject ID, an expiration time, and minimal authorization claims. Everything else should be fetched server-side once the token has been validated.


// Decoded JWT payload - visible to anyone who intercepts the token
{
  "sub": "user_48213",
  "email": "customer@example.com",
  "role": "customer",
  "iat": 1752300000,
  "exp": 1752303600
  // WARNING: never put secrets here, e.g.:
  // "internal_api_key": "sk_live_51Hxxxxx"  <- readable in plain text!
  // "password_reset_token": "a1b2c3..."      <- readable in plain text!
  // A JWT payload is base64url-encoded, NOT encrypted.
  // Anyone holding the token can decode it instantly.
}

3. The alg:none vulnerability: when the signature simply disappears

The JWT specification (RFC 7519) allows the algorithm value none in the header, originally intended for use cases where integrity is already guaranteed at another layer. In practice, this value became one of the best-known JWT vulnerabilities of all: if an attacker sets the header to {"alg":"none"} and strips the signature entirely, poorly implemented validators still accept the token because they read the algorithm from the token itself instead of enforcing it server-side. That allows forging any claim at will, such as "role":"admin", without ever knowing a valid signing key.

The defense here is unambiguous and must never be left up to the client or the token itself: the application must explicitly specify an allowlist of permitted algorithms during validation, and none must never be part of that list. Modern libraries like firebase/php-jwt from version 6 onward now mandatorily require the algorithm parameter on every decode call, but older versions and hand-rolled validators are frequently the weak point here. A penetration test that specifically sends alg:none payloads against every token validation endpoint belongs in every API security audit.

4. Algorithm confusion: RS256 mistaken for HS256

Even more subtle than alg:none is the algorithm confusion attack between asymmetric and symmetric schemes. Many APIs sign tokens with RS256, an asymmetric scheme with a private signing key and a public verification key. The public key is deliberately accessible, for instance via a JWKS endpoint or embedded in client-side code. An attacker who knows this public key can craft a forged token, change the header to HS256, and abuse the public RSA key as an HMAC secret.

If the server also reads the algorithm from the incoming token instead of enforcing it, it verifies the HMAC signature using exactly the same public key the attacker used to forge it, and mistakenly accepts the token as valid. This vulnerability has been documented in several well-known JWT libraries before their maintainers introduced mandatory algorithm pinning. The only reliable countermeasure: the server must never trust the alg field inside the token. Instead, the expected algorithm family must be hard-coded, and RS256 keys and HS256 secrets should live in separate key stores with different access permissions.


<?php

declare(strict_types=1);

namespace Mironsoft\Security\Model;

use Firebase\JWT\JWT;
use Firebase\JWT\Key;
use Firebase\JWT\SignatureInvalidException;

/**
 * Validates JWT access tokens with a pinned algorithm allowlist.
 * The algorithm is never read from the token header alone -
 * it is always paired with the matching key type.
 */
final class TokenValidator
{
    /**
     * @param string $publicKeyPem RSA public key in PEM format, used only for RS256
     */
    public function __construct(
        private readonly string $publicKeyPem
    ) {
    }

    /**
     * Decodes and verifies a JWT, rejecting any token that does not
     * explicitly use RS256. The "none" algorithm is never permitted.
     *
     * @param string $jwt Raw JWT string from the Authorization header
     * @return array<string, mixed> Decoded and verified claims
     * @throws SignatureInvalidException When the signature does not match
     */
    public function validate(string $jwt): array
    {
        // Explicitly pin the algorithm - never trust the "alg" header alone.
        // This single line prevents both alg:none and RS256/HS256 confusion.
        $decoded = JWT::decode($jwt, new Key($this->publicKeyPem, 'RS256'));

        return (array) $decoded;
    }
}

5. Getting token expiration and refresh token rotation right

A frequently underestimated risk is the lifetime of access tokens. A JWT without an exp claim, or one valid for several days, stays usable after theft, whether through an intercepted network packet or an XSS script, for as long as the attacker wants. The established best practice is short-lived access tokens of five to fifteen minutes combined with longer-lived refresh tokens that are used exclusively to renew the access token and never for direct API access.

The decisive piece is refresh token rotation: every renewal issues not just a new access token but also a new refresh token, while the old one is invalidated server-side. If an already-used, supposedly invalidated refresh token shows up again, that's a clear sign of a stolen token, and the system should immediately revoke the entire token family, not just the single token. This detection technique, known as refresh token reuse detection, can be implemented with a simple database table that stores one currently valid token ID per family.


<?php

declare(strict_types=1);

namespace Mironsoft\Security\Model;

use Mironsoft\Security\Api\RefreshTokenRepositoryInterface;

/**
 * Rotates refresh tokens on every use and detects reuse of
 * already-invalidated tokens as a sign of theft.
 */
final class RefreshTokenRotationService
{
    /**
     * @param RefreshTokenRepositoryInterface $repository Persists refresh token state
     */
    public function __construct(
        private readonly RefreshTokenRepositoryInterface $repository
    ) {
    }

    /**
     * Issues a new token pair and invalidates the previous refresh token.
     * If the given token was already rotated once before, the whole
     * token family is revoked immediately.
     *
     * @param string $presentedToken Refresh token sent by the client
     * @return array{access_token: string, refresh_token: string}
     * @throws \RuntimeException When token reuse is detected
     */
    public function rotate(string $presentedToken): array
    {
        $record = $this->repository->findByToken($presentedToken);

        if ($record === null || $record->isRevoked()) {
            // Reuse of an already-rotated token: assume theft, kill the family
            $this->repository->revokeFamily($record?->getFamilyId());
            throw new \RuntimeException('Refresh token reuse detected.');
        }

        $newRefreshToken = $this->repository->rotate($record);
        $newAccessToken = $this->issueShortLivedAccessToken($record->getUserId());

        return [
            'access_token' => $newAccessToken,
            'refresh_token' => $newRefreshToken,
        ];
    }

    /**
     * Creates a short-lived access token, valid for ten minutes.
     *
     * @param int $userId Subject the token is issued for
     * @return string Signed JWT access token
     */
    private function issueShortLivedAccessToken(int $userId): string
    {
        // Access token TTL kept intentionally short: 10 minutes
        return 'signed.jwt.token';
    }
}

6. Revocation: why stateless tokens are hard to revoke

The biggest architectural contradiction in JWT lies in its core idea itself: a token is considered valid as long as its signature checks out and it hasn't expired, with no database lookup at all. That's exactly what makes JWT fast and horizontally scalable, but it also makes immediate revocation hard. If an employee is terminated or an account is compromised, an already-issued access token remains technically valid until its exp timestamp, even if it's already been flagged as compromised on the backend.

In practice, three strategies have become established and can be combined: first, keeping access token lifetimes as short as possible so the window of an unrevokable token stays minimal. Second, a denylist for explicitly revoked token IDs (the jti claim), typically stored in Redis with a TTL matching the token's remaining validity, so the list never grows unbounded. Third, a global token version stamp per user that gets incremented on every security-relevant action such as a password change; every token carries this stamp as a claim, and the server compares it against the current database value on every request. Pure stateless purists skip revocation altogether and rely solely on short lifetimes, which is an acceptable trade-off for many APIs but can be too risky for highly sensitive systems.


# Store a revoked token's jti (JWT ID) in Redis with a TTL matching
# the token's remaining lifetime, so the denylist self-cleans.
redis-cli SETEX "jwt:revoked:4f8a1c9e-2b3d-4e5f-9a1b-8c7d6e5f4a3b" 600 "revoked"

# Check revocation status during request validation
redis-cli EXISTS "jwt:revoked:4f8a1c9e-2b3d-4e5f-9a1b-8c7d6e5f4a3b"

7. Client-side storage: cookies versus localStorage and XSS

The most common discussion in JWT security reviews centers on where the token should be stored in the browser. localStorage is convenient because JavaScript can access it without restriction, but that's exactly the problem: any successful Cross-Site Scripting (XSS) on the page, whether through unfiltered user input or a compromised third-party script, can read the token via localStorage.getItem() and send it to an attacker's server. Since JWTs are typically not bound to an IP address or device, a token stolen this way works from anywhere.

The more robust alternative is an httpOnly cookie with the Secure and SameSite=Strict or Lax flags. The browser denies JavaScript any read access to an httpOnly cookie, which renders classic token-stealing XSS ineffective. The trade-off: cookies are susceptible to Cross-Site Request Forgery (CSRF) if SameSite isn't set correctly, which is why an additional CSRF token or the double-submit cookie pattern is recommended. For single-page applications with their own API domain, a proven pattern is to keep the access token short-lived in memory (not in localStorage, but in a plain JavaScript variable) and persist only the refresh token in an httpOnly cookie.


<?php

declare(strict_types=1);

namespace Mironsoft\Security\Controller\Auth;

use Magento\Framework\App\Action\HttpPostActionInterface;
use Magento\Framework\App\RequestInterface;
use Magento\Framework\Controller\Result\JsonFactory;

/**
 * Issues the refresh token exclusively as an httpOnly, Secure,
 * SameSite cookie. The token is never exposed to client-side JavaScript.
 */
final class Login implements HttpPostActionInterface
{
    /**
     * @param JsonFactory $jsonFactory Builds the JSON API response
     */
    public function __construct(
        private readonly JsonFactory $jsonFactory
    ) {
    }

    /**
     * Authenticates the request and sets the refresh token cookie.
     *
     * @return \Magento\Framework\Controller\Result\Json
     */
    public function execute()
    {
        $refreshToken = 'issued.refresh.token';

        // httpOnly: not readable by JavaScript, mitigates XSS token theft
        // Secure: only sent over HTTPS
        // SameSite=Strict: mitigates CSRF for cross-site requests
        setcookie('refresh_token', $refreshToken, [
            'expires' => time() + 60 * 60 * 24 * 14,
            'path' => '/api/auth',
            'secure' => true,
            'httponly' => true,
            'samesite' => 'Strict',
        ]);

        $result = $this->jsonFactory->create();
        // Only the short-lived access token goes into the JSON body,
        // held in memory on the client, never in localStorage.
        return $result->setData(['access_token' => 'signed.jwt.token']);
    }
}

8. Signature validation in PHP: typical implementation mistakes

Beyond the conceptual pitfalls, most JWT vulnerabilities happen in just a few lines of validation logic. A classic mistake: the signing key is dynamically loaded based on a claim inside the token itself, for example a kid field (key ID) that gets used unchecked as a file path or database key. Without strict validation of the kid value, this opens attack vectors like path traversal or SQL injection through the token header itself, before the signature has even been checked.

Another widespread mistake is confusing decoding with verification: some developers use a quick debugging function that decodes the payload without checking the signature, for instance calling JWT::urlsafeB64Decode() directly instead of JWT::decode() with a key, and that debug shortcut accidentally makes it into production code. Equally risky is ignoring the exp and nbf claims in hand-written validators, or an overly generous time tolerance (leeway) that makes replay attacks with expired tokens easier. The general rule: signature validation, algorithm pinning, expiration checking, and issuer/audience checking belong in a single, well-tested class, never scattered across multiple controller methods.

9. Insecure vs. secure JWT patterns compared

The following overview summarizes the JWT pitfalls covered in this article and pairs each insecure pattern with the recommended secure implementation.

Area Insecure pattern Secure alternative
Algorithm alg is read from the token, none is accepted Fixed algorithm allowlist in code (e.g. RS256 only)
Client storage Token in localStorage, readable via JS Refresh token in httpOnly/Secure/SameSite cookie
Lifetime Access token with no exp or valid for days 5-15 min. access token + rotating refresh token
Payload content Secrets or password hashes in the payload Minimal claims only, sensitive data kept server-side
Revocation No revocation mechanism in place jti denylist in Redis or a token version stamp

Mironsoft

API security, JWT hardening, and authentication audits for Magento backends

Ready to harden your JWT implementation?

We review your token validation for algorithm confusion, insecure storage, and missing revocation mechanisms, and implement hardened, production-ready authentication for your PHP and Magento APIs.

JWT security audit

Review of signature validation, algorithm pinning, and claims

Refresh token architecture

Implementing rotation, reuse detection, and revocation strategies

API hardening

Secure cookie configuration and CSRF protection for PHP APIs

10. Summary

JWT security rarely fails because of the specification itself, but because of recurring implementation mistakes: trusting the token's alg field instead of a fixed algorithm allowlist, confusing base64url encoding with encryption, overly long token lifetimes without rotation, and the convenient but XSS-prone habit of storing tokens in localStorage. Every one of these pitfalls can be avoided with clear, testable rules: hard-code the algorithm in code, keep the payload minimal, pair short lifetimes with rotating refresh tokens, and store tokens in httpOnly cookies instead of JavaScript-accessible storage.

Stateless authentication remains a deliberate architectural trade-off: it buys scalability at the cost of instant revocation. Anyone who understands this trade-off and cushions it with short token lifetimes, a lean denylist, and a per-user version stamp can run JWT securely and in production on PHP and Magento APIs, without losing the performance benefits that made JWT attractive in the first place.

JWT Security Pitfalls - The Essentials at a Glance

Pin the algorithm

Never read alg from the token. The only reliable way to prevent alg:none and algorithm confusion.

No secrets in the payload

A JWT is only signed, not encrypted. Carry only minimal, non-sensitive claims.

Short lifetime + rotation

5-15 min. access token, rotating refresh token with reuse detection.

Secure storage

httpOnly/Secure/SameSite cookie instead of localStorage, plus a jti denylist for revocation.

11. FAQ: JWT Security Pitfalls

1What is the alg:none vulnerability in JWT?
Poorly implemented validators read the algorithm from the token and then accept unsigned tokens. A fixed algorithm allowlist in server code reliably prevents this.
2Is a JWT encrypted?
No, only base64url-encoded and signed. The payload is readable by anyone who sees the token. Use a JWE for confidentiality, or keep sensitive data out of the payload entirely.
3What is an algorithm confusion attack?
A public RSA key gets abused as an HMAC secret when the server reads the algorithm dynamically from the token instead of enforcing it.
4How long should a JWT access token be valid?
Five to fifteen minutes, combined with a separate, rotating refresh token for longer sessions.
5What is refresh token rotation?
Every token renewal issues a new refresh token and invalidates the old one. Reuse of an invalidated token triggers revocation of the entire token family.
6Why are stateless JWTs hard to revoke?
Validity is checked without a database lookup. Immediate revocation needs extra mechanisms like a jti denylist or a token version stamp.
7Should I store JWTs in localStorage?
Not recommended due to XSS access via JavaScript. An httpOnly cookie with Secure/SameSite for the refresh token is safer.
8Which PHP libraries are suitable for secure JWT validation?
firebase/php-jwt and lcobucci/jwt, each used with an explicit algorithm on every decode call.
9What should never go into a JWT payload?
Passwords, password hashes, API keys, internal system secrets. Use only minimal, non-sensitive claims.
10Is JWT generally suitable for Magento APIs?
Yes, as long as algorithm pinning, short lifetimes, refresh rotation, and secure client storage are implemented consistently.