Skip to content
ecdsa.com

Verify a JWT with JWKS · PHP

How to verify a JWT with a JWKS in PHP

To verify a JWT against a JWKS in PHP, firebase/php-jwt's CachedKeySet fetches the issuer's key set through any PSR-18 client and PSR-6 cache, and JWT::decode picks the key by kid and checks the signature, exp, nbf and iat. Issuer and audience are not checked by the library, so the recipe checks them explicitly. The run checks five tokens.

Tested with firebase/php-jwt 7.2.1 (PHP 8.5.11, Composer 2.10.3) and guzzlehttp/guzzle 8.3.0 (with symfony/cache 8.1.9 as the PSR-6 pool).

install php-jwt, an HTTP client and a cache
composer require firebase/php-jwt:7.2.1 guzzlehttp/guzzle:8.3.0 symfony/cache:8.1.9
verify.php — CachedKeySet for the keys, explicit iss and aud checks
<?php
require __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;
}
check every token in tokens.txt
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

  1. CachedKeySet takes the JWKS URL, a PSR-18 HTTP client (Guzzle), a PSR-17 request factory and a PSR-6 cache pool; keys are cached for expiresAfter seconds.
  2. With rateLimit set to true, unknown kid values refetch the set at most 10 times per minute, so invented kid values cannot hammer the issuer.
  3. JWT::decode($token, $keys) looks up the key by kid, uses the algorithm from the JWK's alg, verifies the signature and checks exp, nbf and iat.
  4. iss and aud are compared afterwards; (array) accepts both a string and an array aud.

Gotchas

  • php-jwt never looks at iss or aud: without the explicit checks, the token for api://billing decodes successfully for the orders API.
  • The algorithm comes from the JWK, not the token: a JWKS whose keys lack alg fails with UnexpectedValueException: JWK must contain an "alg" parameter. Pass the expected algorithm as CachedKeySet's last argument (or to JWK::parseKeySet) for such issuers.
  • An unknown kid raises OutOfBoundsException: Key ID not found — not a JWT exception — so catch Exception (or both types) around decode.
  • Without a persistent cache (a file, APCu, Redis), every PHP request starts empty and fetches the JWKS again.

Related recipes