composer require firebase/php-jwt:7.2.1 guzzlehttp/guzzle:8.3.0 symfony/cache:8.1.9<?phprequire __DIR__ . '/vendor/autoload.php'; use Firebase\JWT\CachedKeySet;use Firebase\JWT\JWT;use GuzzleHttp\Client;use GuzzleHttp\Psr7\HttpFactory;use Symfony\Component\Cache\Adapter\FilesystemAdapter; // Keys by kid, fetched from the JWKS and cached between requests (PSR-6);// an unknown kid triggers one refetch, rate limited.$keys = new CachedKeySet( 'https://issuer.example.com/.well-known/jwks.json', new Client(), new HttpFactory(), new FilesystemAdapter('jwks'), 3600, // cache lifetime, seconds true, // rate-limit refetches for unknown kids); function verifyAccessToken(string $token, CachedKeySet $keys): stdClass{ // Checks the signature (key by kid, alg from the JWK), exp, nbf and iat $claims = JWT::decode($token, $keys); // php-jwt does not check iss or aud: do it here if (($claims->iss ?? null) !== 'https://issuer.example.com') { throw new UnexpectedValueException('unexpected iss ' . json_encode($claims->iss ?? null, JSON_UNESCAPED_SLASHES)); } if (!in_array('api://orders', (array) ($claims->aud ?? []), true)) { throw new UnexpectedValueException('unexpected aud ' . json_encode($claims->aud ?? null, JSON_UNESCAPED_SLASHES)); } if (!isset($claims->exp)) { throw new UnexpectedValueException('token has no exp'); } return $claims;}foreach (file('tokens.txt', FILE_IGNORE_NEW_LINES) as $line) { [$label, $token] = explode(' ', $line); try { $claims = verifyAccessToken($token, $keys); echo "$label: accepted, sub={$claims->sub}", PHP_EOL; } catch (Exception $e) { echo "$label: rejected: ", get_class($e), ': ', $e->getMessage(), PHP_EOL; }}How it works
CachedKeySettakes the JWKS URL, a PSR-18 HTTP client (Guzzle), a PSR-17 request factory and a PSR-6 cache pool; keys are cached forexpiresAfterseconds.- With
rateLimitset totrue, unknownkidvalues refetch the set at most 10 times per minute, so inventedkidvalues cannot hammer the issuer. JWT::decode($token, $keys)looks up the key bykid, uses the algorithm from the JWK'salg, verifies the signature and checksexp,nbfandiat.issandaudare compared afterwards;(array)accepts both a string and an arrayaud.
Gotchas
- php-jwt never looks at
issoraud: without the explicit checks, the token forapi://billingdecodes successfully for the orders API. - The algorithm comes from the JWK, not the token: a JWKS whose keys lack
algfails withUnexpectedValueException: JWK must contain an "alg" parameter. Pass the expected algorithm asCachedKeySet's last argument (or toJWK::parseKeySet) for such issuers. - An unknown
kidraisesOutOfBoundsException: Key ID not found— not aJWTexception — so catchException(or both types) arounddecode. - Without a persistent cache (a file, APCu, Redis), every PHP request starts empty and fetches the JWKS again.