Security-first JWT implementation for PHP 8.2+.
Create, sign, encrypt, decrypt, validate, and reissue JSON Web Tokens with explicit key management and strict defaults.
Supports JWS and JWE, a pluggable algorithm registry, and fine-grained claim validation—without hiding security decisions behind magic defaults.
- ✅ RFC-compliant (JWS, JWE, JWA, JWT, JWK)
- 🔐 Secure-by-default claim validation and key handling
- 🧩 Clear separation of concerns (keys, payloads, algorithms, validation)
- 🔁 Built-in reissue / refresh workflows
- 🧪 Explicit testing-only escape hatches
composer require phithi92/json-web-token:^2.0- PHP 8.2+
- OpenSSL extension (required)
phpseclib/phpseclib(installed automatically)
Tokens produced by this library are fully RFC-compliant and interoperable with other JWT implementations across different languages and platforms.
No proprietary headers, claims, or encoding shortcuts are introduced. As long as the same algorithms, keys, and claims are used, tokens can be safely exchanged with other standards-compliant JWT stacks.
- RFC 7515 — JSON Web Signature (JWS)
- RFC 7516 — JSON Web Encryption (JWE)
- RFC 7517 — JSON Web Key (JWK, reference formats)
- RFC 7518 — JSON Web Algorithms (JWA)
- RFC 7519 — JSON Web Token (JWT)
- RFC 7638 — JWK Thumbprints (
kidderivation)
JwtKeyManager → keys, algorithms, passphrases
JwtPayload → claims & type-safe helpers
JwtTokenService → create / decrypt / reissue
JwtValidator → issuer, audience, claims, replay protection
JwtBundle → parsed token aggregate
Each component is usable independently, but the default factory wires everything safely for you.
JwtTokenServiceFactory::createDefault() is intentionally opinionated and builds a consistent default dependency graph so all operations share the same baseline behavior.
Internally, it creates:
- one shared
JwtValidatorinstance (default: no expected issuer/audience, no clock skew, no private-claim expectations, no JTI registry) - one
JwtPayloadCodec - one
JwtTokenIssuerFactory - one
JwtTokenDecryptorFactory - one
JwtTokenCreator(with the shared default validator) - one
JwtTokenReader - one
JwtClaimsValidationService(with the shared default validator) - one
JwtTokenReissuer(with the shared default validator)
This means:
- Passing
nullas validator uses the shared default validator of this service instance. createDefault()returns a fresh service graph per call (instances are not reused globally).- Claim validation is only as strict as your configured
JwtValidator; for production you should usually pass an explicit validator with issuer/audience/JTI expectations.
createTokenWithoutClaimValidation()anddecryptTokenWithoutClaimValidation()are intentionally unsafe escape hatches for tests/tooling.
JwtKeyManager holds all keys in memory. Asymmetric keys must be PEM-encoded.
Symmetric secrets (HMAC, dir) live in the passphrase store.
use Phithi92\JsonWebToken\Security\KeyManagement\JwtKeyManager;
$manager = new JwtKeyManager();
$manager->addKeyPair(
private: file_get_contents('/path/private.pem'),
public: file_get_contents('/path/public.pem'),
kid: 'main-key'
);
$manager->addPassphrase(
passphrase: getenv('JWT_KEY_PASSPHRASE'),
kid: 'main-key'
);
// For symmetric algorithms (HS*, dir/A*GCM), register a shared secret
$manager->addPassphrase(
passphrase: getenv('JWT_SHARED_SECRET'),
kid: 'HS256'
);If no
kidis provided when issuing tokens, one is derived from the JOSE header (e.g.RS256,RSA-OAEP-256.A256GCM). Make sure the corresponding key is registered under thatkid, or pass akidexplicitly.
JwtPayload provides helpers for standard claims and strict validation.
use Phithi92\JsonWebToken\Token\JwtPayload;
$payload = (new JwtPayload())
->setIssuer('https://issuer.example')
->setAudience('https://service.example')
->setIssuedAt('now')
->setExpiration('+15 minutes')
->setJwtId('token-123')
->addClaim('role', 'admin');Time-based helper setters such as setIssuedAt() and setExpiration() accept date/time strings, for example:
"now"- Relative strings (
+15 minutes) - Absolute datetime strings (
2026-01-01T00:00:00+00:00)
ℹ️ JWT
iat,nbf, andexpare NumericDate values (seconds since Unix epoch in UTC). Always generate and compare timestamps in UTC to avoid timezone drift.
If you want to set UNIX timestamps directly, use setClaimTimestamp():
$payload
->setClaimTimestamp('iat', time())
->setClaimTimestamp('exp', time() + 900);use Phithi92\JsonWebToken\Token\Factory\JwtTokenServiceFactory;
use Phithi92\JsonWebToken\Token\Validator\JwtValidator;
$service = JwtTokenServiceFactory::createDefault();
$validator = new JwtValidator();
$token = $service->createTokenString(
algorithm: 'RS256',
manager: $manager,
payload: $payload,
validator: $validator,
kid: 'main-key'
);You may also issue tokens directly from an array of claims:
$bundle = $service->createTokenFromArray(
algorithm: 'RS256',
manager: $manager,
claims: ['iss' => 'https://issuer.example', 'exp' => time() + 900],
validator: $validator,
kid: 'main-key'
);$bundle = $service->decryptToken(
token: $token,
manager: $manager,
validator: $validator
);
$payload = $bundle->getPayload();For JWS tokens this verifies the signature and reads the payload. For JWE tokens this decrypts and then validates claims.
$isValid = $service->validateTokenClaims(
bundle: $bundle,
validator: $validator
);
⚠️ *WithoutClaimValidation()methods exist only for tests or tooling.
JwtValidator can enforce issuer, audience, private claims and protect against JWT replay attacks via a pluggable JWT ID registry.
jti is the token identifier claim used to uniquely track a token and support replay prevention.
- Without a
JwtIdValidatorInterface,jtiis optional and not checked. - If a
JwtIdValidatorInterfaceis configured, tokens must containjti. - The validator then checks whether the
jtiis allowed by the configured backend (in-memory, Redis, PDO).
When issuing via JwtTokenService::createToken() / createTokenFromArray() and the chosen validator has a JTI validator configured:
- if
jtiis missing, a new randomjtiis generated automatically - this generated
jtiis pre-registered as allowed - if
expis missing in that situation, issuing fails (because JTI tracking needs expiry context)
Practical recommendation:
- Set
jtiandexpexplicitly for all tokens that should be replay-protected. - Use
denyBundle()after successful one-time use to invalidate the token ID for the remaining token lifetime.
InMemoryJwtIdValidator is a simple, deterministic implementation intended for tests, demos, and short‑lived processes.
use Phithi92\JsonWebToken\Token\Validator\InMemoryJwtIdValidator;
use Phithi92\JsonWebToken\Token\Validator\JwtValidator;
$jwtIdValidator = new InMemoryJwtIdValidator(
allowList: ['token-123'],
denyList: ['revoked-token'],
useAllowList: true
);
$validator = new JwtValidator(
expectedIssuer: 'https://issuer.example',
expectedAudience: 'https://service.example',
jwtIdValidator: $jwtIdValidator
);-
useAllowList = true
Only JWT IDs present inallowListare accepted.
Useful for single‑use tokens, login flows, or explicit grants. -
useAllowList = false(default)
All JWT IDs are accepted unless they appear indenyList.
Suitable for classic access tokens with revocation support.
When a token is successfully validated, the service can deny its JWT ID to prevent replay:
$service->denyBundle($bundle, $validator);
⚠️ InMemoryJwtIdValidatoris process‑local and non‑persistent.
Use Redis or PDO validators for production replay protection.
InMemoryJwtIdValidator: ideal for tests and local demos; state is process-local.RedisJwtIdValidator: distributed runtime deny/allow lists with TTL support.PdoJwtIdValidator: relational persistence (requiresjwt_id_listtable with expiry column).
$newBundle = $service->reissueBundle(
interval: '+30 minutes',
bundle: $bundle,
manager: $manager,
validator: $validator
);The original bundle remains untouched.
When issuing, parsing, decrypting, or validating tokens, prefer catching specific exception types first and only then falling back to a generic handler.
use Phithi92\JsonWebToken\Exceptions\Token\InvalidTokenException;
use Phithi92\JsonWebToken\Exceptions\Token\MalformedTokenException;
use Phithi92\JsonWebToken\Exceptions\Token\UnsupportedTokenTypeException;
use Phithi92\JsonWebToken\Exceptions\Payload\ExpiredPayloadException;
use Phithi92\JsonWebToken\Exceptions\Payload\NotYetValidException;
use Phithi92\JsonWebToken\Exceptions\Payload\InvalidIssuerException;
use Phithi92\JsonWebToken\Exceptions\Payload\InvalidAudienceException;
use Phithi92\JsonWebToken\Exceptions\Crypto\SignatureVerificationException;
use Phithi92\JsonWebToken\Exceptions\Crypto\DecryptionException;
use Phithi92\JsonWebToken\Exceptions\Security\PassphraseNotFoundException;
try {
$bundle = $service->decryptToken(
token: $token,
manager: $manager,
validator: $validator
);
// Optional extra claim validation step
$service->validateTokenClaims($bundle, $validator);
} catch (ExpiredPayloadException|NotYetValidException $e) {
// 401: token is time-invalid (expired or not active yet)
} catch (InvalidIssuerException|InvalidAudienceException $e) {
// 403: token is valid but not intended for this API/context
} catch (SignatureVerificationException|DecryptionException $e) {
// 401: signature/JWE auth check failed
} catch (MalformedTokenException|InvalidTokenException|UnsupportedTokenTypeException $e) {
// 400: structurally invalid or unsupported token
} catch (PassphraseNotFoundException $e) {
// 500: server-side key configuration problem
}- 400 Bad Request: malformed token, missing parts, unsupported format/type
- 401 Unauthorized: invalid signature, failed decryption/auth tag, expired or not-yet-valid token
- 403 Forbidden: issuer/audience/private-claim mismatch
- 500 Internal Server Error: missing key material, passphrase, or other server misconfiguration
- Return a generic client message (e.g.
"Invalid or expired token") to avoid leaking verification details. - Log the exact exception message internally with request correlation IDs.
- Do not include secrets, raw token content, or key identifiers in public error payloads unless required.
Identifiers map to handlers via resources/algorithms.php.
- HMAC:
HS256·HS384·HS512 - RSA:
RS256·RS384·RS512 - RSA-PSS:
PS256·PS384·PS512 - ECDSA:
ES256·ES384·ES512
- RSA-OAEP + AES-GCM:
RSA-OAEP/A256GCM·RSA-OAEP-256/A256GCM - Direct AES-GCM:
A128GCM·A192GCM·A256GCM
Prefer RSA-PSS for new RSA signatures and AES-GCM for authenticated encryption. Pin algorithms per client.
- 🔑 Never commit keys or passphrases
- 🔒 Always validate issuer & audience
- ⏱ Use short expiration windows
- 📌 Pin algorithms and
kids per client - 🧯 Catch domain-specific exceptions only
composer install
composer run keys # generate test keys
composer run test
composer run analyseReleased under the MIT License. See LICENSE.
![]() |
|---|
