JSON Web Tokens (JWT)

The Concept

JWTs are self-contained tokens that carry claims (data) with a cryptographic signature. Unlike server sessions, the server doesn't need to store anything - the token itself contains all the information needed to verify the user.

Key insight: With sessions, the server stores data and gives the client a key (session ID). With JWT, the client stores the data, and the server just verifies the signature.

JWT Structure

A JWT consists of three parts separated by dots:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
Part Contains Decoded Example
Header Algorithm & type {"alg": "HS256", "typ": "JWT"}
Payload Claims (user data) {"sub": "42", "name": "Alice", "exp": 1699999999}
Signature Verification hash HMAC-SHA256(header.payload, secret)
Important: JWT payload is Base64URL encoded, NOT encrypted! Anyone can decode and read it. The signature only proves it wasn't tampered with.

How It Works

1. Login Request: Client Server |--- POST /login {user, pass} --->| | | Validates credentials | | Creates JWT: | | header = {"alg":"HS256"} | | payload = {"sub":42,"exp":...} | | signature = HMAC(header.payload, SECRET) |<---- { "token": "eyJ..." } -----| 2. Subsequent API Calls: Client Server |--- GET /api/profile ----------->| | Authorization: Bearer eyJ... | | | Extracts token | | Verifies signature with SECRET | | Checks exp claim (not expired?) | | Reads claims from payload |<---- { "name": "Alice" } -------|

Interactive Demo

Try JWT Demo

The demo lets you:

JWT vs Server Sessions

Aspect JWT Server Sessions
Server Storage None (stateless) Session data in files/database
Scalability Excellent (no shared state) Requires shared session store
Revocation Difficult (valid until expiry) Easy (delete session)
Token Size Larger (contains all claims) Small (just session ID)
Validation Cost CPU (crypto verification) I/O (database/cache lookup)

When to Use JWT

Good fit: Poor fit:

Security Considerations

1. Token Storage

Where you store the JWT in the browser matters significantly for security:

localStorage/sessionStorage: Vulnerable to XSS attacks. Any JavaScript on the page (including injected malicious scripts) can read and steal the token.
HttpOnly Cookie: The safer choice for browser apps. JavaScript cannot access it, protecting against XSS. Add SameSite=Strict and Secure flags for additional protection.

2. Claim Validation

The server must validate standard JWT claims on every request:

<?php
// Check expiration (exp) - reject expired tokens
if ($payload['exp'] < time()) {
    return null;  // Token expired
}

// Check "issued at" (iat) - reject tokens from the future
// (allows 60s clock skew tolerance)
if ($payload['iat'] > time() + 60) {
    return null;  // Suspicious: issued in the future
}

// Check "not before" (nbf) - token not yet valid
if (isset($payload['nbf']) && $payload['nbf'] > time()) {
    return null;  // Token not yet valid
}
?>

3. Signature Verification

Always verify the signature using timing-safe comparison to prevent timing attacks:

<?php
// Use hash_equals() - NOT strcmp() or ===
// Timing-safe comparison prevents attackers from learning
// which bytes of the signature are correct
if (!hash_equals($expectedSignature, $providedSignature)) {
    return null;  // Invalid signature - token was tampered with
}
?>

The Implementation

Below is the core JWT implementation used in this demo (educational only - use a library like firebase/php-jwt in production):

<?php
// Secret key - in production, use a long random string from env vars
define('JWT_SECRET', 'your-256-bit-secret-key');

/**
 * Base64URL encode (URL-safe Base64)
 * Standard Base64 uses +, /, and = which aren't URL-safe
 */
function base64UrlEncode(string $data): string {
    return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
}

/**
 * Create a JWT token
 */
function createJwt(array $payload): string {
    $header = ['alg' => 'HS256', 'typ' => 'JWT'];

    $headerEncoded = base64UrlEncode(json_encode($header));
    $payloadEncoded = base64UrlEncode(json_encode($payload));

    // HMAC-SHA256 signature
    $signature = hash_hmac('sha256',
        "$headerEncoded.$payloadEncoded",
        JWT_SECRET, true);
    $signatureEncoded = base64UrlEncode($signature);

    return "$headerEncoded.$payloadEncoded.$signatureEncoded";
}

/**
 * Verify and decode a JWT token
 */
function verifyJwt(string $token): ?array {
    $parts = explode('.', $token);
    if (count($parts) !== 3) return null;

    [$headerEncoded, $payloadEncoded, $signatureEncoded] = $parts;

    // Recalculate expected signature
    $expectedSignature = base64UrlEncode(
        hash_hmac('sha256',
            "$headerEncoded.$payloadEncoded",
            JWT_SECRET, true)
    );

    // Timing-safe comparison
    if (!hash_equals($expectedSignature, $signatureEncoded)) {
        return null;
    }

    $payload = json_decode(base64UrlDecode($payloadEncoded), true);

    // Validate claims (exp, iat, nbf)
    if (isset($payload['exp']) && $payload['exp'] < time()) return null;
    if (isset($payload['iat']) && $payload['iat'] > time() + 60) return null;

    return $payload;
}
?>

Back to State Management | Sessions Demo | Security Patterns