<?php// RFC 7638: the required members of each key type, in lexicographic orderconst REQUIRED = ['EC' => ['crv', 'kty', 'x', 'y'], 'RSA' => ['e', 'kty', 'n'], 'OKP' => ['crv', 'kty', 'x'], 'oct' => ['k', 'kty']]; function base64url(string $bytes): string{ return rtrim(strtr(base64_encode($bytes), '+/', '-_'), '=');} function jwkThumbprint(array $jwk): string{ $members = []; foreach (REQUIRED[$jwk['kty']] as $name) { $members[$name] = $jwk[$name]; } // No escaped slashes, no \u escapes: the members are hashed exactly as RFC 7638 serializes them $canonical = json_encode($members, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR); return base64url(hash('sha256', $canonical, true));} foreach (['ec.jwk.json', 'rsa.jwk.json', 'okp.jwk.json'] as $file) { echo "$file: ", jwkThumbprint(json_decode(file_get_contents($file), true, flags: JSON_THROW_ON_ERROR)), PHP_EOL;}// From a PEM public key: ext-openssl exposes the raw key componentsfunction publicJwk(string $pem): array{ $details = openssl_pkey_get_details(openssl_pkey_get_public($pem)); return match ($details['type']) { OPENSSL_KEYTYPE_EC => [ 'kty' => 'EC', 'crv' => ['prime256v1' => 'P-256', 'secp384r1' => 'P-384', 'secp521r1' => 'P-521'][$details['ec']['curve_name']], // coordinates are fixed-length: left-pad to the curve size (32 bytes for P-256) 'x' => base64url(str_pad($details['ec']['x'], intdiv($details['bits'] + 7, 8), "\0", STR_PAD_LEFT)), 'y' => base64url(str_pad($details['ec']['y'], intdiv($details['bits'] + 7, 8), "\0", STR_PAD_LEFT)), ], OPENSSL_KEYTYPE_RSA => ['kty' => 'RSA', 'n' => base64url($details['rsa']['n']), 'e' => base64url($details['rsa']['e'])], OPENSSL_KEYTYPE_ED25519 => ['kty' => 'OKP', 'crv' => 'Ed25519', 'x' => base64url($details['ed25519']['pub_key'])], };} foreach (['ec.pub.pem', 'rsa.pub.pem', 'okp.pub.pem'] as $file) { echo "$file: ", jwkThumbprint(publicJwk(file_get_contents($file))), PHP_EOL;}How it works
REQUIREDlists the members per key type in lexicographic order, so the array is built already sorted.json_encodewithJSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODEproduces the compact canonical form;hash('sha256', …, true)returns the raw digest.openssl_pkey_get_detailsexposesec.x/ec.y,rsa.n/rsa.eanded25519.pub_keyas binary strings.base64urlmaps+/to-_and strips the padding.
Gotchas
json_encodeescapes/as\/by default. JWK members are base64url and never contain/, but a thumbprint over other members (or a symmetrickcopied from elsewhere) silently changes if the flag is missing.- ext-openssl returns EC coordinates without leading zero bytes;
str_padrestores their fixed length, or about one P-256 or P-384 key in 128, and about three P-521 keys in four, gets a wrong thumbprint. - firebase/php-jwt has no thumbprint function; its
JWK::parseKeyconverts JWKs to keys, not the other way round. hash('sha256', $data)without the third argument returns hex; base64url-encoding that hex string gives a long, wrong value.