JWKS (RS256 / ES256)
Rs256JwksJwtVerifier (v0.6.0) verifies asymmetric JWTs (RS256 / ES256) using a JWKS document. Pair it with BearerTokenAuthMiddleware + JwtBearerTokenVerifierAdapter (or any other auth middleware that consumes a JwtVerifierInterface) to authenticate OIDC-style tokens issued by an external provider.
Use this verifier when:
- the issuer publishes a JWKS endpoint (most OIDC providers, Auth0, Keycloak, Cognito, Azure AD, …);
- tokens are signed with a private key the API never sees (asymmetric);
- key rotation must Just Work without a deploy.
For first-party JWTs signed with a shared secret, keep using Hs256JwtVerifier.
Minimal example with a remote JWKS
use BetterRoute\Middleware\Jwt\HttpJwksProvider;
use BetterRoute\Middleware\Jwt\Rs256JwksJwtVerifier;
$jwks = new HttpJwksProvider(
jwksUri: 'https://issuer.example.com/.well-known/jwks.json',
ttlSeconds: 3600,
issuer: 'https://issuer.example.com'
);
$verifier = new Rs256JwksJwtVerifier(
jwks: $jwks,
leewaySeconds: 60,
expectedIssuer: 'https://issuer.example.com',
expectedAudience: 'better-route',
requireExpiration: true,
maxLifetimeSeconds: 3600,
maxTokenLength: 8192,
allowedAlgorithms: ['RS256']
);
Plug the verifier into a JwtAuthMiddleware:
use BetterRoute\Middleware\Jwt\JwtAuthMiddleware;
use BetterRoute\Middleware\Auth\WpClaimsUserMapper;
$jwt = new JwtAuthMiddleware(
verifier: $verifier,
requiredScopes: ['account:read'],
userMapper: new WpClaimsUserMapper()
);
Rs256JwksJwtVerifier constructor
new Rs256JwksJwtVerifier(
JwksProviderInterface $jwks,
int $leewaySeconds = 60,
?callable $now = null,
?string $expectedIssuer = null,
?string $expectedAudience = null,
bool $requireExpiration = true,
?int $maxLifetimeSeconds = null,
int $maxTokenLength = 8192,
array $allowedAlgorithms = ['RS256']
);
expectedIssuer/expectedAudience— strict equality;audmay be a string or a list inside the token.requireExpiration—trueby default. Tokens withoutexpare rejected.maxLifetimeSeconds— capsexp - iat. A misissued token with a 100-year lifetime is rejected before claims are trusted.maxTokenLength— guards against giant tokens before parsing.allowedAlgorithms— explicit list.noneand anyHS*value is rejected at construction even if accidentally configured. Currently supported:RS256andES256.
Hardening
The verifier deliberately refuses to be permissive:
- Strict
kidmatch. Only keys whosekidmatches the token header are considered. There is no "try every key" fallback. - One refresh, one retry. If the
kidis unknown, the JWKS provider is asked torefresh()and the lookup is retried once. Misses still fail closed. - Algorithm pinning.
noneandHS*are rejected at construction. The tokenalgmust match an entry inallowedAlgorithms. - Public-only JWK shape. RSA keys must declare
kty=RSAand carryn+e. ES256 keys must declarekty=EC,crv=P-256, and carryx+y. Optionalusemust besig. Optionalalgmust match the token algorithm. - Private fields stripped.
JwksKeySanitizerfilters JWKs through an explicit allowlist (alg,crv,e,kid,kty,n,use,x,y) before any provider hands them to the verifier — even if a misbehaving JWKS endpoint includes private components liked. - OpenSSL required. Verification uses
openssl_verify. The verifier throws if the OpenSSL extension is unavailable.
Providers
HttpJwksProvider
Fetches a remote JWKS through the WordPress HTTP API and caches it via transients.
new HttpJwksProvider(
string $jwksUri,
int $ttlSeconds = 3600,
?string $cacheKey = null,
?string $issuer = null,
?callable $httpGet = null,
?callable $getTransient = null,
?callable $setTransient = null,
?callable $deleteTransient = null
);
jwksUrimust behttps. The constructor throws otherwise.- The default HTTP client uses
wp_remote_getwithsslverify => trueand a10second timeout. Non-200 responses, empty bodies, andWP_Errorreturns throwRuntimeException. - The default cache key is
better_route_jwks_<sha1($jwksUri)>. PasscacheKeyif you want a stable identifier across deploys. issueris informational — used by the cache invalidation hook below.
StaticJwksProvider
In-memory provider for tests and pinned-key configurations:
use BetterRoute\Middleware\Jwt\StaticJwksProvider;
$provider = new StaticJwksProvider([
[
'kid' => 'kid-1',
'kty' => 'RSA',
'n' => '...base64url modulus...',
'e' => 'AQAB',
'alg' => 'RS256',
'use' => 'sig',
],
]);
Keys are sanitized at construction; private fields are dropped silently.
Custom providers
Implement BetterRoute\Middleware\Jwt\JwksProviderInterface:
interface JwksProviderInterface
{
/** @return list<array<string, string>> */
public function keys(): array;
public function refresh(): void;
}
refresh() is invoked by the verifier on a kid miss. Make it idempotent and fast.
Cache invalidation
HttpJwksProvider registers a WordPress action hook on construction:
do_action('better_route/jwks_refresh'); // refresh all
do_action('better_route/jwks_refresh', 'https://issuer.example.com'); // refresh that issuer only
When the provider was built with an issuer, the issuer-scoped variant only clears that provider. When called without an issuer, every registered provider clears its cache.
Trigger this from your auth provisioning flow when you rotate keys, or from a CLI/cron job after a webhook from the issuer.
Validation checklist
- a token with an unknown
kidtriggers a single JWKS refresh, then401 invalid_token; - a token signed with a key whose
use !== 'sig'is rejected; none/HS*configured inallowedAlgorithmsthrows at construction;HttpJwksProviderrejects non-httpsURIs;- private JWK fields (
d,p,q, …) are not exposed to the verifier even if the JWKS endpoint includes them.
Common mistakes
- Using
Rs256JwksJwtVerifierfor tokens signed with a shared secret — useHs256JwtVerifierfor that. - Setting
expectedAudienceto a different string than the issuer expects (audmismatch is a common cause of401 invalid_token). - Forgetting to register the cache-invalidation hook caller when you rotate keys (cached JWKS keeps serving the old set until TTL expires).
- Loading a JWKS over HTTP — the provider rejects non-
httpsURIs at construction.