A zero-dependency PHP library for encoding and decoding JSON Web Tokens (JWT). Supports HS256
(HMAC-SHA256) and RS256 (RSA-SHA256), validates exp/nbf claims, and uses hash_equals()
for constant-time signature comparison.
Once published to Packagist:
composer require kasapdev/php-jwt-authOr just require the files directly:
require_once 'src/JwtException.php';
require_once 'src/InvalidTokenException.php';
require_once 'src/InvalidSignatureException.php';
require_once 'src/InvalidClaimException.php';
require_once 'src/ExpiredTokenException.php';
require_once 'src/Base64Url.php';
require_once 'src/Jwt.php';use Kasapdev\JwtAuth\Jwt;
use Kasapdev\JwtAuth\ExpiredTokenException;
use Kasapdev\JwtAuth\InvalidSignatureException;
use Kasapdev\JwtAuth\InvalidTokenException;
$secret = 'a-long-random-shared-secret';
$token = Jwt::encode([
'sub' => 'user-42',
'role' => 'admin',
'iat' => time(),
'exp' => time() + 3600, // expires in one hour
], $secret);
try {
$payload = Jwt::decode($token, $secret);
echo $payload['sub']; // "user-42"
} catch (ExpiredTokenException $e) {
// exp is in the past, or nbf is in the future
} catch (InvalidSignatureException $e) {
// token is well-formed but the signature doesn't match
} catch (InvalidTokenException $e) {
// malformed token: wrong segment count, bad base64, bad JSON, bad/disallowed alg
}use Kasapdev\JwtAuth\Jwt;
// $privateKeyPem / $publicKeyPem are PEM strings, e.g. loaded from files.
$token = Jwt::encode(['sub' => 'user-42'], $privateKeyPem, 'RS256');
$payload = Jwt::decode($token, $publicKeyPem, ['RS256']);A typical flow is one process issuing a token and a later, separate request verifying it — the example below keeps them apart to make that explicit:
use Kasapdev\JwtAuth\Jwt;
use Kasapdev\JwtAuth\ExpiredTokenException;
use Kasapdev\JwtAuth\InvalidClaimException;
use Kasapdev\JwtAuth\InvalidSignatureException;
use Kasapdev\JwtAuth\InvalidTokenException;
$secret = 'a-long-random-shared-secret';
// --- Login endpoint: issue a token ---
$accessToken = Jwt::encode([
'sub' => 'user-42',
'iss' => 'https://auth.example.com',
'aud' => 'billing-api',
'iat' => time(),
'exp' => time() + 900, // 15 minutes
], $secret);
// ...the client stores $accessToken and sends it back as, e.g., an Authorization: Bearer header.
// --- A later request to a protected endpoint: verify the token ---
function authenticate(string $bearerToken, string $secret): array
{
try {
return Jwt::decode(
$bearerToken,
$secret,
allowedAlgos: ['HS256'],
issuer: 'https://auth.example.com',
audience: 'billing-api',
);
} catch (ExpiredTokenException $e) {
throw new RuntimeException('Session expired, please log in again.', previous: $e);
} catch (InvalidSignatureException|InvalidClaimException|InvalidTokenException $e) {
throw new RuntimeException('Not authenticated.', previous: $e);
}
}
$user = authenticate($accessToken, $secret);
echo $user['sub']; // "user-42"$token = Jwt::encode(['sub' => '1'], $secret, 'HS256', ['kid' => 'key-2024-01']);typ defaults to "JWT" and alg is always forced to match the $algo argument, regardless of
what's passed in $header — a caller cannot smuggle a different algorithm into the header than
the one actually used to sign the token.
use Kasapdev\JwtAuth\InvalidClaimException;
$token = Jwt::encode([
'sub' => 'user-42',
'iss' => 'https://auth.example.com',
'aud' => 'billing-api',
], $secret);
try {
$payload = Jwt::decode(
$token,
$secret,
allowedAlgos: ['HS256'],
issuer: 'https://auth.example.com',
audience: 'billing-api',
);
} catch (InvalidClaimException $e) {
// "iss" doesn't match, or "aud" doesn't contain an expected value
}issuer and audience are opt-in: pass them and decode() requires the token to carry a
matching iss / aud claim, or it throws InvalidClaimException. Leave them null (the
default) and iss/aud are ignored entirely, same as before. audience accepts either a single
string or an array of acceptable values, and matches if the token's own aud — a string or an
array per RFC 7519 §4.1.3 — contains any of them.
Jwt::decode()'s $secret parameter also accepts an array of candidate secrets/keys instead
of a single string. This lets you rotate a signing secret without invalidating tokens that were
already issued under the old one: put the new secret first (the common case, since most incoming
tokens will already use it) and the old secret(s) after it, and decode() tries each candidate in
turn — with the same constant-time comparison per attempt — until one verifies.
use Kasapdev\JwtAuth\Jwt;
use Kasapdev\JwtAuth\InvalidSignatureException;
$oldSecret = 'secret-issued-before-the-rotation';
$newSecret = 'freshly-rotated-secret';
// A token issued before the rotation, signed with the old secret.
$oldToken = Jwt::encode(['sub' => 'user-42'], $oldSecret);
// After rotating, decode against both: new secret first, old secret(s) after.
$payload = Jwt::decode($oldToken, [$newSecret, $oldSecret]);
echo $payload['sub']; // "user-42" -- still verifies, even though $newSecret alone wouldn't work
try {
Jwt::decode($oldToken, [$newSecret, 'some-other-secret']);
} catch (InvalidSignatureException $e) {
// $oldSecret isn't in the candidate list, so no candidate verifies.
}This works the same way for RS256: pass an array of PEM-encoded public keys instead of a single
one, and each is tried in turn. A single string $secret continues to work exactly as before —
this is purely additive.
Jwt::encode(array $payload, string $secret, string $algo = 'HS256', array $header = []): stringJwt::decode(string $token, string|array $secret, array $allowedAlgos = ['HS256'], ?string $issuer = null, string|array|null $audience = null): array
For RS256, $secret in encode() is a PEM-encoded RSA private key, and in decode() a
PEM-encoded RSA public key. In decode(), $secret may also be an array of candidate
secrets/keys for key rotation — see Key Rotation.
Base64Url::encode(string $data): stringBase64Url::decode(string $data): string— throwsInvalidTokenExceptionon invalid input
All extend the common JwtException base, so you can catch broadly or specifically:
InvalidTokenException— malformed token (wrong segment count, invalid base64url, invalid JSON, missing/disallowedalg, a non-scalar/wrong-type registered claim, or an invalid signing/verification key)InvalidSignatureException— the token is well-formed but its signature does not verifyExpiredTokenException— the signature is valid butexpis in the past ornbfis in the futureInvalidClaimException— the signature is valid but an opted-intoissuer/audiencecheck failed:iss/audis missing, or doesn't match
- Signature comparison for HS256 uses
hash_equals(), which is constant-time and resistant to timing attacks. decode()always verifies the signature before trusting the payload's claims.- The
algused to sign a token is never taken on faith from the token's own header without your explicit consent: pass the algorithms you're willing to accept via$allowedAlgos, and anything else is rejected withInvalidTokenException(this closes the classic "alg confusion" / "alg: none" class of JWT vulnerabilities).
php tests/run.phpThe suite covers HS256 and RS256 round trips, tampered signatures, wrong secrets/keys, expired
and not-yet-valid tokens, issuer/audience matching, key rotation via candidate-secret arrays, and
a range of malformed-token inputs. If the environment's OpenSSL
configuration can't generate an RSA key pair (some minimal PHP installs need OPENSSL_CONF
pointed at a valid openssl.cnf before openssl_pkey_new() will work), the RS256 tests are
skipped with a [SKIP] line rather than failing — HS256 coverage runs unconditionally either way.
MIT