Migrating from v2.x to v3.0¶
v3.0 is a breaking release that:
- Migrates the underlying library from
lcobucci/jwt ^4tolcobucci/jwt ^5. - Replaces all mutable fluent setters with an immutable
with*()API (withSplitData(),withParamData(),withLeeway(),withExpiresAt()). - Removes the ambiguous
decode()return type (DataSet|RequiredConstraintsViolated);decode()now always throws andtryDecode()is the non-throwing variant. - Adds first-class RSA / ECDSA support alongside the previous HMAC modes.
- Hardens the default config:
signer,issuer,audienceandidentifierarenullby default and the library refuses to operate until they are set. - Fails closed on misconfiguration: an algorithm/signer mismatch, a missing
SignedWithconstraint while validation is on, and an invalid date modifier all throw a clear exception instead of failing silently or with a cryptic error.
The minimum PHP version is now 8.2.
Breaking changes at a glance¶
| Topic | v2.x | v3.0 |
|---|---|---|
| Underlying library | lcobucci/jwt ^4 |
lcobucci/jwt ^5 |
JWT::decode() return |
DataSet \| RequiredConstraintsViolated |
Plain (always) |
| Fail mode | Config::$throwable flag |
decode() throws, tryDecode() returns ?Plain |
| Config::$throwable | exists | removed |
| Mutable setters | setSplitData(), setParamData() |
withSplitData(), withParamData(), withLeeway(), withExpiresAt() |
withLeeway() argument |
n/a | ?int — null resets to "no leeway"; a negative int throws |
| Cache method | clearCache() |
removed (no cache) |
encode() uid type |
mixed $uid |
int\|string\|null $uid (an integer ID round-trips as an integer) |
Default signer |
'mBC5v1sOKVvbdEitdSBenu59nfNfhwkedkJVNabosTw=' |
null (must be configured) |
Default issuer/audience/identifier |
non-null example values | null (empty string now rejected too) |
| Algorithm support | HMAC only | HMAC, RSA, ECDSA |
| Type/signer mismatch | cryptic lcobucci "key" error | JWTConfigurationException |
Missing SignedWith (with $validate=true) |
silently skipped | JWTConfigurationException |
ValidAt constraint |
deprecated ValidAt |
LooseValidAt (default, ValidAt still aliased) / StrictValidAt |
Code-level migration¶
Decoding¶
v2.x
$config = config('JWT');
$config->throwable = false;
$result = (new JWT($config))->decode($token);
if ($result instanceof RequiredConstraintsViolated) {
return failure($result->getMessage());
}
echo $result->get('data');
v3.0
$jwt = new JWT(config('JWT'));
// Throwing API (default).
try {
$claims = $jwt->decode($token); // Plain
echo $claims->claims()->get('data');
} catch (RequiredConstraintsViolated $e) {
return failure($e->getMessage());
}
// Non-throwing variant.
$claims = $jwt->tryDecode($token); // ?Plain
if ($claims === null) {
return failure('Invalid token');
}
echo $claims->claims()->get('data');
Plain::claims() returns the same DataSet you used in v2.x. The change is one extra ->claims() call, in exchange for Plain::headers() access (needed for the new getPayload() shortcut).
Mutators are now immutable¶
v2.x
v3.0
$jwt = (new JWT($config))
->withSplitData()
->withParamData('payload')
->withLeeway(30) // new in v3 — optional clock skew tolerance
->withExpiresAt('+5 minutes'); // new in v3 — per-instance expiry override
If you held a reference to a configured $jwt in v2.x, your code worked because the setters mutated $jwt. In v3.0 the original instance is unchanged — assign the result of with*() to a new variable or chain.
withLeeway(?int $seconds) accepts null to reset to "no leeway"; a negative int throws InvalidArgumentException. withExpiresAt(string $modifier) overrides the configured expiresAt for this instance only (handy for short-lived access tokens) and throws InvalidArgumentException on an empty string. Both follow the immutable pattern and return a new instance.
Compact-mode payloads¶
v2.x — manual json_decode after every decode:
v3.0 — symmetric helper:
encode() now writes header cty=json for compact tokens; getPayload() uses it to decide whether to decode. Tokens generated by v2.x do not carry that header — for those, use decode() + json_decode as before.
Configuration class¶
v2.x defaults (insecure)
public string $signer = 'mBC5v1sOKVvbdEitdSBenu59nfNfhwkedkJVNabosTw=';
public string $issuer = 'http://example.local';
public string $audience = 'http://example.local';
public string $identifier = '4f1g23a12aa';
public bool $throwable = true;
v3.0 defaults (fail-loud)
public int|string|null $uid = null; // string OR integer ID; round-trips with its JSON type
public string $algorithmType = 'symmetric'; // or 'asymmetric'
public string $algorithm = \Lcobucci\JWT\Signer\Hmac\Sha256::class; // must match $algorithmType
public ?string $signer = null; // run `php spark jwt:key`
public ?string $signingKey = null; // run `php spark jwt:keypair`
public ?string $verifyingKey = null;
public ?string $passphrase = null;
public ?string $issuer = null; // required, no default (empty string also rejected)
public ?string $audience = null;
public ?string $identifier = null;
public string $canOnlyBeUsedAfter = '+0 minute'; // DateTimeImmutable::modify() modifier
public string $expiresAt = '+24 hour'; // DateTimeImmutable::modify() modifier
public ?int $leeway = 0; // new — clock skew in seconds; null = no leeway
public bool $validate = true; // new — set false to decode without any validation
public bool $allowUnsafeExtraction = false; // new — silences extractClaimsUnsafe warning
public array $validateClaims = [
'SignedWith', 'IssuedBy', 'LooseValidAt', 'IdentifiedBy', 'PermittedFor',
];
// Allowed $validateClaims values: SignedWith, IssuedBy, IdentifiedBy, PermittedFor,
// LooseValidAt (alias: ValidAt), StrictValidAt.
After upgrading, every install must set these explicitly via app/Config/JWT.php or .env. The library throws JWTConfigurationException on the first encode/decode if any required field is missing.
$algorithm must match $algorithmType: a symmetric type requires an Lcobucci\JWT\Signer\Hmac\* signer, and an asymmetric type requires an Rsa\* or Ecdsa\* signer. A mismatch — e.g. switching $algorithmType to 'asymmetric' but leaving the default HMAC Sha256 signer — throws JWTConfigurationException with a clear message instead of a cryptic lcobucci "key" error.
Removed APIs¶
Config\JWT::$throwable— gone. Usedecode()(throws) vs.tryDecode()(returns null).JWT::clearCache()— gone. The library no longer caches state across calls; the v2.x bug where a cachedFrozenClockvalidated expired tokens as fresh is impossible to reintroduce.JWT::setSplitData(),JWT::setParamData()— replaced bywithSplitData(),withParamData().
New fail-closed guards¶
v3.0 refuses to silently weaken security. Watch for these new exceptions while migrating:
- Signature verification is mandatory by default. If
$validate = true(the default) but$validateClaimsdoes not contain'SignedWith',decode()throwsJWTConfigurationExceptionrather than skip signature verification. To decode without any validation, setConfig\JWT::$validate = falseexplicitly. $validate = falselogs a warning. When validation is disabled,decode()emits a'warning'vialog_message()(parallel toextractClaimsUnsafe()). It is intended for tests / debugging only — never for production traffic.- Algorithm/type mismatch throws. See the note above: a signer that does not match
$algorithmTypenow throwsJWTConfigurationExceptioninstead of a cryptic lcobucci error. - Invalid date modifiers throw consistently. An invalid
canOnlyBeUsedAfterorexpiresAtmodifier now throwsInvalidArgumentExceptionon every supported PHP version (8.2 returnedfalse, 8.3+ throws). A valid futurecanOnlyBeUsedAfteris still clamped to issuance time so freshly-issued tokens are immediately usable (unchanged, intended behaviour).
Asymmetric (RSA / ECDSA) — new in v3.0¶
Generate a key pair:
php spark jwt:keypair --algorithm=rsa --bits=2048 --output=writable/keys
# or
php spark jwt:keypair --algorithm=ecdsa --curve=prime256v1 --output=writable/keys
Wire app/Config/JWT.php:
public string $algorithmType = 'asymmetric';
public string $algorithm = \Lcobucci\JWT\Signer\Rsa\Sha256::class; // RS256
// or \Lcobucci\JWT\Signer\Ecdsa\Sha256::class for ES256
public ?string $signingKey = WRITEPATH . 'keys/jwt-private.pem';
public ?string $verifyingKey = WRITEPATH . 'keys/jwt-public.pem';
public ?string $passphrase = null; // set if you encrypted the private key
signingKey / verifyingKey accept either a filesystem path (preferred) or the raw PEM contents.
Step-by-step upgrade checklist¶
composer require daycry/jwt:^3(will pulllcobucci/jwt:^5automatically).- Update
app/Config/JWT.php: - Set
signer(HMAC) orsigningKey/verifyingKey(asymmetric). - Set
issuer,audience,identifierto your real values — the example URLs from v2.x are gone (empty strings are now rejected, same asnull). - Make sure
$algorithmmatches$algorithmType(HMAC signer for'symmetric', RSA/ECDSA signer for'asymmetric') — a mismatch now throws. - Replace
'ValidAt'with'LooseValidAt'(or'StrictValidAt') in$validateClaimsif you customized it. - Keep
'SignedWith'in$validateClaimswhile$validate = true— removing it now throws instead of silently skipping signature verification. - Remove
$throwableif you had set it. - Search your codebase:
setSplitData(→withSplitData((and re-assign the returned instance).setParamData(→withParamData((idem).clearCache(→ delete the call.->decode(...)followed byinstanceof RequiredConstraintsViolated→ switch to try/catch ortryDecode().$claims->get(→$claims->claims()->get((or migrate togetPayload()for compact tokens).- If you exposed
extractClaimsUnsafe()in production, setConfig\JWT::$allowUnsafeExtraction = trueto silence the warning, or migrate the call todecode(). - Run your test suite. If you previously suppressed
ValidAtto dodge timing flakes, you can now remove the workaround — the v2.x cache bug is fixed.
Need help?¶
Open an issue at https://github.com/daycry/jwt/issues. Include the v2.x snippet you are migrating and the error you hit.