JWT and Bearer
better-route ships two JWT verifiers:
Hs256JwtVerifier— symmetric (shared secret), since 0.3.0.Rs256JwksJwtVerifier— asymmetric (RS256/ES256) backed by JWKS, since 0.6.0.
Both implement JwtVerifierInterface and plug into JwtAuthMiddleware the same way. Pick HS256 for first-party tokens you sign yourself; pick the JWKS verifier for OIDC providers that publish a key set.
JWT with HS256 verifier
use BetterRoute\Middleware\Jwt\Hs256JwtVerifier;
use BetterRoute\Middleware\Jwt\JwtAuthMiddleware;
use BetterRoute\Middleware\Auth\WpClaimsUserMapper;
$verifier = new Hs256JwtVerifier(
secret: $_ENV['JWT_SECRET'],
leewaySeconds: 30,
expectedIssuer: 'https://issuer.example.com',
expectedAudience: 'myapp',
requireExpiration: true, // v0.3.0 default — set false to allow tokens without `exp`
maxLifetimeSeconds: 3600, // optional cap on `exp - iat`; requires both `iat` and `exp` (v1.1.0)
maxTokenLength: 8192 // v0.3.0 default
);
$jwtMiddleware = new JwtAuthMiddleware(
verifier: $verifier,
requiredScopes: ['content:*'],
userMapper: new WpClaimsUserMapper()
);
v0.6.0 internals
Hs256JwtVerifier was rewired to use the shared Crypto helper for constant-time signature comparison and base64url decoding. Public behavior is unchanged — the same shared crypto primitives are now reusable outside the verifier.
v0.3.0 hardening
Hs256JwtVerifier enforces (defaults in parentheses):
exprequired by default (requireExpiration: true). Tokens withoutexpare rejected unless explicitly disabled.expectedIssuer/expectedAudiencefor strictiss/audvalidation when set.audmatches against either a string or a list of audiences in the token.maxLifetimeSecondsrejects tokens whoseexp - iatexceeds the cap. (v1.1.0) When the cap is configured, tokens must carry bothiatandexp— previously a token withoutiatskipped the lifetime check entirely; now it is rejected (401 invalid_token). Applies toHs256JwtVerifierandRs256JwksJwtVerifieralike.maxTokenLength(8192bytes) rejects oversized tokens before parsing.
WpClaimsUserMapper defaults changed: idClaims is now ['user_id', 'uid', 'wp_user_id']. Re-add 'sub' explicitly if your tokens use it as the WP user identifier.
Since 1.0.0: WpClaimsUserMapper no longer maps email or login/username claims by default — $emailClaims and $loginClaims default to empty, so only id claims and an explicit $customResolver resolve a WP user out of the box. When you opt into email mapping it requires a truthy email_verified claim ($requireEmailVerified, default true). This prevents account takeover from a validly signed third-party token carrying an unverified email; prefer a first-party user_id claim or an issuer-scoped custom resolver.
JWT with RS256/ES256 (JWKS) verifier
For tokens issued by an OIDC provider, use Rs256JwksJwtVerifier with HttpJwksProvider:
use BetterRoute\Middleware\Jwt\HttpJwksProvider;
use BetterRoute\Middleware\Jwt\Rs256JwksJwtVerifier;
$jwks = new HttpJwksProvider(
jwksUri: 'https://issuer.example.com/.well-known/jwks.json',
issuer: 'https://issuer.example.com'
);
$verifier = new Rs256JwksJwtVerifier(
jwks: $jwks,
expectedIssuer: 'https://issuer.example.com',
expectedAudience: 'better-route',
allowedAlgorithms: ['RS256']
);
The full hardening rules (strict kid matching, algorithm pinning, private-field stripping) live on the dedicated JWKS RS256 / ES256 page.
Bearer middleware with custom verifier
use BetterRoute\Middleware\Auth\BearerTokenAuthMiddleware;
$bearer = new BearerTokenAuthMiddleware(
verifier: $customVerifier,
requiredScopes: ['api:read'],
userMapper: new WpClaimsUserMapper(),
provider: 'partner_bearer'
);
Scope matching
Server-defined required-scope wildcards are always honored:
- required
content:*matches grantedcontent:read
Since 1.0.0: a trailing * on a granted (token-supplied) scope is treated as a literal by default, so token data cannot widen its own authority. To honor hierarchical wildcard grants (e.g. granted content:* satisfying required content:read), opt in with allowGrantedScopeWildcards: true on JwtAuthMiddleware / BearerTokenAuthMiddleware:
$jwtMiddleware = new JwtAuthMiddleware(
verifier: $verifier,
requiredScopes: ['content:read'],
allowGrantedScopeWildcards: true, // honor a granted `content:*`
);
BearerTokenAuthMiddleware and JwtAuthMiddleware accept both array scope claims and OIDC-style space-delimited strings. 0.6.0 adds regression coverage for the string-shape behavior.
Common mistakes
- Empty JWT secret
- Using a non-HS256 token with
Hs256JwtVerifier— switch toRs256JwksJwtVerifierfor RS256/ES256 - Expecting scopes from an unsupported claim shape
- (v0.3.0) issuing tokens without
expwhilerequireExpirationis on - (v0.3.0) relying on
subfor WP user mapping without re-adding it toWpClaimsUserMapper::$idClaims - (v1.1.0) issuing tokens without
iatwhilemaxLifetimeSecondsis configured — the lifetime cap now requires bothiatandexp
Validation checklist
- malformed token ->
401 invalid_token - scope mismatch ->
403 insufficient_scope - mapped WP user is set when mapper resolves userId