SyntaxStudy
Sign Up
Web Security JWT Security: Pitfalls and Best Practices
Web Security Beginner 2 min read

JWT Security: Pitfalls and Best Practices

JSON Web Tokens (JWTs) are a compact, self-contained format for transmitting claims between parties as a JSON object signed with HMAC-SHA256 or RSA/ECDSA. Because JWTs are signed and optionally encrypted, the server can verify their integrity without a database lookup, making them suitable for stateless APIs and microservices. However, JWT has several well-known implementation pitfalls that have caused real-world vulnerabilities. The most notorious JWT vulnerability is the `alg:none` attack. Early JWT libraries that honoured the `alg` header field without restricting it could be tricked into accepting an unsigned token by setting `"alg":"none"`. Always specify the expected algorithm explicitly in the verify call — never accept whatever the token header claims. The related algorithm confusion attack applies to RS256 vs HS256: if a library that expects RSA public key verification is fed a symmetric HMAC token signed with the public key itself, it may verify it incorrectly. Additional JWT best practices: set short expiry (`exp`) — 15 minutes for access tokens, with a longer-lived refresh token stored in an HttpOnly cookie. Include `iss` (issuer) and `aud` (audience) claims and validate them. Never store sensitive data in the payload (it is base64-encoded, not encrypted, so anyone who holds the token can decode it). For high-security scenarios use JWE (JSON Web Encryption) to encrypt the payload. Implement token revocation via a short-lived blocklist for logout and emergency revocation.
Example
<?php
// Secure JWT handling with firebase/php-jwt

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

$privateKey = file_get_contents('/run/secrets/jwt_private_key'); // PEM RSA key
$publicKey  = file_get_contents('/run/secrets/jwt_public_key');

// --- ISSUE an access token (RS256 asymmetric signing) ---
function issueAccessToken(int $userId, string $email): string
{
    global $privateKey;
    $now = time();
    $payload = [
        'iss' => 'https://api.example.com',       // issuer
        'aud' => 'https://app.example.com',       // audience
        'sub' => (string) $userId,                // subject
        'iat' => $now,                             // issued at
        'exp' => $now + 900,                       // 15-minute expiry
        'email' => $email,
        // Do NOT include: passwords, PII beyond what's needed, secrets
    ];
    return JWT::encode($payload, $privateKey, 'RS256');
}

// --- VERIFY a token (explicit algorithm — never accept 'alg' from header) ---
function verifyAccessToken(string $token): object
{
    global $publicKey;
    $decoded = JWT::decode($token, new Key($publicKey, 'RS256'));
    // Validate additional claims
    if ($decoded->iss !== 'https://api.example.com') {
        throw new \RuntimeException('Invalid issuer');
    }
    if ($decoded->aud !== 'https://app.example.com') {
        throw new \RuntimeException('Invalid audience');
    }
    return $decoded;
}

// --- Refresh token stored in HttpOnly cookie, not in JWT payload ---
// Set-Cookie: refresh_token=<opaque_token>; HttpOnly; Secure; SameSite=Strict; Path=/auth/refresh