Skip to content
ecdsa.com

PEM ⇄ JWK · PHP

How to convert a PEM public key to a JWK and back in PHP

In PHP, ext-openssl exposes the coordinates of a PEM public key for building a JWK, and firebase/php-jwt's JWK::parseKey turns a JWK back into a PEM. Because php-jwt assembles that PEM without checking the point, the recipe lets OpenSSL parse it before use. The run checks the RFC 7515 round trip byte for byte.

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

install firebase/php-jwt
composer require firebase/php-jwt:7.2.1
convert.php — PEM to JWK with ext-openssl, JWK to PEM with php-jwt
<?php
require __DIR__ . '/vendor/autoload.php';
use Firebase\JWT\JWK;
function base64url(string $bytes): string
{
return rtrim(strtr(base64_encode($bytes), '+/', '-_'), '=');
}
// PEM -> JWK with ext-openssl (EC coordinates padded to the curve size)
$details = openssl_pkey_get_details(openssl_pkey_get_public(file_get_contents('pub.pem')));
$size = intdiv($details['bits'] + 7, 8);
$jwk = [
'kty' => 'EC',
'crv' => 'P-256',
'x' => base64url(str_pad($details['ec']['x'], $size, "\0", STR_PAD_LEFT)),
'y' => base64url(str_pad($details['ec']['y'], $size, "\0", STR_PAD_LEFT)),
];
file_put_contents('out.jwk.json', json_encode($jwk));
echo json_encode($jwk), PHP_EOL;
// JWK -> PEM with php-jwt: JWK::parseKey assembles a PEM from x and y (getKeyMaterial)
$key = JWK::parseKey(json_decode(file_get_contents('pub.jwk.json'), true), 'ES256');
// php-jwt does not check the point; parsing with OpenSSL rejects one that is not on the curve
$publicKey = openssl_pkey_get_public($key->getKeyMaterial());
if ($publicKey === false) {
throw new UnexpectedValueException('invalid EC public key: ' . openssl_error_string());
}
$pem = openssl_pkey_get_details($publicKey)['key'];
file_put_contents('out.pem', $pem);
echo $pem;

How it works

  1. openssl_pkey_get_details returns ec.x and ec.y as binary strings, plus the key size in bits.
  2. str_pad restores the fixed 32-byte length of each coordinate before base64url encoding.
  3. JWK::parseKey($jwk, 'ES256') returns a Key whose getKeyMaterial() is a PEM string built from x and y; the second argument is the algorithm to assume when the JWK has no alg.
  4. openssl_pkey_get_public parses that PEM and openssl_pkey_get_details(…)['key'] writes it back in OpenSSL's own format.

Gotchas

  • JWK::parseKey does not validate the point: a JWK with an altered y still produces a PEM. Only openssl_pkey_get_public rejects it (error:1E08010C:DECODER routines::unsupported), so do not skip that step.
  • Without the default algorithm, a JWK lacking alg fails: UnexpectedValueException: JWK must contain an "alg" parameter.
  • ext-openssl drops leading zero bytes from EC coordinates; without str_pad, about one key in 128 produces a JWK with a 31-byte x or y, which strict parsers reject.
  • json_encode escapes / as \/ by default. JWK members never contain /, but other fields added to the JWK (such as an x5u URL) would — pass JSON_UNESCAPED_SLASHES.

Related recipes