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 DemoThe demo lets you:
- Log in and receive a JWT
- Decode the token to see its parts
- Call a protected API with the token
- See what happens with expired/invalid tokens
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:
- Stateless APIs (REST, GraphQL)
- Microservices (avoid shared session store)
- Short-lived tokens with refresh token pattern
- Cross-domain authentication (SSO)
- When you need immediate revocation (logout everywhere)
- Long session durations
- When token size matters (sent with every request)
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