f21/jwt
f21/jwt adds JSON Web Token authentication to Laravel applications, providing a JWT guard, token generation and validation, middleware for protecting routes, and helpers for stateless API authentication without relying on sessions. It integrates with Laravel’s auth system and supports API token work...
f21/jwt is a small, dependency-light PHP JWT library (F21\Jwt\Jwt) that wraps encode/decode with HS256/HS384/HS512 (and RS* via OpenSSL) plus a handful of typed exceptions. It is archived, so treat it as legacy glue — install it, verify the source once, then wrap it behind your own interface so you can swap it out later.
Minimal path:
composer require f21/jwt
use F21\Jwt\Jwt;
use F21\Jwt\Exception\TokenExpiredException;
use F21\Jwt\Exception\SignatureInvalidException;
$jwt = new Jwt(getenv('JWT_SECRET'), 'HS256');
// issue
$token = $jwt->encode([
'sub' => $user->id,
'iat' => time(),
'exp' => time() + 3600,
'scope' => 'api',
]);
// verify
try {
$payload = $jwt->decode($token);
} catch (TokenExpiredException $e) {
// 401 with a distinct code so the client can refresh
} catch (SignatureInvalidException $e) {
// 401, never leak details
}
Where to look first, in this order:
src/Jwt.php — the constructor signature (secret, algo/algorithm) and whether decode() returns an array or stdClass. These two details differ between releases and are the source of most "why is my claim missing" bugs.src/Exception/ — the exact exception class names. Catch on these, not on \Exception, or you will silently swallow expiry.README for the encode options (exp, nbf, leeway) it actually supports, if any.First real use case: drop it into a stateless API auth middleware. Issue a short-lived access token on login, validate on each request, and return 401 with WWW-Authenticate: Bearer on failure. That is the 80% path and where the library is adequate.
Wrap it, do not sprinkle it. Bind a single configured instance in the container and expose your own narrow interface. When you eventually migrate to firebase/php-jwt or lcobucci/jwt, the blast radius is one class.
// AppServiceProvider
$this->app->singleton(TokenService::class, fn () => new TokenService(
new Jwt(config('jwt.secret'), config('jwt.algo', 'HS256'))
));
Middleware validation.
public function handle($request, Closure $next)
{
$header = $request->header('Authorization', '');
if (! str_starts_with($header, 'Bearer ')) {
return response()->json(['error' => 'missing_token'], 401);
}
try {
$claims = $this->tokens->parse(substr($header, 7));
} catch (TokenExpiredException) {
return response()->json(['error' => 'token_expired'], 401);
} catch (\Throwable) {
return response()->json(['error' => 'token_invalid'], 401);
}
$request->attributes->set('auth', [
'id' => $claims['sub'],
'scope' => $claims['scope'] ?? null,
]);
return $next($request);
}
Access + refresh split. Keep the access token in the Authorization header and the refresh token in an HttpOnly, Secure, SameSite=Lax cookie. The library has no revocation, so give refresh tokens a jti claim and persist jti => [user_id, expires_at] in your DB/Redis; reject on refresh if the jti is not present. That table is your only logout/revocation mechanism — the JWT itself cannot be invalidated.
Claim discipline. Standard claims (iss, aud, sub, exp, nbf, jti) plus a namespaced app claim (https://yourapp.com/roles) rather than top-level admin: true, which is a common collision/confusion vector. Validate aud and iss yourself in TokenService::parse() — small libraries like this one frequently skip those checks, and that omission is a real security gap for multi-service tokens.
Key rotation via kid. The library gives you no key selection. Implement it above the library: read the header's kid (base64url-decode segment 0 yourself), look up the secret in config, then construct a Jwt with that key. This is the cleanest extension point and keeps you migration-ready.
Testing. Freeze time or inject a clock; exp comparisons against time() make tests flaky at the second boundary. Generate tokens in a factory and assert on the decoded array rather than on the raw string.
Signing vs. encryption. This is JWS only — payloads are base64url-encoded, not encrypted. Anyone holding the token can read the claims. Never put PII, roles you would not show the user, or anything sensitive in the payload.
composer.json and add a composer audit step plus a calendar reminder to reassess.decode() does not read alg from the token header and honour it. If it does, an attacker can present alg: none or an HS256 token signed with your RS256 public key. Always pass the algorithm explicitly to the constructor and, if the library lets the token decide, stop using it.bin2hex(random_bytes(32))), not env('APP_KEY') reuse and never a human-typed string. Rotating APP_KEY will silently invalidate every issued token — keep the JWT secret separate.+// characters are the classic hand-rolled-decoder pitfall. If you decode segments yourself (for kid, above), use rtrim(strtr(base64_encode($x), '+/', '-_'), '=') and pad on the way back in.exp type mismatches. Some versions compare exp as an int, others coerce; passing a string exp or an ISO-8601 date can produce tokens that expire immediately or never. Always time() + $ttl as an integer.exp when issuing to compensate. Without this you get intermittent, unreproducible 401s in production only.TokenExpiredException and SignatureInvalidException separately — "expired" is actionable for the client (refresh), "invalid" is not. Broad catch (\Exception) hides malformed-config errors as auth failures and makes debugging miserable.json_decode(base64url_decode($parts[1]))) before blaming the library — 90% of "signature invalid" reports are a trailing newline in .env, a leading Bearer left on the string, or whitespace from a curl copy-paste.sub plus a version/jti and look up the rest.decode() returns an array or an object in your pinned version before writing $claims['sub'] everywhere. Mixed usage across the codebase is a subtle source of null ids.TokenService accepting and emitting the same claim set you would use with firebase/php-jwt (which is a near-drop-in for encode/decode and has a matching exception style). That makes the eventual swap a single-file change rather than an audit of every controller.How can I help you explore Laravel packages today?