Skip to content
ecdsa.com

JWK thumbprint · PHP

How to compute a JWK thumbprint (RFC 7638) in PHP

PHP computes a JWK thumbprint with json_encode and hash: pick the required members, encode them with JSON_UNESCAPED_SLASHES, hash with SHA-256 and base64url-encode. ext-openssl provides the components of PEM keys, including Ed25519 since PHP 8.4. The results match the published RFC 7638 and RFC 8037 thumbprints.

Tested with PHP 8.5.11 (ext-json, ext-hash, ext-openssl (OpenSSL 3.5.7)).

jwkThumbprint — RFC 7638 for EC, RSA, OKP and oct keys
<?php
// RFC 7638: the required members of each key type, in lexicographic order
const 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;
}
build the public JWK of a PEM key with ext-openssl, then take its thumbprint
// From a PEM public key: ext-openssl exposes the raw key components
function 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

  1. REQUIRED lists the members per key type in lexicographic order, so the array is built already sorted.
  2. json_encode with JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE produces the compact canonical form; hash('sha256', …, true) returns the raw digest.
  3. openssl_pkey_get_details exposes ec.x/ec.y, rsa.n/rsa.e and ed25519.pub_key as binary strings.
  4. base64url maps +/ to -_ and strips the padding.

Gotchas

  • json_encode escapes / as \/ by default. JWK members are base64url and never contain /, but a thumbprint over other members (or a symmetric k copied from elsewhere) silently changes if the flag is missing.
  • ext-openssl returns EC coordinates without leading zero bytes; str_pad restores 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::parseKey converts 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.

Related recipes