Skip to content
ecdsa.com

Verify an ES256 JWT · PHP

How to verify an ES256 JWT in PHP

To verify an ES256 JWT in PHP, use firebase/php-jwt: JWT::decode with a Key bound to the public key PEM and the algorithm 'ES256' checks the signature, exp, nbf and iat in one call. Internally it converts the token's raw r‖s signature to DER for ext-openssl — the step that hand-written PHP verifiers most often get wrong — but issuer and audience checks remain your code.

Tested with firebase/php-jwt 7.2.1 (PHP 8.5.11, OpenSSL 3.5.7, Composer 2.10.3).

install the library with Composer
composer require firebase/php-jwt:7.2.1
verify.php — ES256 pinned through the Key, claims checked
<?php
require __DIR__ . '/vendor/autoload.php';
use Firebase\JWT\JWT;
use Firebase\JWT\Key;
JWT::$leeway = 30; // seconds of clock skew allowed for exp, nbf and iat (default 0)
// The Key binds the PEM to ES256: a token whose header says anything else is rejected
$key = new Key(file_get_contents('pub.pem'), 'ES256');
$claims = JWT::decode(trim(file_get_contents('token.txt')), $key); // signature, exp, nbf, iat
// php-jwt does not check iss or aud: that is the caller's job
if (($claims->iss ?? null) !== 'https://ecdsa.com') {
throw new UnexpectedValueException('unexpected issuer');
}
echo "valid ES256 token for {$claims->sub}, expires ", gmdate('c', $claims->exp), "\n";

How it works

  1. new Key($pem, 'ES256') binds the public key to one algorithm. A token whose header names another algorithm fails with UnexpectedValueException: Incorrect key for this algorithm before the signature is checked.
  2. JWT::decode splits the token, verifies the signature over the header.payload text (re-encoding the 64-byte r‖s as DER for openssl_verify) and returns the payload as a stdClass.
  3. It throws SignatureInvalidException for a bad signature, ExpiredException after exp, and BeforeValidException before nbf (or a future iat); the run triggers the first three cases.
  4. JWT::$leeway is a static setting for clock skew, 0 by default; set it once at bootstrap rather than per call.

Gotchas

  • php-jwt never looks at iss or aud. Compare them yourself after decode, as the snippet does for the issuer — otherwise a valid token from another issuer that shares the key, or meant for another API, is accepted.
  • A token without exp passes: the library checks expiry only when the claim exists. Require exp explicitly if your tokens must expire.
  • Code written for php-jwt 5 passes the PEM as a plain string. Version 7 then fails with the misleading "kid" empty, unable to lookup correct key — the fix is not a kid but wrapping the PEM in new Key($pem, 'ES256'), which is also what prevents algorithm confusion.
  • For keys published as a JWKS, use JWK::parseKeySet (or CachedKeySet) and let the token's kid select the key; a mismatch raises the kid error rather than a signature error.

Related recipes