web-token/jwt-framework
A comprehensive PHP library for JSON Web Tokens, implementing JWS, JWE, JWK, JWT, and related RFCs. It offers signing, encryption, key management, and token validation with a clean, standards-compliant API suitable for any PHP project.
This is a JWT framework, not a one-call JWT helper. You compose four things: an AlgorithmManager (the allow-list), a key (JWK/JWKSet), a builder/verifier, and a serializer. Budget an hour to understand those four pieces; after that, day-to-day use is mostly configuration and policy code.
Install (library only):
composer require web-token/jwt-library
For Symfony, add the bundle instead (web-token/jwt-bundle) and let it wire the factories.
Where to look first:
web-token.spomky-labs.com — read JWS, Key Management, Header Checker, Claim Checker, then the Symfony bundle page.AlgorithmManager, JWSBuilder, JWSVerifier, CompactSerializer, JWKFactory, ClaimCheckerManager, HeaderCheckerManager.php bin/console config:dump-reference jose (or jwt on 3.x) before writing any YAML.jwt-core/jwt-library, so namespaces moved. Check the migration guide before assuming a bug.First use case — issue and verify an HS256 API token:
use Jose\Component\Core\AlgorithmManager;
use Jose\Component\Core\Util\JWKFactory;
use Jose\Component\Signature\Algorithm\HS256;
use Jose\Component\Signature\JWSBuilder;
use Jose\Component\Signature\JWSVerifier;
use Jose\Component\Signature\Serializer\CompactSerializer;
$algorithms = new AlgorithmManager([new HS256()]); // allow-list, not a registry
$key = JWKFactory::createFromSecret(
$_ENV['JWT_SECRET'],
['alg' => 'HS256', 'use' => 'sig', 'kid' => 'app-2026-01']
);
$jws = (new JWSBuilder($algorithms))
->create()
->withPayload(json_encode([
'iss' => 'https://app.example.com',
'sub' => (string) $user->id,
'aud' => 'https://api.example.com',
'iat' => time(),
'exp' => time() + 900,
]))
->addSignature($key, ['alg' => 'HS256', 'typ' => 'JWT', 'kid' => 'app-2026-01'])
->build();
$token = (new CompactSerializer())->serialize($jws, 0);
Verification is the mirror image, plus the two steps everyone forgets — header checks and claim checks:
$jws = (new CompactSerializer())->unserialize($token);
if (! (new JWSVerifier($algorithms))->verifyWithKey($jws, $key, 0)) {
throw new RuntimeException('Invalid signature');
}
$claims = json_decode($jws->getPayload(), true, flags: JSON_THROW_ON_ERROR);
// Signature verified. Now enforce policy:
$claimChecker = new ClaimCheckerManager([
new ExpirationTimeChecker(),
new IssuedAtChecker(),
new NotBeforeChecker(),
new AudienceChecker('https://api.example.com'),
new IssuerChecker('https://app.example.com'),
]);
$claimChecker->check($claims, ['exp', 'iat', 'iss', 'aud', 'sub']);
Two rules for day one: never put None in the AlgorithmManager, and always call ClaimCheckerManager::check() with mandatory claims — signature verification alone is not authentication.
Wrap the primitives in two services: an issuer and a verifier.
The library has no opinion about TTLs, claims, or key lookup. Put that policy in one place. An TokenIssuer with issue(User $user, array $scopes = []): string and a TokenVerifier with verify(string $token): VerifiedToken (or a thrown exception) keeps cryptographic details out of controllers. All the assembling of AlgorithmManager, JWSBuilder, and serializers happens once in a service provider / container definition, not per request.
Verification middleware/authenticator pipeline. Order matters and should be explicit:
InvalidArgumentException).unserialize gives you headers and payload only).kid header against your key set. Reject unknown or missing kid unless you have exactly one expected key.HeaderCheckerManager with AlgorithmChecker bound to your allow-list, and any crit handling).JWSVerifier and the resolved key.ClaimCheckerManager with exp/nbf/iat/aud/iss mandatory.Do steps 4–6 even when the signature verifies. In Symfony, put this in a custom Authenticator/listener using JWSVerifierFactory; in Laravel, a middleware resolved from a service provider. Keep the failure response uniform — never leak which check failed.
OIDC / third-party IdP verification (JWKS). This is the highest-leverage use: verifying tokens from Auth0/Keycloak/Entra/etc.
kid, filter by use: sig and key_ops, and ignore keys whose alg conflicts with your allow-list.AlgorithmManager from the issuers you actually trust (RS256, ES256, EdDSA), not from whatever alg the token claims.kid. On miss, refetch once (rate-limited), then fail closed.jku or x5u URLs from the token header. That is an SSRF and trust-anchor bypass waiting to happen.Key rotation and publishing a JWKS endpoint.
Keep a current signing key and the previous public key(s). Sign with kid in the protected header; expose {"keys":[...]} at /.well-known/jwks.json. JWKSet::createFromJson()/JWKSet plus a loop over all() matching get('kid') is enough for selection. For asymmetric algorithms, JWKFactory can create RSA (createRSAKey), EC (createECKey), and OKP/Ed25519 (createOKPKey) keys; for production, generate private keys offline and load them from a secret store rather than generating at boot.
Use JWE when the payload is sensitive or must be hidden.
A JWS is signed, not encrypted — anyone holding the token can read the claims. For PII, use JWE (or nested JWT: sign then encrypt). You need two algorithm managers for JWE: key-encryption (alg, e.g. RSA-OAEP-256, A256KW, ECDH-ES) and content-encryption (enc, e.g. A256GCM). The compact serializer only supports one recipient; multi-recipient or multi-signature needs the JSON serializers.
Separate internal and external token profiles.
Internal service-to-service tokens: short TTL, aud set to the target service, Ed25519 or ES256, verified locally against a cached public key. User-facing access tokens: sub, scope, jti, 5–15 minute TTL, refresh handled by an opaque server-side token — not by a long-lived JWT. Different AlgorithmManager instances per profile make accidental cross-acceptance much harder.
Testing pattern.
Use fixed test keys (never production keys) and assert negative cases, not just the happy path: tampered payload, wrong aud, expired token, alg: none, unknown kid, HS256 token presented to an RS256 verifier. For time-sensitive claims, generate tokens with explicit iat/exp and either inject a clock into custom checkers or freeze time in the test; avoid sleep().
When not to reach for this package.
If you only need "sign an HS256 token and check exp", firebase/php-jwt or lcobucci/jwt will be less code. Choose web-token when you need JWK/JWKS, multiple algorithm families, JWE, standards-complete header/claim checkers, or the Symfony integration. That trade-off is worth stating explicitly in your team's ADR.
JWSVerifier::verifyWithKey() returns a boolean and does not check exp, nbf, aud, or iss. A token with a valid signature but the wrong audience is still valid cryptographically. Always run ClaimCheckerManager and pass mandatory claims explicitly.alg header is attacker-controlled. Bind verification to a fixed allow-list (AlgorithmManager + AlgorithmChecker) and, where possible, to the key's own alg. Never include None in a production manager, and never let the token choose the key type.kid handling is your job. The framework does not automatically pick a key from a JWKSet based on the header. Resolve it yourself; reject missing/unknown kid when more than one key is trusted, and reject duplicate kids in a set.false return tells you nothing. verifyWithKey() returning false is intentional API design, not a debugging aid. Wrap it and log the protected header ($jws->getSignature(0)->getProtectedHeader()), the kid, and the algorithm — never the token or claims.InvalidArgumentException separately from verification failure, and map both to the same 401 externally.JsonGeneralSerializer/JsonFlattenedSerializer. Mixing serializers between issue and verify is a common source of "valid token rejected" bugs.enc) manager, or omitting the key-wrapping algorithm from the alg manager, produces confusing "algorithm not supported" errors at build time, not at configuration time.zip/DEFLATE; it has been deprecated and creates decompression-bomb risk on verification.p2c iteration count and fixed ECDH-ES key derivation. If you accept password-based or ECDH-encrypted tokens from untrusted parties, stay on a patched 4.x release and read the security notes; pinning an old 3.x "because it works" is a liability.jwt) and 4.x (jose in current releases), and the factories only accept algorithms declared in config — calling JWSBuilderFactory::create(['RS512']) when config only enabled RS256 fails at runtime. Run config:dump-reference and cache:clear after changes. Also remember that inline JWKs in YAML end up in the compiled config cache: use env vars/secrets for private material and mark public keys with is_public: true.exp/nbf. In distributed systems, allow a small tolerance (a few seconds) via custom checker logic or by setting nbf slightly in the past — otherwise you will see intermittent 401s across nodes.ClaimChecker implementation (one checker per claim, returning the claim names it supports); custom headers in a HeaderChecker. Registering these keeps all policy in one pipeline instead of scattered if statements in controllers.kid, typ, and alg minimal, avoid embedding roles/permissions lists that can grow unbounded, and prefer scope strings plus a server-side lookup for large authorization data.JWSVerifier — do not rebuild the manager or refetch keys per request.How can I help you explore Laravel packages today?