From f31a884c1d72e31795ff6d985095a3a562173db2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Guillaume=20Delr=C3=A9?= Date: Sun, 10 May 2026 21:08:59 +0200 Subject: [PATCH 1/2] fix: validate numeric type of iat, nbf and exp claims in encode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The decode() path already rejected non-numeric iat/nbf/exp values, but encode() accepted any type and silently produced an invalid token. Add the same is_numeric guards in encode() and cover them with unit tests. Signed-off-by: Guillaume Delré --- src/JWT.php | 9 +++++++++ tests/JWTTest.php | 24 ++++++++++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/src/JWT.php b/src/JWT.php index 0d2e47c9..706aef58 100644 --- a/src/JWT.php +++ b/src/JWT.php @@ -224,6 +224,15 @@ public static function encode( if ($keyId !== null) { $header['kid'] = $keyId; } + if (isset($payload['nbf']) && !\is_numeric($payload['nbf'])) { + throw new UnexpectedValueException('Payload nbf must be a number'); + } + if (isset($payload['iat']) && !\is_numeric($payload['iat'])) { + throw new UnexpectedValueException('Payload iat must be a number'); + } + if (isset($payload['exp']) && !\is_numeric($payload['exp'])) { + throw new UnexpectedValueException('Payload exp must be a number'); + } $segments = []; $segments[] = static::urlsafeB64Encode((string) static::jsonEncode($header)); $segments[] = static::urlsafeB64Encode((string) static::jsonEncode($payload)); diff --git a/tests/JWTTest.php b/tests/JWTTest.php index 6832655f..8b22d4d3 100644 --- a/tests/JWTTest.php +++ b/tests/JWTTest.php @@ -609,6 +609,30 @@ public function testDecodeExpectsIntegerExp() JWT::decode($payload, $this->hmacKey); } + public function testEncodeThrowsWhenIatIsNotNumeric(): void + { + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessage('Payload iat must be a number'); + + JWT::encode(['iat' => 'not-an-int'], $this->hmacKey->getKeyMaterial(), 'HS256'); + } + + public function testEncodeThrowsWhenNbfIsNotNumeric(): void + { + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessage('Payload nbf must be a number'); + + JWT::encode(['nbf' => 'not-an-int'], $this->hmacKey->getKeyMaterial(), 'HS256'); + } + + public function testEncodeThrowsWhenExpIsNotNumeric(): void + { + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessage('Payload exp must be a number'); + + JWT::encode(['exp' => 'not-an-int'], $this->hmacKey->getKeyMaterial(), 'HS256'); + } + public function testRsaKeyLengthValidationThrowsException(): void { $this->expectException(DomainException::class); From 96501871bf909833ef1207bc96c7e5a204211236 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Guillaume=20Delr=C3=A9?= Date: Mon, 11 May 2026 20:37:01 +0200 Subject: [PATCH 2/2] test: add regression test for numeric string timestamps in encode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Covers the case where iat/nbf/exp are passed as numeric strings (e.g. (string) time()), which must remain accepted to avoid a breaking change. Signed-off-by: Guillaume Delré --- tests/JWTTest.php | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tests/JWTTest.php b/tests/JWTTest.php index 8b22d4d3..60b7ae48 100644 --- a/tests/JWTTest.php +++ b/tests/JWTTest.php @@ -633,6 +633,21 @@ public function testEncodeThrowsWhenExpIsNotNumeric(): void JWT::encode(['exp' => 'not-an-int'], $this->hmacKey->getKeyMaterial(), 'HS256'); } + public function testEncodeAcceptsNumericStringTimestamps(): void + { + $payload = [ + 'message' => 'abc', + 'iat' => (string) time(), + 'exp' => (string) (time() + 20), + 'nbf' => (string) (time() - 20), + ]; + + $encoded = JWT::encode($payload, $this->hmacKey->getKeyMaterial(), 'HS256'); + $decoded = JWT::decode($encoded, $this->hmacKey); + + $this->assertSame('abc', $decoded->message); + } + public function testRsaKeyLengthValidationThrowsException(): void { $this->expectException(DomainException::class);