firebase/php-jwt is the most widely used library for signing and verifying JSON Web Tokens in PHP, and using it safely comes down to one rule: always pin the exact algorithm you expect during decode. The library itself is small and well maintained, but every serious JWT vulnerability in PHP applications traces back to how developers call JWT::decode(), not to a flaw in the parsing code. This guide walks through the correct usage patterns and the one historical bug that shaped the current API.
If your application issues tokens for session state, API access, or single sign-on, the way you handle verification determines whether an attacker can forge a token that your server happily accepts.
What firebase/php-jwt actually does
The package installs with Composer:
composer require firebase/php-jwt
It gives you two core operations. Encoding turns a payload array plus a key and algorithm into a signed compact token:
use Firebase\JWT\JWT;
use Firebase\JWT\Key;
$payload = [
'iss' => 'https://api.example.com',
'sub' => 'user-1042',
'iat' => time(),
'exp' => time() + 3600,
];
$jwt = JWT::encode($payload, $privateKey, 'RS256');
Decoding reverses the process and validates the signature:
$decoded = JWT::decode($jwt, new Key($publicKey, 'RS256'));
That Key object is the important part, and its presence is a direct result of a security lesson the library learned the hard way.
The algorithm-confusion problem (CVE-2021-46743)
JSON Web Tokens carry their own algorithm claim in the header, the alg field. A naive verification library reads that field and uses whatever algorithm the token declares. That design creates a class of attack known as algorithm confusion.
The scenario: your server verifies RS256 tokens using an RSA public key, which is not secret. An attacker takes that public key, crafts a token with the header changed to HS256, and signs it using the public key bytes as an HMAC secret. If the verifier trusts the header's alg value and reuses the same key material, it will run HMAC-SHA256 over a key the attacker also has, and the forged signature validates.
In firebase/php-jwt this surfaced as CVE-2021-46743, rated by the maintainers and advisory databases as an algorithm-confusion issue affecting versions before 6.0.0. It applied specifically when a caller passed a key ring (multiple keys in an array or an ArrayAccess object) as the second argument and a list of algorithms as the third, with a mix of key types such as both RS256 and HS256 present. The kid header could then be abused to select the wrong key for the wrong algorithm.
Version 6.0.0 removed the ambiguity by changing the decode() signature. Instead of accepting a loose key plus a separate list of algorithms, it now requires you to bind each key to exactly one algorithm through the Key class. A token claiming HS256 can no longer be verified against a key you registered as RS256, because the binding is explicit.
Upgrading past the vulnerable API
If you are still on a 5.x release, the migration is mechanical but worth doing deliberately. The old call looked like this:
// Pre-6.0 style, no longer recommended
$decoded = JWT::decode($jwt, $publicKey, ['RS256']);
The modern equivalent is:
$decoded = JWT::decode($jwt, new Key($publicKey, 'RS256'));
For rotating or multiple signing keys, pass an associative array of Key objects indexed by key ID, and the library matches on the token's kid header while still enforcing the per-key algorithm binding:
$keys = [
'key-2024' => new Key($pubKey2024, 'RS256'),
'key-2025' => new Key($pubKey2025, 'RS256'),
];
$decoded = JWT::decode($jwt, $keys);
Because transitive dependencies frequently pull in older releases through frameworks or plugins, it is worth confirming the resolved version in your lockfile rather than trusting the version you asked for. An SCA tool such as Safeguard can flag a vulnerable firebase/php-jwt pulled in indirectly, which is how most teams discover they are exposed. Software composition analysis on the SCA product page explains how transitive resolution is tracked.
Validate more than the signature
A valid signature only proves the token was minted by someone holding your key. It says nothing about whether the token is still meant to be honored. Always check the registered claims:
exp(expiration) is enforced automatically bydecode(), which throwsExpiredExceptionwhen the token is past its lifetime.nbf(not before) andiat(issued at) are also checked, with a configurable leeway viaJWT::$leewayto tolerate small clock skew.issandaudare not checked for you. If you rely on them, inspect the decoded payload and reject mismatches yourself.
JWT::$leeway = 60; // tolerate 60 seconds of clock skew
try {
$decoded = JWT::decode($jwt, new Key($publicKey, 'RS256'));
if (($decoded->iss ?? null) !== 'https://api.example.com') {
throw new UnexpectedValueException('Bad issuer');
}
} catch (\Firebase\JWT\ExpiredException $e) {
http_response_code(401);
exit('Token expired');
} catch (\Exception $e) {
http_response_code(401);
exit('Invalid token');
}
Common mistakes worth avoiding
A few patterns show up repeatedly in code review:
Accepting none. Some libraries historically allowed an alg of none, meaning an unsigned token. firebase/php-jwt does not support this in decode, and you should never build a shim that does.
Using symmetric secrets that are too short. For HS256, the shared secret should be at least 256 bits of real entropy. A dictionary word signed with HMAC is brute-forceable offline once an attacker has one token.
Storing tokens where JavaScript can read them. This is not a library concern but a deployment one. A token in localStorage is exposed to any XSS on the page. Prefer an HttpOnly cookie for browser sessions.
FAQ
Is firebase/php-jwt still maintained?
Yes. It is maintained under the Firebase organization on GitHub and remains the standard PHP JWT library. The 6.x line is current and receives updates, and you should stay on the latest 6.x release.
Which version fixes the algorithm-confusion vulnerability?
Version 6.0.0 and later. The fix was structural: JWT::decode() now requires a Key object that binds each key to a single algorithm, which removes the ambiguity that CVE-2021-46743 exploited. Anything before 6.0.0 should be considered affected.
Does firebase/php-jwt check token expiration automatically?
Yes. The exp, nbf, and iat claims are validated during decode(), and an expired token raises ExpiredException. Issuer (iss) and audience (aud) claims are not checked automatically, so validate those in your own code.
Can I use the same key for signing and verifying?
Only with symmetric algorithms like HS256, where one shared secret does both. With asymmetric algorithms such as RS256 or ES256 you sign with a private key and verify with the corresponding public key, and you must register the algorithm explicitly through the Key object so a forged alg header cannot switch schemes.