JWT — JSON Web Tokens
Lift ships a self-contained JWT implementation: encode/decode, HS256–HS512 (symmetric) and RS256–RS512 (asymmetric), a fluent Claims builder, and a ready-to-use Bearer-auth middleware. Zero external dependencies — pure PHP plus ext-openssl for RSA.
Mental model: a JWT is
{header}.{payload}.{signature}— three base64url segments separated by dots. The signature proves the payload wasn't tampered with. If you trust the signature, you trust the payload.
When to use JWTs
- Stateless API auth (mobile apps, SPAs, service-to-service).
- Short-lived signed tickets (password-reset links, email confirmation, magic-link login).
- Inter-service trust (one of your services signs a token; another verifies it without sharing DB state).
When not to use them:
- Server-rendered session-cookie apps — use Sessions. They handle revocation cleanly.
- Storing sensitive data — JWT payloads are base64-encoded, not encrypted. Anyone with the token can read them. (For encrypted tokens, see JWE — not implemented by this library.)
30-second example
use Lift\Jwt\Jwt;
use Lift\Jwt\Claims;
$jwt = new Jwt(secret: $_ENV['JWT_SECRET']);
// Issue a token
$token = $jwt->encode(
Claims::make()
->subject('user_42')
->expiresIn(3600)
->extra(['role' => 'admin'])
->toArray()
);
// Later, verify and decode
try {
$payload = $jwt->decode($token);
// $payload['sub'] === 'user_42'
// $payload['role'] === 'admin'
} catch (\Lift\Jwt\JwtException $e) {
// expired, tampered, wrong key, malformed, …
}
Algorithms
| Enum case | Type | Use when |
|---|---|---|
JwtAlgorithm::HS256 |
HMAC SHA-256 | Single-process apps. Both issuer and verifier share one secret. |
JwtAlgorithm::HS384 |
HMAC SHA-384 | Same, larger digest. Rarely needed. |
JwtAlgorithm::HS512 |
HMAC SHA-512 | Same, larger digest. Rarely needed. |
JwtAlgorithm::RS256 |
RSA SHA-256 | Multi-service. Private key signs; public key verifies. |
JwtAlgorithm::RS384 |
RSA SHA-384 | Same. |
JwtAlgorithm::RS512 |
RSA SHA-512 | Same. |
Symmetric (HS*) — simple
$jwt = new Jwt(
secret: $_ENV['JWT_SECRET'],
algo: JwtAlgorithm::HS256, // default
);
The secret must be at least 32 random bytes. Generate one once:
php -r 'echo base64_encode(random_bytes(64));'
Asymmetric (RS*) — for distributed systems
The issuer holds the private key. Verifiers only need the public key. Compromise the public key — nothing happens; an attacker still can't forge tokens.
// Issuer
$issuer = new Jwt(
algo: JwtAlgorithm::RS256,
privateKey: file_get_contents('/keys/private.pem'),
);
$token = $issuer->encode($payload);
// Verifier (only needs the public key)
$verifier = new Jwt(
algo: JwtAlgorithm::RS256,
publicKey: file_get_contents('/keys/public.pem'),
);
$payload = $verifier->decode($token);
Generate a key pair:
openssl genpkey -algorithm RSA -out private.pem -pkeyopt rsa_keygen_bits:2048
openssl rsa -in private.pem -pubout -out public.pem
The Claims builder
Standard JWT claim names are three-letter codes. The fluent builder spells them out:
$payload = Claims::make()
->subject('user_42') // sub
->issuer('https://api.example.com') // iss
->audience('https://app.example.com') // aud (or array)
->id(Uuid::v7()) // jti — unique token id
->issuedAt() // iat — defaults to now()
->expiresIn(3600) // exp — 1 hour from now
->notBefore(time() + 60) // nbf — valid only after this time
->extra([
'role' => 'admin',
'email' => '[email protected]',
])
->toArray();
You can skip the builder entirely and pass a raw array — it's just sugar.
What Lift validates automatically
When you call $jwt->decode($token) it checks:
| Claim | Behaviour |
|---|---|
| Signature | Always verified against the configured key/secret. |
exp |
Token rejected if now > exp (with leeway tolerance). |
nbf |
Token rejected if now < nbf (with leeway tolerance). |
iss |
Verified only if you configured issuer: on the Jwt instance. |
aud |
Verified only if you configured audience: on the Jwt instance. |
Anything else (sub, custom claims) is not validated by the library — you check them in your handler / middleware.
Issuer / audience enforcement
$jwt = new Jwt(
secret: $_ENV['JWT_SECRET'],
issuer: 'https://auth.example.com',
audience: 'https://api.example.com',
);
// decode() will reject any token whose `iss` and `aud` don't match
This protects you from token replay across services — a token issued for the billing API can't be used against the admin API.
Clock skew
If your verifier's clock is a couple of seconds behind the issuer's, freshly-issued tokens may briefly look "not yet valid". Allow a small leeway:
$jwt = new Jwt(secret: $_ENV['JWT_SECRET'], leeway: 30); // ±30 seconds
The middleware
Drop-in Bearer-token authentication:
use Lift\Jwt\JwtMiddleware;
$jwt = new Jwt(secret: $_ENV['JWT_SECRET']);
$app->use(new JwtMiddleware($jwt));
// In any handler:
$app->get('/me', function (Request $req) {
$claims = $req->getAttribute('jwt'); // decoded payload
return ['user_id' => $claims['sub']];
});
What it does:
- Reads
Authorization: Bearer <token>. - Calls
$jwt->decode($token). - On success: attaches the payload to
$reqas'jwt'attribute, calls the next handler. - On failure: returns
401 UnauthorizedJSON +WWW-Authenticate: Bearerheader.
Skipping public routes
The middleware can ignore exact paths — useful when you mount it globally:
$app->use(new JwtMiddleware(
jwt: $jwt,
except: ['/login', '/register', '/healthz', '/openapi.json'],
));
For more flexible skipping (regex paths, public groups), apply per-group instead of globally:
$app->group('/api', function ($g) use ($jwt) {
$g->get('/me', /* … */);
$g->get('/orders', /* … */);
})->middleware(new JwtMiddleware($jwt));
Custom attribute name
new JwtMiddleware($jwt, attribute: 'auth');
// later: $req->getAttribute('auth')
End-to-end example: login + protected route
use Lift\App;
use Lift\Crypto\Hasher;
use Lift\Http\Request;
use Lift\Http\Response;
use Lift\Jwt\Claims;
use Lift\Jwt\Jwt;
use Lift\Jwt\JwtMiddleware;
$app = new App();
$jwt = new Jwt(secret: $_ENV['JWT_SECRET']);
$hasher = new Hasher();
$app->instance(Jwt::class, $jwt);
$app->instance(Hasher::class, $hasher);
// 1. Login — public
$app->post('/login', function (Request $req) use ($jwt, $hasher, $db) {
$data = $req->validate([
'email' => 'required|email',
'password' => 'required|string',
]);
$user = $db->table('users')->where('email', $data['email'])->first();
if ($user === null || !$hasher->verify($data['password'], $user['password_hash'])) {
return Response::json(['error' => 'Invalid credentials'], 401);
}
$token = $jwt->encode(
Claims::make()
->subject((string) $user['id'])
->expiresIn(3600)
->extra(['email' => $user['email'], 'role' => $user['role']])
->toArray()
);
return Response::json(['token' => $token, 'expires_in' => 3600]);
});
// 2. Protect everything under /api with the middleware
$app->group('/api', function ($g) {
$g->get('/me', function (Request $req) {
$claims = $req->getAttribute('jwt');
return ['id' => $claims['sub'], 'email' => $claims['email']];
});
$g->get('/admin/stats', function (Request $req) {
$claims = $req->getAttribute('jwt');
if (($claims['role'] ?? '') !== 'admin') {
throw new \Lift\Exception\ForbiddenException();
}
return ['users' => 42, 'orders' => 1337];
});
})->middleware(new JwtMiddleware($jwt));
$app->run();
Client usage:
# 1. Login
TOKEN=$(curl -s -X POST http://localhost:8000/login \
-H 'Content-Type: application/json' \
-d '{"email":"[email protected]","password":"hunter2"}' | jq -r .token)
# 2. Use the token
curl http://localhost:8000/api/me \
-H "Authorization: Bearer $TOKEN"
Refresh tokens
JWT is stateless — once issued, you can't "revoke" a token before it expires. The standard fix:
- Access tokens are short-lived (5–15 min) JWTs.
- Refresh tokens are long-lived (days/weeks) opaque IDs stored in your DB. When an access token expires, the client exchanges the refresh token for a new access token. You can revoke a refresh token by deleting the row.
Sketch:
$app->post('/refresh', function (Request $req) use ($jwt, $db) {
$data = $req->validate(['refresh_token' => 'required|string']);
$row = $db->table('refresh_tokens')
->where('token', hash('sha256', $data['refresh_token']))
->where('expires_at', '>', date('Y-m-d H:i:s'))
->first();
if ($row === null) {
throw new \Lift\Exception\UnauthorizedException("Bad refresh token");
}
// Optional: rotate — delete the old refresh token, issue a new pair.
$access = $jwt->encode(Claims::make()->subject((string)$row['user_id'])->expiresIn(900)->toArray());
return ['access_token' => $access];
});
Storing tokens client-side
SPA / browser: put the access token in memory (a JS variable, never localStorage). Refresh token in an HttpOnly; Secure; SameSite=Strict cookie. This combination resists XSS (the access token isn't reachable by malicious scripts) and CSRF (the cookie can't be exfiltrated by other sites).
Mobile: platform secure storage (Keychain / Keystore).
Server-to-server: in process memory; re-read from a secrets manager on rotation.
Security checklist
- ✅ HMAC secret is at least 32 random bytes, kept in env vars / secrets manager, never in git.
- ✅ Short
exp(≤ 1 hour for access tokens). - ✅ Always validate
issandaudwhen you have multiple services / clients. - ✅ Always pin
algoserver-side — never let the token'salgheader decide. - ✅ Rotate RSA keys at least annually. Support multiple public keys during a rotation window (build a small
KeySelectorif you need this). - ❌ Never trust the payload before
decode()returns successfully. - ❌ Never put passwords, raw PII, or session cookies inside a JWT — they're plaintext-visible.
- ❌ Never use
alg: none— Lift's enum doesn't even include it, but be aware some libraries do.
Common pitfalls
| Symptom | Cause | Fix |
|---|---|---|
Token has expired immediately after issuing |
Issuer and verifier clocks drift | Set leeway: 30 on the verifier. |
| 401 on every request after deploy | JWT_SECRET changed |
Rotate carefully: tokens issued with the old secret die instantly. Use rolling deploys / two-key support. |
Missing or malformed Authorization header. |
Header is Bearer token (correct) but client forgot the space, or uses bearer (lowercase OK) |
Lift's check is case-sensitive on Bearer — make sure the client sends exactly that prefix. |
Token's role field updated server-side but the user still sees the old role |
JWT is stateless; old tokens still work until exp |
Short exp + refresh tokens; or maintain a server-side "session version" the client must echo. |
RSA verification returns false for valid token |
Wrong key, or PEM has CRLF line endings | Re-export keys; verify with openssl rsa -in private.pem -check. |
| Token is huge | You stuffed lots of claims into it | Keep payloads tiny (sub + a few ids). The token is sent on every request. |
Cheat sheet
// Configure
$jwt = new Jwt(
secret: $_ENV['JWT_SECRET'],
algo: JwtAlgorithm::HS256,
leeway: 30,
issuer: 'https://auth.example.com',
audience: 'https://api.example.com',
);
// Issue
$token = $jwt->encode(
Claims::make()->subject('user_42')->expiresIn(3600)->extra([...])->toArray()
);
// Verify
try { $payload = $jwt->decode($token); }
catch (JwtException $e) { /* 401 */ }
// Middleware
$app->use(new JwtMiddleware($jwt, except: ['/login']));
$claims = $req->getAttribute('jwt');