socialweb/jwk reads, writes, and converts JSON Web Keys (JWKs) and JWK Sets, as published in RFC 7517.
Encrypting traffic with TLS keeps anyone between two applications from reading or changing what passes between them. It protects one connection at a time, though. When a message passes through a gateway, a load balancer, or a proxy that ends one TLS connection and opens another, the message is in the clear at that hop, and the receiver has no way to tell whether it changed there. HTTP Message Signatures close that gap: the sender signs the parts of the message that matter, and the receiver checks the signature with the sender's public key, however many hops lie between them.
JSON Web Keys are how senders publish those keys and receivers fetch them. A
JWK is one key as JSON, and a JWK Set is the document a signer publishes with
its current public keys, at a URL receivers know. OpenID Connect providers and
OAuth2 authorization servers publish one at their jwks_uri, services within
an enterprise that attach signed claims to their messages do the same, and
ActivityPub servers publish a public key for each actor, most often as PEM
text.
This package handles those keys. On the publishing side, it reads the private
key openssl made, keeps the public half, and writes the set. On the
receiving side, it parses a published set, refusing what the RFCs refuse so
that a key object that exists is well formed, finds the key a signature names,
and hands it to a verifier as the PEM text or OpenSSL key handle that library
takes. It converts between PEM and JWK in both directions, so a key in either
form can be published in either.
Signing and verifying belong to the library that takes the key: socialweb/http-message-signatures for HTTP message signatures, or any JWT library that takes a PEM key. This package depends on the OpenSSL extension and on no other package.
This project adheres to a code of conduct. By participating in this project and its community, you are expected to uphold this code.
Install this package as a dependency using Composer.
composer require socialweb/jwkWhen your application receives a signed HTTP message, a request or a response, it needs the sender's public key to check the signature. That means fetching the sender's key set, picking out the key the signature names, and handing that key to your verifier. This package does the middle step. Fetching is your HTTP client's job, and verifying is the signing library's.
JwkSet::fromJson() takes the JSON text of the set, or the text already
decoded with json_decode(). Pass publicOnly: true when the set is someone
else's: a verifier has no use for a private key or a symmetric (oct) key,
and either one in a published set means something has gone wrong, so the
parser refuses them.
A set may hold a key this package cannot use, such as a key type it does not
implement. RFC 7517 says to ignore such a key and use the rest, so the parser
sets it aside with its reason instead of throwing, and skipped() lists what
was set aside. A verifier that wants nothing set aside checks that the list is
empty.
$keyId is the identifier the signature names. A signer may name its key by
kid or by thumbprint, so the example looks up both. findByKeyId() throws
AmbiguousKeyId when two keys share a kid, which RFC 7517 allows for keys
of different types; pass a KeyType as the second argument to choose.
use SocialWeb\Http\MessageSignatures\VerificationKey;
use SocialWeb\Jwk\JwkSet;
// The JWK Set your HTTP client fetched from the sender's site
$json = <<<'JSON'
{
"keys": [
{
"kty": "EC",
"crv": "P-256",
"x": "f83OJ3D2xF1Bg8vub9tLe1gHMzV76e8Tus9uPHvRVEU",
"y": "x_FEzRu9m36HLN_tue659LNpXW6pCyStikYjKIWI5a0",
"kid": "2026-10"
},
{
"kty": "OKP",
"crv": "Ed25519",
"x": "11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo"
},
{
"kty": "AKP",
"kid": "pq-1"
}
]
}
JSON;
$set = JwkSet::fromJson($json, publicOnly: true);
foreach ($set->skipped() as $skipped) {
echo "Skipped key {$skipped->index}: {$skipped->cause->reason->name}\n";
}
$keyId = 'kPrK_qmxVWaYVA9wwBF6Iuo3vVzz7TxHCTwXBygrS4k';
$jwk = $set->findByKeyId($keyId) ?? $set->findByThumbprint($keyId);
if ($jwk === null) {
echo "No usable public key for {$keyId}\n";
exit;
}
echo $jwk->toPem();
$verificationKey = VerificationKey::fromPem($jwk->toPem());This prints:
Skipped key 2: UnsupportedKeyType
-----BEGIN PUBLIC KEY-----
MCowBQYDK2VwAyEA11qYAYKxCrfVS/7TyWQHOg7hcvPapiMlrwIaaPcHURo=
-----END PUBLIC KEY-----
toPem() returns the public key as a SubjectPublicKeyInfo PEM, whatever the
key object holds, which is the form most libraries take. The last line builds
a key for socialweb/http-message-signatures; a JWT library that verifies with
a PEM public key takes the same string, and toOpenSslKey() returns the key
as OpenSSL holds it, for code that calls the OpenSSL functions directly.
Note
The example that follows is for
socialweb/http-message-signatures. A JWT library
has its own way of taking a key; give it the PEM text from toPem(), as the
example above does.
In socialweb/http-message-signatures, that lookup belongs in a KeyResolver.
When its verifier checks a message, it reads the key identifier from the
signature and asks the resolver for the key with that identifier. The resolver
below answers from a set the application fetched earlier. $owner is the
identity the set belongs to, which the verifier reports on a verified result:
use SocialWeb\Http\MessageSignatures\KeyLookup;
use SocialWeb\Http\MessageSignatures\KeyResolver;
use SocialWeb\Http\MessageSignatures\VerificationKey;
use SocialWeb\Jwk\JwkSet;
final class JwkSetKeyResolver implements KeyResolver
{
public function __construct(
private readonly JwkSet $set,
private readonly string $owner,
) {
}
public function resolve(KeyLookup $lookup): ?VerificationKey
{
$jwk = $this->set->findByKeyId($lookup->keyId)
?? $this->set->findByThumbprint($lookup->keyId);
if ($jwk === null) {
return null;
}
return VerificationKey::fromPem($jwk->toPem(), owner: $this->owner);
}
public function refresh(KeyLookup $lookup): ?VerificationKey
{
return $this->resolve($lookup);
}
}
$resolver = new JwkSetKeyResolver($set, 'https://example.com/users/alice');A resolver that fetches the set itself would do so here, and refresh() is
where it fetches again after a failure that a rotated key could explain. The
README of socialweb/http-message-signatures says what the verifier checks
before it asks for a key and where key policy, such as a minimum key size,
belongs.
The key's members are readable after parsing: $jwk->keyId, $jwk->algorithm,
$jwk->use, and $jwk->keyOperations hold the common members, each key class
holds its own members as decoded bytes, and additionalMembers() returns the
members this package does not define.
The other side of the exchange is your own keys. When your application signs the messages it sends, every receiver needs your public key to check them. Sending the key to each receiver ahead of time does not scale, and it breaks the moment you replace the key. So you publish it instead: your current public keys go in a JWK Set at a URL on your domain, and a receiver fetches the set when a signature of yours arrives.
Where that URL is depends on what you speak. An OpenID Connect provider or an
OAuth2 authorization server lists it as jwks_uri in its metadata. For HTTP
message signatures, the receiver's key resolver decides where to look, so pick
a stable URL on your domain and name it in your documentation or in the key
identifier your signatures use. Either way, the response has the media type
application/jwk-set+json.
To build the set, read your private key, keep its public half, label it, and
write the set. Jwk::fromPem() reads the text of a PEM file, such as the one
openssl genpkey writes; "Generating keys" below shows how to make one.
toPublic() returns the key with every private member removed. The with
methods set the common members of RFC 7517, each returning a new key.
JwkSet::toJson() writes the set with the members of each key in the order
the RFCs list them.
The labels are for the receiver. kid is the name a signature uses to say
which key signed it, so it must be unique within the set; the thumbprint is a
good choice, and RFC 7638 suggests it as one. use says what the key is for,
and alg which algorithm it signs with. The example publishes a current
Ed25519 key beside an older RSA key, so that a message signed with either
still verifies while receivers move to the new one.
use SocialWeb\Jwk\Jwk;
use SocialWeb\Jwk\JwkSet;
use SocialWeb\Jwk\KeyUse;
$signing = Jwk::fromPem(file_get_contents('/etc/example/keys/ed25519.pem'));
$legacy = Jwk::fromPem(file_get_contents('/etc/example/keys/rsa.pem'));
$set = new JwkSet([
$signing->toPublic()
->withKeyId($signing->thumbprint())
->withAlgorithm('Ed25519')
->withUse(KeyUse::Signature),
$legacy->toPublic()
->withKeyId('2026-10')
->withAlgorithm('RS256')
->withUse(KeyUse::Signature),
]);
echo $set->toJson(), "\n";This prints the set on one line. With whitespace added and the RSA modulus shortened, it looks like this:
{
"keys": [
{
"kty": "OKP",
"crv": "Ed25519",
"x": "HdIujjJCS1emHacaH_QNs_RVOSo3_S1-KBTvUpAuusg",
"use": "sig",
"alg": "Ed25519",
"kid": "dbI0N0QpamDhcInuOQDRKbve8kP_fUH-3udA4nVZeBQ"
},
{
"kty": "RSA",
"n": "kufUKmyuBF6zl82CXxYx7JsgB_HrCCFClTFlMBPLMWf6jXK_8wMBknrZ...",
"e": "AQAB",
"use": "sig",
"alg": "RS256",
"kid": "2026-10"
}
]
}In a framework whose controllers return PSR-7 responses, the controller that
serves the set writes that text to the response body and sets the media type.
$set is the set built above, and $responseFactory is any PSR-17 response
factory:
use Psr\Http\Message\ResponseFactoryInterface;
use Psr\Http\Message\ResponseInterface;
use SocialWeb\Jwk\JwkSet;
final class JwkSetController
{
public function __construct(
private readonly JwkSet $set,
private readonly ResponseFactoryInterface $responseFactory,
) {
}
public function __invoke(): ResponseInterface
{
$response = $this->responseFactory
->createResponse(200)
->withHeader('Content-Type', 'application/jwk-set+json');
$response->getBody()->write($this->set->toJson());
return $response;
}
}
$controller = new JwkSetController($set, $responseFactory);Build the set once, when the application starts, rather than reading the key
files on every request. A private key writes its private members, so call
toPublic() before publishing, as the example does.
Ed25519 is the algorithm name RFC 9864 registers for signing with
an Ed25519 key; EdDSA, the older name from RFC 8037, is deprecated by that
RFC and still accepted by the parser, since this package reads what is sent.
This package sets no alg on its own.
A thumbprint is a name for a key that comes from the key itself. Two parties
that hold the same public key compute the same thumbprint, whatever kid each
gave it, so it works as a kid when you publish and as a lookup when a
signature names a key that way. thumbprint() computes the JWK Thumbprint of
RFC 7638, SHA-256 by default, and thumbprintUri() the URI form of RFC 9278.
A private key gives the thumbprint of its public form. The key below is the
Ed25519 public key of RFC 8037 appendix A.2, whose thumbprint appendix A.3
computes.
use SocialWeb\Jwk\Jwk;
$jwk = Jwk::fromJson(<<<'JSON'
{
"kty": "OKP",
"crv": "Ed25519",
"x": "11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo"
}
JSON);
echo $jwk->thumbprint(), "\n";
echo $jwk->thumbprintUri(), "\n";This prints:
kPrK_qmxVWaYVA9wwBF6Iuo3vVzz7TxHCTwXBygrS4k
urn:ietf:params:oauth:jwk-thumbprint:sha-256:kPrK_qmxVWaYVA9wwBF6Iuo3vVzz7TxHCTwXBygrS4k
The thumbprint is computed from the parsed values, so whitespace and member
order in the input do not change it. HashAlgorithm::Sha384 and Sha512 are
accepted as the argument of both methods.
Warning
The thumbprint of an oct key is a hash of the key value itself and reveals
information about the key, as RFC 7638 section 7 warns. Compute one only
where the key is strong enough that the hash gives nothing away.
Keys reach an application in several forms: a file openssl wrote, a key
another library already holds, a JWK in a request, or a published set.
Jwk::fromPem() reads every form openssl writes: a PKCS #8 private key,
encrypted or not, a PKCS #1 RSA PRIVATE KEY, a SEC 1 EC PRIVATE KEY, a
SubjectPublicKeyInfo PUBLIC KEY, a PKCS #1 RSA PUBLIC KEY, and a
certificate, whose public key is read. Jwk::fromOpenSslKey() reads a key
your code already holds as an OpenSSLAsymmetricKey.
- A key is given as PEM text, never as a path. Read the file yourself, as the
examples do; a string that begins with
file://is refused. - An encrypted PEM needs its passphrase as the second argument:
Jwk::fromPem($pem, $passphrase). Without it, or with the wrong one, the key is refused asConversionFailedwith the reasonUnreadablePem. A text that holds both a certificate and a private key that cannot be decrypted is refused the same way, not read for its certificate. - A PKCS #8 private key that includes its public key, the version 2 form of
RFC 5958, is read with OpenSSL 3.5.0 or later; an older OpenSSL refuses it
as
UnreadablePem.openssl genpkeywrites the version 1 form, without the public key, which every OpenSSL reads. - A DSA or DH key, an EC key on a curve this package does not implement, and
an RSA key under the id-RSASSA-PSS algorithm identifier are refused as
ConversionFailedwith the reasonUnsupportedKey.
Jwk::fromJson() and JwkSet::fromJson() take JSON text, or the text already
decoded with json_decode(), with or without associative: true. When a JSON
object has two members with one name, PHP's parser keeps the lexically last,
and so does this package, in a JWK and in a JWK Set alike; RFC 7517 allows
either that or a refusal.
A refusal names what was wrong. Every one is InvalidJwk with an
InvalidJwkReason case and, when one member is at fault, its name, or
InvalidJwkSet for a set object that is not well formed. Every exception the
package throws implements SocialWeb\Jwk\Exception\JwkException, so one
catch covers them all.
toPem() writes the public key as a SubjectPublicKeyInfo PEM.
toPrivatePem() writes the private key as PKCS #8, unencrypted, or encrypted
with AES-256-CBC when a passphrase is given: $jwk->toPrivatePem($passphrase).
An RSA private key whose private members are d alone cannot be written as
PKCS #8, which holds the primes, and is refused as ConversionFailed with the
reason MissingCrtMembers; toOpenSslKey() still returns such a key, and it
signs. An oct key has no PEM form.
var_dump() and print_r() show a key with its private members redacted, and
serialize() refuses a key. var_export() and an (array) cast bypass that
and show the private members; do not log a key through either. The JSON form
from toJson() is the serialization, and fromJson() the way back.
This package never generates a key. openssl genpkey does, and this package
reads what it writes. Each command below makes one private key of a different
type; run the one for the algorithm you sign with, and ignore the others. An
RSA key signs with RS256 and the other RSA algorithms, an EC key on P-256
with ES256, and an Ed25519 key with Ed25519. One key is enough; the
publishing example holds two only to show a key being replaced.
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out rsa.pem
openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 -out p256.pem
openssl genpkey -algorithm Ed25519 -out ed25519.pemEach command writes an unencrypted PKCS #8 private key. Keep the file outside
the web root, readable only by the user your application runs as. Add
-aes-256-cbc to encrypt it with a passphrase that openssl asks for;
fromPem() then takes the passphrase as its second argument. Give the
passphrase to your application through an environment variable, not in its
code or configuration files. The public form to publish comes from fromPem()
and toPublic():
use SocialWeb\Jwk\Jwk;
$privateKey = Jwk::fromPem(file_get_contents('/etc/example/keys/ed25519.pem'));
echo $privateKey->toPublic()->toJson(), "\n";This prints a key like this:
{"kty":"OKP","crv":"Ed25519","x":"HdIujjJCS1emHacaH_QNs_RVOSo3_S1-KBTvUpAuusg"}- The JWK and JWK Set objects and their common members,
use,key_ops,alg,kid,x5u,x5c,x5t, andx5t#S256(RFC 7517). - The
RSA,EC, andoctkey types of RFC 7518 section 6, theOKPkey type of RFC 8037 onEd25519,Ed448,X25519, andX448, and thesecp256k1curve of RFC 8812. - The JWK Thumbprint of RFC 7638 and the thumbprint URI of RFC 9278.
- Conversion between a JWK and the PEM forms OpenSSL reads and writes, in both directions, and to and from an OpenSSL key handle.
A private key is checked against its public members, and members this package does not define are kept and written back, as RFC 7517 requires.
Left out, by design: fetching a JWK Set or the certificates behind x5u,
generating keys, encrypted JWKs and JWK Sets (JWE), signing, verifying, or
encrypting with a key, validating a certificate chain, a policy on key sizes
and algorithms, and choosing a key from a set by use, alg, or key_ops.
Contributions are welcome! To contribute, please familiarize yourself with CONTRIBUTING.md.
Keeping user information safe and secure is a top priority, and we welcome the contribution of external security researchers. If you believe you've found a security issue in software that is maintained in this repository, please read SECURITY.md for instructions on submitting a vulnerability report.
socialweb/jwk is copyright © socialweb/jwk Contributors and licensed for use under the terms of the GNU Lesser General Public License (LGPL-3.0-or-later) as published by the Free Software Foundation. Please see COPYING.LESSER, COPYING, and NOTICE for more information.