From v4.2 to v4.3¶
This is a minor version upgrade: your code keeps working. Version 4.3 introduces no breaking change, but it is the version where 5.0 is prepared, so it carries a lot of deprecations. Each of them names its replacement, and the summary table at the end lists them all.
Two algorithms move to their own packages. If your application enables none or RSA1_5, this is the only change that needs an entry in your composer.json — see Dangerous algorithms.
Coming from an earlier version, read the previous guides first: From v4.1 to v4.2, then From v4.0 to v4.1.
New features¶
Every service has an interface¶
None of the main services was final and none of them had an interface: they could neither be relied upon as closed, nor be decorated.
Every service now implements an interface — JWSBuilderInterface, JWSVerifierInterface, JWSLoaderInterface, JWEBuilderInterface, JWEDecrypterInterface, JWELoaderInterface, NestedTokenBuilderInterface, NestedTokenLoaderInterface, ClaimCheckerManagerInterface and HeaderCheckerManagerInterface — and the library type-hints those interfaces internally, so a decorator can be injected anywhere a service is expected.
<?php
use Jose\Component\Signature\JWSLoaderInterface;
final readonly class MyTokenReader
{
public function __construct(
private JWSLoaderInterface $jwsLoader, // Instead of the concrete JWSLoader
) {
}
}
In the Symfony Bundle, each service is now aliased with its interface too, so JWSBuilderInterface $myJwsBuilder is autowired exactly as JWSBuilder $myJwsBuilder already was.
Extending the concrete classes is deprecated. They carry the @final annotation and their constructor raises a deprecation notice. Implement the interface or decorate the service instead.
Result objects instead of output parameters¶
The verifiers, the decrypters, the loaders and the serializer managers used to write their secondary results — the key that verified a signature, the index of the recipient that could be decrypted, the name of the serializer — into variables of the caller. Such methods cannot be called with named arguments, cannot be composed and are hard to type for static analysers.
Readonly result objects carry those values now:
| New method | Returns |
|---|---|
JWSVerifier::verify() |
VerificationResult — isVerified(), getSignatureIndex(), getKey() |
JWEDecrypter::decrypt() |
DecryptionResult — isDecrypted(), getJwe(), getRecipientIndex(), getKey() |
JWSLoader::loadAndVerify() |
LoadingResult — getJws(), getSignatureIndex(), getKey() |
JWELoader::loadAndDecrypt() |
LoadingResult — getJwe(), getRecipientIndex(), getKey() |
NestedTokenLoader::loadAndVerify() |
LoadingResult |
JWSSerializerManager::unserializeToken() |
UnserializationResult — getJws(), getSerializerName() |
JWESerializerManager::unserializeToken() |
UnserializationResult — getJwe(), getSerializerName() |
Before:
$jws = $jwsLoader->loadAndVerifyWithKeySet($token, $jwkset, $signature);
// $signature has been written into by the loader
After:
$result = $jwsLoader->loadAndVerify($token, $jwkset);
$jws = $result->getJws();
$signature = $result->getSignatureIndex();
$key = $result->getKey(); // The key that verified the signature
The new methods accept a JWK or a JWKSet, so the *WithKey()/*WithKeySet() pairs collapse into one method. The callable that observes the keys discarded along the way — introduced in 4.2 and read with func_get_arg() until now — is a declared argument:
$result = $jwsVerifier->verify($jws, $jwkset, 0, null, static function (Throwable $e): void {
// Called for each key that could not verify the signature
});
JWEDecrypter::decrypt() also fixes a long standing oddity: it leaves the JWE it is given untouched and carries the decrypted one in the result, instead of writing the payload into the object by reference.
$result = $jweDecrypter->decrypt($jwe, $jwkset, 0);
if ($result->isDecrypted()) {
$payload = $result->getJwe()->getPayload(); // $jwe itself is unchanged
}
Info
The algorithm interfaces and TokenTypeSupport cannot change without breaking every third-party implementation, so 4.3 only documents the signature they will have in 5.0 and ships the objects they will return. See Announced For 5.0.
The builders are really immutable¶
The builders advertised an immutable with*() API they did not honour: they accumulated state on the receiver, which is why create() existed — not as a named constructor, but as a reset.
Every accumulation method now clones first and never touches the receiver, and every check spanning several calls is performed by build(), so the order of the calls no longer matters:
JWSBuilder::addSignature()no longer pins the payload encoding on the receiver. Theb64consistency checks are read from the signatures bybuild().JWEBuilder::addRecipient()no longer resolves the key and content encryption algorithms. They are read from the complete header of each recipient bybuild(), so a recipient can be added before the shared headers carrying itsalgandenc.- The disjoint header requirement of RFC 7516 is verified for the three headers by
build(), whatever the order in which they were set.
As a consequence, a builder can be reused safely:
$base = (new JWSBuilder($algorithmManager))->withPayload($payload);
$first = $base->addSignature($key1, ['alg' => 'HS256'])->build();
$second = $base->addSignature($key2, ['alg' => 'RS256'])->build(); // $base is untouched
create() is deprecated: there is no state to reset any more. Remove the call.
Immutable managers¶
A manager expresses a policy — these algorithms and no others — so a consumer must not be able to widen it. AlgorithmManager::add() mutated a service that is usually shared; it is deprecated in favour of with(), which returns a new manager and leaves the current one untouched:
The state of the checker and serializer managers is now readonly, and both serializer managers are readonly classes.
Typed accessors on the JWK¶
Reading a parameter meant writing the same three steps every time: has(), then get(), then a type assertion, because get() returns an undeclared mixed and throws when the parameter is absent.
$jwk->kty(); // string — always present
$jwk->alg(); // ?string — null when the key is not restricted to an algorithm
$jwk->use(); // ?string — null when the key is not restricted to a usage
$jwk->getString('kid'); // string — asserts the parameter is a string
$jwk->find('kid'); // mixed — null instead of throwing when absent
JWK and JWKSet announce that they become final readonly in 5.0. JWKSet::sortKeys() was public only because the comparison was passed to usort() as a callable; it is a closure now and calling the method is deprecated. Conversely, getIterator() lost the @internal annotation that wrongly told users not to iterate over a key set.
The key factory is an injectable service¶
JWKFactory was a bag of sixteen static methods, half of which reach for OpenSSL, for sodium or for the filesystem. Application code generating or loading keys could not be unit tested without any of them, and the factory could not be decorated to audit, cache or delegate the generation to a hardware backed implementation.
The behaviour now lives in instance methods behind JWKFactoryInterface. The names lose the create prefix, which was only there because the methods were static:
| Static method (deprecated) | Instance method |
|---|---|
JWKFactory::createRSAKey() |
rsa() |
JWKFactory::createECKey() |
ec() |
JWKFactory::createOctKey() |
oct() |
JWKFactory::createOKPKey() |
okp() |
JWKFactory::createNoneKey() |
none() |
JWKFactory::createFromJsonObject() |
fromJsonObject() |
JWKFactory::createFromValues() |
fromValues() |
JWKFactory::createFromSecret() |
fromSecret() |
JWKFactory::createFromCertificateFile() |
fromCertificateFile() |
JWKFactory::createFromCertificate() |
fromCertificate() |
JWKFactory::createFromX509Resource() |
fromX509Resource() |
JWKFactory::createFromX5C() |
fromX5C() |
JWKFactory::createFromKeyFile() |
fromKeyFile() |
JWKFactory::createFromKey() |
fromKey() |
JWKFactory::createFromPKCS12CertificateFile() |
fromPKCS12CertificateFile() |
JWKFactory::createFromKeySet() |
fromKeySet() |
<?php
use Jose\Component\KeyManagement\JWKFactoryInterface;
final readonly class MyKeyProvider
{
public function __construct(
private JWKFactoryInterface $jwkFactory,
) {
}
public function newSigningKey(): JWK
{
return $this->jwkFactory->ec('P-256', ['use' => 'sig', 'alg' => 'ES256']);
}
}
The bundle registers JWKFactory — it already did — and now aliases it with its interface, so JWKFactoryInterface $factory is autowired. The static methods keep working and delegate to a default instance.
A JOSE exception hierarchy¶
Every exception thrown by the library implements the Jose\Component\Core\Exception\JoseException marker interface, so a single catch block handles any failure reported by the framework:
<?php
use Jose\Component\Core\Exception\JoseException;
try {
$result = $jwsLoader->loadAndVerify($token, $jwkset);
} catch (JoseException $throwable) {
// The cause, when there is one, is available through $throwable->getPrevious()
}
Each new exception extends the SPL class that was thrown at that place before, hence the existing catch blocks keep matching. The Checker exceptions join the hierarchy as well.
Two failures gained a precise type: both loaders report a token that cannot be loaded with InvalidTokenException, where the JWS loader used to throw a bare Exception and the JWE loader a RuntimeException.
Sealed internal mutators¶
JWS::addSignature() and JWE::withPayload() were public only because the objects are not built in one go — the first is what the builder and the serializers use to append a signature, the second is how the decrypter injects the plaintext it just authenticated. Both were reachable by anybody holding a token, so a JWS returned by a loader could gain a signature that was never verified, and the payload of a decrypted JWE could be replaced by an arbitrary value.
Calling either method from outside the library now raises a deprecation notice; they are removed in 5.0, where the objects are built by their constructor. A custom JWS serializer is a supported extension point and keeps working.
Sealed value objects¶
JWK, JWKSet, JWS, JWE and Signature were documented as becoming final readonly in 5.0, but only in prose: unlike the services, they carried no @final annotation and raised nothing when they were extended. They now do both.
The notice is not the one the services raise. A value object has no interface to decorate in place of the inheritance: it carries no behaviour to change, and the state a subclass adds to it cannot survive the constructor becoming the only way to populate it.
What to do: build the object with its constructor, and keep the state your subclass adds in the object of yours that uses it.
// Instead of extending JWK to carry your own metadata:
final readonly class ManagedKey
{
public function __construct(
public JWK $key,
public DateTimeImmutable $rotatedAt,
) {
}
}
ES256K leaves the experimental package¶
ES256K (ECDSA over secp256k1, RFC 8812) has been a standard algorithm since 2020 but was shipped in the experimental package. It is now Jose\Component\Signature\Algorithm\ES256K, registered by the bundle under the same ES256K alias, with an ES256KKeyAnalyzer for its keys.
The experimental class is kept until 5.0 as a deprecated subclass of the new one — instantiating it raises a deprecation — and the bundle keeps its service, deprecated too, for the applications that inject it. Nothing changes for the tokens.
ML-DSA, the first post-quantum signature¶
RFC 9964 registers ML-DSA (FIPS 204) for JOSE: the ML-DSA-44, ML-DSA-65 and ML-DSA-87 algorithms and the AKP key type. The library ships all three, over keys whose priv is the 32-byte seed of FIPS 204 — the only private key representation the RFC allows — and whose alg is required.
<?php
use Jose\Component\Core\AlgorithmManager;
use Jose\Component\KeyManagement\JWKFactory;
use Jose\Component\Signature\Algorithm\MLDSA65;
$key = (new JWKFactory())->mldsa('ML-DSA-65'); // A fresh key
$key = (new JWKFactory())->mldsa('ML-DSA-65', ['priv' => $seed]); // Rebuilt from its stored seed
$algorithmManager = new AlgorithmManager([new MLDSA65()]);
JWK::thumbprint() computes the RFC 9964 section 6 thumbprint of an AKP key (over alg, kty and pub), toPublic() strips priv, the key loader reads the RFC 9881 PEM forms and the certificates holding such a key, a MLDSAKeyAnalyzer checks the key structure, and key:generate:mldsa / keyset:generate:mldsa generate keys.
The computation is OpenSSL's and needs PHP 8.4 and an OpenSSL 3.5 runtime, checked at runtime: MLDSA44::isSupported() tells, the constructors throw a MissingDependencyException naming the missing piece, and the Symfony Bundle registers the algorithms only when they can run. See the signature algorithms page.
Ed25519, Ed448 and X448¶
RFC 9864 registers the fully-specified Ed25519 and Ed448 signature algorithms and deprecates the polymorphic EdDSA: the name of the algorithm alone must say which curve is in use. The library ships both, and EdDSA is deprecated. The keys are unchanged — an OKP key with crv: Ed25519 or crv: Ed448 — only the alg value differs.
<?php
use Jose\Component\Core\AlgorithmManager;
use Jose\Component\Signature\Algorithm\Ed25519;
use Jose\Component\Signature\Algorithm\Ed448;
$algorithmManager = new AlgorithmManager([new Ed25519(), new Ed448()]);
The same release closes the RFC 8037 gap on the 448 curves: Ed448 keys sign and verify, X448 keys work with every ECDH-ES* and ECDH-SS* algorithm, JWKFactory::okp() and key:generate:okp generate both, and a new OKPKeyAnalyzer checks the structure of the OKP keys.
Two platform notes:
Ed448andX448run on OpenSSL and need PHP 8.4 or later: before that version PHP cannot ask OpenSSL for the digest-less signature or the raw key material of these curves.Ed448::isSupported()tells whether the platform can run the algorithm, its constructor throws aMissingDependencyExceptionwhen it cannot, and the Symfony Bundle registers it only when it can run.Ed25519andX25519still use thesodiumextension when it is loaded. On PHP 8.4 they now fall back to OpenSSL when it is not, sosodiumis no longer required there.
Migrating from EdDSA — the algorithm keeps working, the tokens already in circulation still verify, and signing with it raises a deprecation notice. Register Ed25519 next to EdDSA on the verifiers, switch the issuer to alg: Ed25519 with the same key, then drop EdDSA once no old token is left. A key carrying alg: EdDSA is refused by the Ed25519 algorithm (and the reverse), so update the alg of the keys with the issuer, or leave it out during the migration. See the signature algorithms page.
JWK thumbprint URI¶
RFC 9278 names a key by its RFC 7638 thumbprint: urn:ietf:params:oauth:jwk-thumbprint:sha-256:NzbLsXh8uDCcd-6MNwXF4W_7noWXFZAfHkxZsRGC9Xs. It is the key-based sub or kid of OAuth DPoP, SIOP v2, OpenID for Verifiable Credentials and OpenID Federation, and every implementer had to build it by hand.
<?php
use Jose\Component\Core\JwkThumbprintUri;
$jwk->thumbprintUri(); // The URI, "sha-256" by default
$jwk->thumbprintUri('sha-512'); // The hash function is given by its IANA name
JwkThumbprintUri::parse($sub)->matches($jwk); // Verifier side: does this key match the URI?
$jwkset->selectKeyByThumbprintUri($sub); // Or find the key in a key set; null if none matches
The console command key:thumbprint gains a --uri option. See the Key page.
Explicit typing with the typ header¶
RFC 8725 section 3.11 recommends that a verifier accepts only the token profile it expects — at+jwt, dpop+jwt, secevent+jwt, logout+jwt… — so that a token issued for one purpose cannot be replayed for another one. The library had checkers for alg, exp, iss or aud, but none for typ.
The new Jose\Component\Checker\TypeChecker closes the gap. It only reads the protected header and compares the media types as RFC 7515 defines them: case-insensitively, with an optional application/ prefix.
<?php
use Jose\Component\Checker\TypeChecker;
$headerCheckerManagerFactory->add('typ', new TypeChecker(['at+jwt']));
$headerCheckerManager = $headerCheckerManagerFactory->create(['alg', 'typ']);
$headerCheckerManager->check($jws, 0, ['alg', 'typ']); // "typ" is mandatory
In the Symfony Bundle, a header checker manager declares the accepted types directly:
The ConfigurationHelper::addHeaderChecker() helper accepts the same list as an additional argument after the tags. See the Header Checker page.
JKUFactory and X5UFactory stand on their own¶
UrlKeySetFactory is announced for removal in 5.0, yet JKUFactory and X5UFactory — the documented way to load a jku or a x5u key set, and not deprecated themselves — extended it.
The fetching code moved to an internal trait the two factories use, so they no longer inherit anything from the deprecated class and neither of them emits a deprecation. UrlKeySetFactory now says what is deprecated — itself, as a public extension point — and extending it raises a notice.
enabledCache(), deprecated by annotation since 4.1, raises the notice now and states its replacement: an HTTP client that caches the responses, which honours the cache directives of the endpoint where the fixed lifetime of a cache pool ignores them.
Announced for 5.0¶
Some interfaces of the library are implemented by third-party code, so they cannot change without breaking it. 4.3 is the last release able to give notice, so it documents the signature they will have in 5.0 and ships the objects they will return, ready to be used.
None of these interfaces changes in 4.3: nothing to do today, unless you maintain an implementation.
Encryption algorithms¶
The key management algorithms write the header parameters they need to add to the token — epk, iv, tag, p2s… — into an array of the caller. In 5.0 they return a WrappedKey carrying both the key and those parameters:
| Method | 5.0 signature |
|---|---|
KeyEncryption::encryptKey() |
encryptKey(JWK $key, string $cek, array $completeHeader): WrappedKey |
KeyWrapping::wrapKey() |
wrapKey(JWK $key, string $cek, array $completeHeader): WrappedKey |
KeyAgreement::getAgreementKey() |
getAgreementKey(int $encryptionKeyLength, string $algorithm, JWK $recipientKey, ?JWK $senderKey, array $completeHeader): WrappedKey |
KeyAgreementWithKeyWrapping::wrapAgreementKey() |
wrapAgreementKey(JWK $recipientKey, ?JWK $senderKey, string $cek, int $encryptionKeyLength, array $completeHeader): WrappedKey |
use Jose\Component\Encryption\Algorithm\KeyEncryption\WrappedKey;
$wrapped = new WrappedKey($encryptedCek, ['iv' => $iv, 'tag' => $tag]);
$wrapped->getKey();
$wrapped->getAdditionalHeader();
The content encryption algorithms do the same with their authentication tag. ContentEncryptionAlgorithm::encryptContent() returns an EncryptedContent in 5.0 and loses its ?string &$tag parameter:
use Jose\Component\Encryption\Algorithm\EncryptedContent;
$encrypted = new EncryptedContent($ciphertext, $tag);
$encrypted->getCiphertext();
$encrypted->getTag(); // null when the algorithm computes none
decryptKey() and unwrapKey() gain their fourth argument, the expected CEK size introduced in 4.2, as a declared and required parameter in the same release.
Token type supports¶
TokenTypeSupport::retrieveTokenHeaders() is the last public interface of the library built entirely on output parameters: it writes the two headers into variables of the caller and returns void. In 5.0 it returns a TokenHeaders:
// 4.3
public function retrieveTokenHeaders(JWT $jwt, int $index, array &$protectedHeader, array &$unprotectedHeader): void;
// 5.0
public function retrieveTokenHeaders(JWT $jwt, int $index): TokenHeaders;
use Jose\Component\Checker\TokenHeaders;
$headers = new TokenHeaders($protectedHeader, $unprotectedHeader);
$headers->getProtectedHeader();
$headers->getUnprotectedHeader();
A support given a token it does not handle returns an object carrying two empty headers, which is what it does today by leaving the two output parameters untouched.
Behaviour changes¶
Nothing that requires a change in your code. Two points are worth knowing:
JWEDecrypter::decrypt()does not modify the JWE it receives. The deprecateddecryptUsingKey()/decryptUsingKeySet()still write the payload into the object passed by reference, so both behaviours coexist until 5.0.- The builders no longer depend on the order of the calls. Sequences that used to fail — adding a recipient before the shared protected header carrying its
alg, or reusing a shared builder service after ab64build — now succeed. Nothing that worked before stops working.
An unrelated misconfiguration is now reported early: passing an algorithm that is neither a key encryption nor a content encryption algorithm to JWEBuilder or JWEDecrypter is deprecated, where it used to turn into a much later algorithm not supported error.
Dangerous algorithms move to their own packages¶
The none signature algorithm and the RSA1_5 key encryption algorithm are standard but dangerous: the first disables signature verification altogether, the second uses a padding vulnerable to the Bleichenbacher adaptive chosen-ciphertext attack. Both were shipped in the main library and were as easy to enable as any other algorithm.
They now live in two dedicated packages, so that using them is an explicit and auditable decision:
| Algorithm | Package | Class |
|---|---|---|
none |
web-token/jwt-unsecured |
Jose\Unsecured\Signature\None |
RSA1_5 |
web-token/jwt-rsa15 |
Jose\Rsa15\KeyEncryption\RSA15 |
They are deliberately not moved to web-token/jwt-experimental: they are perfectly standard, and an application asking for Blake2b should not get none in the bargain.
The classes of the library are kept until 5.0 as empty deprecated subclasses of the new ones, so the migration is a composer require and a change of use statement:
composer require web-token/jwt-unsecured # only if you use "none"
composer require web-token/jwt-rsa15 # only if you use "RSA1_5"
-use Jose\Component\Signature\Algorithm\None;
+use Jose\Unsecured\Signature\None;
-use Jose\Component\Encryption\Algorithm\KeyEncryption\RSA15;
+use Jose\Rsa15\KeyEncryption\RSA15;
The Symfony Bundle registers the classes of the new packages, so applications that never enable those algorithms are not flooded with a deprecation they cannot act on. The configuration aliases are unchanged: none and RSA1_5 still name them.
Warning
If you are still using one of these two algorithms, take the opportunity to reconsider. See Security Recommendations.
Deprecation summary¶
Everything below still works in 4.3 and is removed in 5.0.
Services¶
| Deprecated | Replacement |
|---|---|
JWSBuilder::create(), JWEBuilder::create() |
Remove the call — the builders are immutable |
Extending JWSBuilder, JWSVerifier, JWSLoader, JWEBuilder, JWEDecrypter, JWELoader, NestedTokenBuilder, NestedTokenLoader, ClaimCheckerManager, HeaderCheckerManager |
Implement the matching interface, or decorate the service |
JWSVerifier::verifyWithKeySet() |
verify() |
JWEDecrypter::decryptUsingKey(), decryptUsingKeySet() |
decrypt() |
JWSLoader::loadAndVerifyWithKey(), loadAndVerifyWithKeySet() |
loadAndVerify() |
JWELoader::loadAndDecryptWithKey(), loadAndDecryptWithKeySet() |
loadAndDecrypt() |
The $signature argument of NestedTokenLoader::load() |
loadAndVerify() |
The $name argument of JWSSerializerManager::unserialize() and JWESerializerManager::unserialize() |
unserializeToken() |
JWSVerifier::verifyWithKey() is not deprecated: it takes a single key and returns a boolean, with no output parameter.
Managers and factories¶
| Deprecated | Replacement |
|---|---|
AlgorithmManager::add() |
with() |
JWSSerializerManagerFactory::names(), JWESerializerManagerFactory::names() |
aliases() |
Passing a non-encryption algorithm to JWEBuilder or JWEDecrypter |
Remove it from the algorithm manager |
Keys¶
| Deprecated | Replacement |
|---|---|
The sixteen static JWKFactory::createXxx() methods |
Inject JWKFactoryInterface — see the table above |
JWKSet::sortKeys() |
None — it is an implementation detail of selectKey() |
UrlKeySetFactory::enabledCache() |
An HTTP client that caches the responses |
Extending UrlKeySetFactory |
JKUFactory, X5UFactory, or your own implementation |
Tokens¶
| Deprecated | Replacement |
|---|---|
JWS::addSignature() called from outside the library |
JWSBuilder |
JWE::withPayload() called from outside the library |
JWEDecrypter |
Extending JWK, JWKSet, JWS, JWE or Signature |
Build the object with its constructor and keep your own state beside it |
new Signature($sig, $decodedProtectedHeader, null, ...) |
Pass the encoded protected header — the decoded one is silently discarded without it, and this throws in 5.0 |
Algorithms¶
| Deprecated | Replacement |
|---|---|
Jose\Component\Signature\Algorithm\None |
Jose\Unsecured\Signature\None (web-token/jwt-unsecured) |
Jose\Component\Encryption\Algorithm\KeyEncryption\RSA15 |
Jose\Rsa15\KeyEncryption\RSA15 (web-token/jwt-rsa15) |
The hardcoded RSA1_5 CEK size table |
Read the expected CEK size argument |
Jose\Component\Signature\Algorithm\EdDSA (deprecated by RFC 9864; its removal is a 5.0 candidate) |
Ed25519 — same key, alg: Ed25519 |
Jose\Experimental\Signature\ES256K |
Jose\Component\Signature\Algorithm\ES256K |
Symfony bundle¶
| Deprecated | Replacement |
|---|---|
| The inheritance based event dispatching services | The decorators registered under the same service ids — no configuration change needed |
The event dispatching verifier and decrypter of the bundle used to drop the callable the loaders pass to observe the discarded keys, so the reason of a failure was lost as soon as the bundle services were used. They forward it now, as the new decorators do.