facile-it/php-jose-verifier
A lightweight PHP library for validating JWT tokens from OpenID and OAuth2 providers. It builds verifiers and decrypters automatically from issuer and client metadata, fetching public keys from a jwks_uri endpoint, with optional GMP support for better performance.
Install
composer require facile-it/php-jose-verifier
Add ext-gmp to your composer.json require block and to your Docker/Forge/Envoyer build image. The README calls it out explicitly: RSA signature verification falls back to a material slower path without it, and this is the kind of thing you notice only at p95 under load.
Where to look first
Facile\JoseVerifier\Builder\* — everything you actually use lives here (AccessTokenVerifierBuilder, IdTokenVerifierBuilder, UserInfoVerifierBuilder). The rest of the package is @internal / implementation detail.Facile\JoseVerifier\Exception\InvalidTokenExceptionInterface — the one exception type you catch.web-token/* for web-token/jwt-library; the 1.0 line jumps that library to 4.x. Under 0.x semver, assume a minor bump can break you.First use case: validate an access token from an external IdP
This is the hole in the Laravel ecosystem the package fills — you are the resource server, Keycloak/Auth0/Okta/Entra ID issues the tokens, and the app has never seen the signing key. A tinker smoke test proves the wiring in about five minutes:
use Facile\JoseVerifier\Builder\AccessTokenVerifierBuilder;
use Facile\JoseVerifier\Exception\InvalidTokenExceptionInterface;
$issuer = [
'issuer' => 'https://keycloak.example.com/realms/acme',
'jwks_uri' => 'https://keycloak.example.com/realms/acme/protocol/openid-connect/certs',
];
$client = ['client_id' => 'my-laravel-app'];
$verifier = AccessTokenVerifierBuilder::create($issuer, $client)->build();
try {
$payload = $verifier->verify($jwtFromAuthorizationHeader);
} catch (InvalidTokenExceptionInterface $e) {
report($e);
}
$payload is the decoded claims array (sub, aud, exp, scope, custom roles) — that's what you feed into your authorization logic.
Do not reach for this package if you are issuing your own tokens. Passport and tymon/jwt-auth own that. This is strictly the "trust someone else's IdP" piece.
Config + service provider, per issuer
Builders are immutable and cheap to configure, so keep the metadata in config/oidc.php and bind one verifier singleton per named issuer. Multi-tenant (one Laravel app, several IdPs) works naturally this way — key the container binding by tenant:
// config/oidc.php
return [
'default' => env('OIDC_ISSUER', 'acme'),
'issuers' => [
'acme' => [
'issuer' => env('OIDC_ISSUER_URL'),
'jwks_uri' => env('OIDC_JWKS_URI'),
'client' => [
'client_id' => env('OIDC_CLIENT_ID'),
'client_secret' => env('OIDC_CLIENT_SECRET'),
],
'cache_ttl' => 86400,
],
],
];
// AppServiceProvider::register()
$this->app->singleton(AccessTokenVerifierBuilder::class, function ($app) {
$cfg = config('oidc.issuers.' . config('oidc.default'));
$jwks = (new JwksProviderBuilder())
->withCache($app->make(Psr\SimpleCache\CacheInterface::class))
->withCacheTtl($cfg['cache_ttl']);
return AccessTokenVerifierBuilder::create(
['issuer' => $cfg['issuer'], 'jwks_uri' => $cfg['jwks_uri']],
$cfg['client']
)->withJwksProviderBuilder($jwks);
});
Build the verifier once per request (or once per Octane worker — it's immutable and safe to hold).
Middleware guard
The idiomatic Laravel shape is a middleware that verifies the bearer token and stashes claims on the request, rather than fighting Auth::guard():
public function handle($request, Closure $next)
{
$jwt = $request->bearerToken();
abort_unless($jwt, 401);
try {
$claims = app(AccessTokenVerifierBuilder::class)->build()->verify($jwt);
} catch (InvalidTokenExceptionInterface $e) {
abort(401, 'Invalid token');
}
$request->attributes->set('oidc.claims', $claims);
// or: Auth::setUser(new OidcUser($claims));
return $next($request);
}
OIDC login flow: id_token
IdTokenVerifier is the relying-party half of the authorization code flow. Note the extra immutable with* calls — they aren't optional decoration, they enable the at_hash / c_hash / s_hash checks:
$verifier = IdTokenVerifierBuilder::create($issuer, $client)
->build()
->withState($state) // enables s_hash validation
->withAccessToken($accessToken) // enables at_hash validation
->withCode($code); // enables c_hash validation
$claims = $verifier->verify($idToken);
Skipping withAccessToken() means at_hash is silently unchecked, not rejected. Pass everything you have.
UserInfo verifier
When the userinfo endpoint returns a signed/encrypted JWT body instead of plain JSON, UserInfoVerifierBuilder decrypts and validates in one shot. Same builder signature, same exception type — worth a separate binding if you use it, since the client metadata response algs differ (userinfo_signed_response_alg vs id_token_signed_response_alg).
Bridging Laravel's cache and HTTP client
The package speaks PSR-16 and PSR-18; Laravel speaks its own. Two small bindings buy you the whole integration:
symfony/cache's Psr16Cache wrapping a Laravel store adapter, or ~40 lines of your own CacheInterface implementing get/set/delete/has against Cache::store(). Without it, every request hits jwks_uri.Http facade middleware (retry, telemetry logging) for free; wrap Guzzle with a retry middleware if you care.The PSR-16 cache mismatch is the #1 operational trap. Illuminate\Contracts\Cache\Repository does not implement Psr\SimpleCache\CacheInterface. Wire the adapter, or your JWKS endpoint becomes a hard dependency of every authenticated request — a latency tax and a single point of failure.
JWKS caching has a rotation window. withCacheTtl(86400) is the default and means a rotated signing key isn't seen for up to a day. Either shorten the TTL or handle verification failure by busting the cache and retrying once before you 401. Cheap insurance against a mysterious outage the day your IdP rotates keys.
Catch InvalidTokenExceptionInterface, not a concrete class. It's an interface with multiple implementations behind it (expired, bad signature, bad audience, malformed). If you want to distinguish 401-vs-400 or log a meaningful message, inspect the concrete type or the message — but always catch the interface as a floor.
Test clock injection. Since 0.4.4 the library takes a Psr\Clock\ClockInterface, so you can freeze time in tests instead of sleeping past exp. Bind a fake clock in your test container; verifying exp/nbf behaviour otherwise means real sleep() calls in CI.
auth_time is required when require_auth_time is true (fixed in 0.5.0). If you consume IdP metadata that sets it and your IdP omits the claim, verification fails — and that's correct behaviour, not a bug to work around.
at_hash/c_hash are only checked when you supply the corresponding values (0.4.5 deliberately stopped requiring them). This is the single most common silent-security-gap in integrations: the verifier passes, but token substitution was never actually blocked. Make withState()/withAccessToken()/withCode() unconditional in your flow code.
Pin the version exactly. "facile-it/php-jose-verifier": "1.0.*" (or 0.5.* if you're still on the old line), not ^. Minor bumps have been breaking in practice, and 1.0.0 requires PHP 8.2+ and web-token/jwt-library 4.x — treat upgrades as change-managed work with a test suite that exercises a real token.
Watch the transitive crypto tree, not just this package. Real CVEs will land in web-token/jwt-library and its dependencies. Your composer audit in CI needs to cover the subtree; a green top-level audit means nothing here.
No revocation, no introspection, no opaque tokens. The library verifies self-contained JWTs only. Anything needing immediate invalidation requires short token lifetimes, a denylist keyed on jti you maintain yourself, or a token-introspection call you write by hand. Don't assume verify() protects you against a compromised-but-unexpired token.
Bus factor 1. Five stars, zero dependents, single-vendor stewardship (Facile.it). It's well-built and fills a real gap, but budget for the "we fork or vendor this" scenario in your risk register, and keep your integration surface thin — one service provider, one middleware, one adapter — so a swap to firebase/php-jwt or a hand-rolled web-token integration stays a contained change.
Extension point: if you need behaviour the builders don't expose (custom claim checkers, non-standard validation), pass a configured JwksProviderBuilder and compose around the verifier rather than subclassing — the internals are @internal and will move.
How can I help you explore Laravel packages today?