Advanced¶
Key rotation with kid¶
(3.2.0) Rotate signing keys without invalidating tokens that are still in flight.
The issuing side stamps each token with a kid header (Config\JWT::$keyId or JWT::withKeyId()). The verifying side keeps a kid => key map in Config\JWT::$verifyingKeys; on decode() the token's kid selects the matching key, falling back to the single $verifyingKey / $signer when the kid is absent or unknown.
// Issuing side — stamp the active key id.
$token = JWT::for()->withKeyId('2026-06')->encode($data);
// Verifying side — app/Config/JWT.php
public ?string $keyId = '2026-06'; // new tokens use this key
public array $verifyingKeys = [
'2026-05' => '/path/old-public.pem', // still accepted…
'2026-06' => '/path/new-public.pem', // …alongside the new one
];
Rotation workflow
- Add the new key to
$verifyingKeysand point$keyId(and$signingKey, for asymmetric) at it. Old tokens still verify against theirkid. - Wait until every token issued under the old key has expired.
- Remove the old entry from
$verifyingKeys.
If a key is leaked, remove it from $verifyingKeys immediately to revoke its tokens.
Security note: the configured signer/algorithm is always used regardless of the token's
kid, so an attacker-chosenkidcan never downgrade the verifier to a weaker algorithm. Values are PEM contents/paths for asymmetric, or base64 secrets for symmetric.
Utility Methods¶
tryDecode()¶
Like decode() but returns null instead of throwing. Convenient inside middleware.
$decoded = $jwt->tryDecode($input);
if ($decoded === null) {
return service('response')->setStatusCode(401);
}
getPayload()¶
Validate and return the original payload value:
- Scalar payloads → returned as-is.
- Compact-mode arrays (header
cty=json) → alreadyjson_decoded back into an array. - Split-mode → returns the value of
paramData(oftennullsince data is spread across many claims). Usedecode()and inspectclaims()->all()for split mode.
Throws the same exceptions as decode().
isValid()¶
true iff tryDecode() succeeds — never throws.
isExpired()¶
Cheap pre-flight check that inspects only the exp claim against time(). Does not verify the signature. Returns true for malformed tokens (defensive: treat unparseable as "no longer valid").
getTimeToExpiry()¶
Seconds remaining until exp, clamped at 0. Returns null if the token cannot be parsed or has no exp claim.
$ttl = $jwt->getTimeToExpiry($token);
if ($ttl !== null && $ttl < 300) {
// warn the client to refresh
}
extractClaimsUnsafe()¶
Returns all claims as an associative array without any validation. Returns null if the token cannot be parsed.
The library logs a warning each time this method is called unless you explicitly opt in with
Config\JWT::$allowUnsafeExtraction = true. The flag exists to make accidental production usage visible in logs.
Use only when you have already verified the token through another mechanism, or when reading metadata (e.g. iss / kid) before deciding which key to verify with.
Error handling¶
RequiredConstraintsViolated¶
Validation failures (signature, claims, expiry, etc.) produce Lcobucci\JWT\Validation\RequiredConstraintsViolated. getMessage() lists the violated constraints.
InvalidTokenException¶
Parsing failures (token is not three base64-encoded segments, encryption headers, etc.) produce Daycry\JWT\Exceptions\InvalidTokenException.
JWTConfigurationException¶
Encoding without a configured signer / signingKey / issuer / audience / identifier produces Daycry\JWT\Exceptions\JWTConfigurationException with a message naming the missing field. An empty string counts as missing, just like null. The same exception is also thrown when:
$validateClaimsdoes not contain'SignedWith'while$validate = true(decoding refuses to silently skip signature verification — set$validate = falseto decode without any validation);$algorithmdoes not match$algorithmType('symmetric'needs anLcobucci\JWT\Signer\Hmac\*signer;'asymmetric'needs anRsa\*orEcdsa\*signer);$validateClaimscontains an unknown name (allowed:SignedWith,IssuedBy,IdentifiedBy,PermittedFor,LooseValidAt(aliasValidAt),StrictValidAt).
\JsonException¶
encode() uses JSON_THROW_ON_ERROR for compact-mode arrays. Non-serialisable payloads (resources, recursive references) raise \JsonException.
Combined try/catch¶
use Daycry\JWT\Exceptions\InvalidTokenException;
use Daycry\JWT\Exceptions\JWTConfigurationException;
use Lcobucci\JWT\Validation\RequiredConstraintsViolated;
try {
$claims = $jwt->decode($token);
} catch (JWTConfigurationException $e) {
// Misconfiguration — operator-facing.
log_message('error', $e->getMessage());
throw $e;
} catch (RequiredConstraintsViolated $e) {
return $this->respond(['error' => 'Invalid token', 'detail' => $e->getMessage()], 401);
} catch (InvalidTokenException $e) {
return $this->respond(['error' => 'Bad token'], 400);
}
Middleware pattern¶
<?php
namespace App\Filters;
use CodeIgniter\Filters\FilterInterface;
use CodeIgniter\HTTP\RequestInterface;
use CodeIgniter\HTTP\ResponseInterface;
use Daycry\JWT\JWT;
class JWTAuthFilter implements FilterInterface
{
public function before(RequestInterface $request, $arguments = null)
{
$header = $request->getHeaderLine('Authorization');
if (! str_starts_with($header, 'Bearer ')) {
return service('response')->setStatusCode(401)
->setJSON(['error' => 'Missing token']);
}
$token = substr($header, 7);
$decoded = JWT::for()->tryDecode($token);
if ($decoded === null) {
return service('response')->setStatusCode(401)
->setJSON(['error' => 'Invalid token']);
}
$request->jwt = $decoded; // available downstream
}
public function after(RequestInterface $request, ResponseInterface $response, $arguments = null) {}
}
Register and apply:
// app/Config/Filters.php
public array $aliases = [
'jwtAuth' => \App\Filters\JWTAuthFilter::class,
];
// app/Config/Routes.php
$routes->group('api', ['filter' => 'jwtAuth'], function ($routes) {
$routes->get('profile', 'ProfileController::index');
});
Multi-tenant / per-request configuration¶
JWT is immutable: the with*() methods (withExpiresAt(), withLeeway(), withSplitData(), withParamData()) each return a new instance, leaving the original — and the shared config('JWT') singleton — untouched. Prefer them over mutating config so concurrent requests can't trample each other's settings.
The uid passed to encode() may be a string or an integer (e.g. a DB primary key); lcobucci/jwt preserves the JSON type, so an integer uid round-trips back as an integer.
use Daycry\JWT\JWT;
function issueAccessToken(array $payload, int|string $uid): string
{
// Short-lived expiry overridden for this call only — config stays at its default.
return JWT::for()
->withExpiresAt('+15 minutes')
->encode($payload, $uid);
}
function issueRefreshToken(int|string $uid): string
{
return JWT::for()
->withExpiresAt('+30 days')
->encode(['type' => 'refresh'], $uid);
}
withExpiresAt(string $modifier) overrides the configured expiresAt for that instance only and accepts any DateTimeImmutable::modify() string. An empty string throws InvalidArgumentException, and an invalid modifier throws InvalidArgumentException when the token is encoded (consistently across PHP 8.2, which returns false, and 8.3+, which throws).
If you genuinely need a different validation profile per token type, pass a cloned config to the constructor — $validateClaims must always include 'SignedWith' or decode() throws JWTConfigurationException (it refuses to silently skip signature verification):
$config = clone config('JWT');
$config->validateClaims = ['SignedWith', 'LooseValidAt']; // keep SignedWith — required
$jwt = new JWT($config);
Clock skew (leeway)¶
LooseValidAt and StrictValidAt accept a leeway. Use it when token issuers and verifiers run on machines with imperfect time sync.
$config->leeway = 30; // seconds; applies to iat / nbf / exp
// or per-call (returns a new instance, config untouched):
$jwt = JWT::for()->withLeeway(60);
// Reset to "no leeway" for a single instance:
$strict = JWT::for()->withLeeway(null);
withLeeway(?int $seconds) accepts a non-negative int or null (no leeway). A negative int throws InvalidArgumentException.
StrictValidAt requires iat, nbf and exp to be present and within the leeway window. LooseValidAt skips checks for any of the three that is missing.
Performance notes¶
| Operation | Cost |
|---|---|
new JWT($config) / JWT::for() |
Negligible |
First encode() / decode() |
Builds the Configuration (signer + key load); InMemory::file() reads PEMs once per call |
tryDecode() |
Same as decode() plus a try/catch wrapper |
getPayload() |
decode() + a single json_decode |
isExpired() / getTimeToExpiry() |
Parse only — no validation |
extractClaimsUnsafe() |
Parse only + warning log |
The library deliberately does not cache validation constraints across calls. The benefit (a few microseconds per request) was not worth the v2.x bug it caused (a frozen clock validating expired tokens as fresh). Build the JWT instance on demand; instantiation is cheap.