Install
$ agentstack add skill-lonsdale201-wp-agent-skills-br-auth-middleware ✓ scanned · ✓ verified — works with Claude Code, Cursor, and more.
Security review
✓ PassedNo issues found. Passed automated security review. · v0.1.0 How review works →
- ✓ Prompt-injection patterns
- ✓ Secret / credential exfiltration
- ✓ Dangerous shell & filesystem operations
- ✓ Untrusted network calls
- ✓ Known-malicious package signatures
What it can access
- ✓ Network access No
- ✓ Filesystem access No
- ✓ Shell / process execution No
- ✓ Environment & secrets No
- ✓ Dynamic code execution No
From automated source analysis of v0.1.0. “Used” means the capability is present in the source — more access means more to trust, not that it’s unsafe.
About
better-route: Authentication middleware
For developers protecting endpoints with authentication via better-route's middleware pipeline. Core patterns: JWT, custom bearer tokens, WordPress application passwords, and browser cookie + nonce auth. Since 0.6.0, JWT verification can be HS256 or asymmetric RS256/ES256 from JWKS.
Misconception this skill corrects
> "I'll use JwtAuthMiddleware with my existing JWT tokens — they don't have an exp claim because they're long-lived."
Post-v0.3.0, Hs256JwtVerifier REQUIRES an exp claim by default ([src/Middleware/Jwt/Hs256JwtVerifier.php:23](Hs256JwtVerifier.php) — requireExpiration = true). Tokens without exp are rejected at line 116. The fix:
// EITHER add exp to your tokens (preferred — long-lived no-exp tokens are a security smell):
$verifier = new Hs256JwtVerifier(secret: $secret); // exp required, default
// OR explicitly disable for migration scenarios:
$verifier = new Hs256JwtVerifier(secret: $secret, requireExpiration: false);
The same release also removed sub from WpClaimsUserMapper's default idClaims ([WpClaimsUserMapper.php:30](WpClaimsUserMapper.php)):
array $idClaims = ['user_id', 'uid', 'wp_user_id'],
If your tokens use sub as the user identifier, re-add it explicitly:
$mapper = new WpClaimsUserMapper(idClaims: ['sub', 'user_id', 'uid', 'wp_user_id']);
Other AI-prone misconceptions:
- "I'll combine
JwtAuthMiddlewarewith->permission(...)for belt-and-suspenders." Wrong — pick one. Use->protectedByMiddleware('jwt')to defer to the middleware OR->permission(callable)for WP-cap checks. Combining means BOTH must pass; usually overshoots intent. - "I'll bind the JWT middleware to a specific route via
->middleware(...)and forget about route-layer permission." Wrong (post-v0.4.0) —->middleware(...)adds the JWT to the pipeline, but the WP permission layer still denies writes by default. You also need->protectedByMiddleware()to set WP permission to__return_true. - "
CookieNonceAuthMiddlewareworks for AJAX from logged-in browser users automatically." Almost — needs the request to carry anX-WP-Nonceheader (or a body field) AND the user to be logged in. AJAX from logged-in users naturally carries WP cookies; you need to manually attach the nonce client-side viawpApiSettings.nonce.
When to use this skill
Trigger when ANY of the following is true:
- The diff instantiates
JwtAuthMiddleware,BearerTokenAuthMiddleware,ApplicationPasswordAuthMiddleware, orCookieNonceAuthMiddleware. - The diff calls
Hs256JwtVerifier,WpClaimsUserMapper. - A route uses
->protectedByMiddleware(...). - Tokens that worked pre-v0.3.0 now fail with
invalid_token.
Workflow
1. Choose the right middleware
| Use case | Middleware | |---|---| | Server-issued JWT tokens (HS256, you control the secret) | JwtAuthMiddleware + Hs256JwtVerifier | | OIDC/OAuth bearer JWTs (RS256/ES256 from JWKS) | BearerTokenAuthMiddleware + JwtBearerTokenVerifierAdapter + Rs256JwksJwtVerifier | | Custom bearer tokens (opaque tokens you verify against your DB / third-party) | BearerTokenAuthMiddleware + your BearerTokenVerifierInterface | | Native WP authentication (admin tools, mobile apps using WP user accounts) | ApplicationPasswordAuthMiddleware | | Browser-side AJAX from logged-in users | CookieNonceAuthMiddleware |
2. JWT auth (most common for SPA / mobile clients)
use \BetterRoute\Middleware\Jwt\JwtAuthMiddleware;
use \BetterRoute\Middleware\Jwt\Hs256JwtVerifier;
use \BetterRoute\Middleware\Auth\WpClaimsUserMapper;
$verifier = new Hs256JwtVerifier(
secret: 'your-secret-key', // shared secret with the issuer
leewaySeconds: 30, // clock-skew tolerance
expectedIssuer: 'https://issuer.example.com', // strict iss check (null = no check)
expectedAudience: 'myapp', // strict aud check (null = no check)
requireExpiration: true, // v0.3.0 default; reject tokens missing exp
maxLifetimeSeconds: 3600, // reject tokens with exp - iat > 3600 (1 hour cap)
maxTokenLength: 8192, // reject oversized tokens before parsing
);
$mapper = new WpClaimsUserMapper(
idClaims: ['sub', 'user_id', 'uid', 'wp_user_id'], // re-add 'sub' explicitly
);
$jwt = new JwtAuthMiddleware(
verifier: $verifier,
requiredScopes: ['api:read'], // optional scope-based authorization
userMapper: $mapper,
);
// Apply to a route group:
$router->group('/protected', function ($group) use ($jwt) {
$group->get('/me', fn ($ctx) => Response::ok($ctx->user));
$group->put('/me', $updateProfile)->protectedByMiddleware('jwt');
})->middleware($jwt);
For write routes, ALSO apply ->protectedByMiddleware() at the route layer so the WP permission layer doesn't deny by default. The middleware does the actual auth; ->protectedByMiddleware() just opens the WP gate.
3. JWT verifier flags (v0.3.0 reference)
| Constructor param | Default | Behavior | |---|---|---| | secret | required | HS256 secret. Compare against header alg HS256. | | leewaySeconds | 0 | Clock skew tolerance for nbf / exp checks. | | expectedIssuer | null | If set, iss claim must match exactly. | | expectedAudience | null | If set, aud claim must match exactly (string or in array). | | requireExpiration | true | Reject tokens without exp. Set to false only for migration. | | maxLifetimeSeconds | null | If set, reject tokens with exp - iat > maxLifetimeSeconds. | | maxTokenLength | 8192 | Reject oversized tokens before base64 decode (DoS guard). |
Verified at [Hs256JwtVerifier.php:21-25](Hs256JwtVerifier.php), enforcement at lines 42 (length), 116 (exp required), 140-145 (max lifetime), 154-180 (iss / aud).
3b. RS256/ES256 JWT via JWKS (v0.6.0)
For third-party OIDC/OAuth access tokens, use the dedicated JWKS verifier rather than a custom implementation:
use \BetterRoute\Middleware\Auth\BearerTokenAuthMiddleware;
use \BetterRoute\Middleware\Auth\JwtBearerTokenVerifierAdapter;
use \BetterRoute\Middleware\Jwt\HttpJwksProvider;
use \BetterRoute\Middleware\Jwt\Rs256JwksJwtVerifier;
$verifier = new Rs256JwksJwtVerifier(
jwks: new HttpJwksProvider('https://issuer.example.com/jwks.json'),
expectedIssuer: 'https://issuer.example.com',
expectedAudience: 'my-api',
allowedAlgorithms: ['RS256'],
);
$auth = new BearerTokenAuthMiddleware(
verifier: new JwtBearerTokenVerifierAdapter($verifier),
requiredScopes: ['api:read'],
);
Strict rules live in br-jwks-jwt-auth: kid must match exactly, none and HS* are rejected, HTTPS JWKS is required, and refresh() is attempted only once on key miss.
4. Custom bearer tokens (opaque, DB-backed)
use \BetterRoute\Middleware\Auth\BearerTokenAuthMiddleware;
use \BetterRoute\Middleware\Auth\BearerTokenVerifierInterface;
class MyTokenVerifier implements BearerTokenVerifierInterface
{
public function verify(string $token): array
{
// Look up token in DB / third-party / cache
$row = MyDb::get('SELECT user_id, scopes FROM api_tokens WHERE token = %s', $token);
if ($row === null) {
throw new \BetterRoute\Http\ApiException('Invalid token.', 401, 'invalid_token');
}
return [
'sub' => (string) $row->user_id,
'scopes' => explode(' ', $row->scopes),
];
}
}
$bearer = new BearerTokenAuthMiddleware(
verifier: new MyTokenVerifier(),
requiredScopes: ['orders:read'],
userMapper: new WpClaimsUserMapper(idClaims: ['sub']),
);
$router->group('/api', function ($group) {
// ...
})->middleware($bearer);
The verifier returns a "claims" array — same shape as JWT claims, even though the token is opaque. The downstream code (scope check, user mapping) is identical.
5. WordPress application passwords
use \BetterRoute\Middleware\Auth\ApplicationPasswordAuthMiddleware;
$appPwd = new ApplicationPasswordAuthMiddleware();
$router->group('/api', function ($group) {
// ...
})->middleware($appPwd);
The middleware reads the standard WP Authorization: Basic header. Application passwords are WP 5.6+ native — users generate them in their profile, no plugin needed. Best for first-party tools and mobile apps with full WP user accounts.
6. Cookie + nonce (browser AJAX)
use \BetterRoute\Middleware\Auth\CookieNonceAuthMiddleware;
$cookieAuth = new CookieNonceAuthMiddleware(
nonceAction: 'wp_rest', // default — matches wpApiSettings.nonce
requireNonce: true,
requireLoggedIn: true,
);
$router->group('/internal', function ($group) {
// ...
})->middleware($cookieAuth);
Client-side, attach the nonce:
fetch('/wp-json/myapp/v1/internal/widget', {
headers: {
'X-WP-Nonce': wpApiSettings.nonce,
},
credentials: 'same-origin',
});
wpApiSettings.nonce is automatically available when wp_enqueue_script('wp-api') runs OR when you've enqueued any script that depends on wp-api-fetch (which most Gutenberg / block editor pages do).
7. Combining middleware
Middleware chains run top-down. For "rate-limit before auth":
$router->group('/api', function ($group) { /* ... */ })
->middleware([$rateLimit, $jwt]); // rate limit first, then auth
For "auth before rate limit" (so authenticated users get a higher rate limit):
->middleware([$jwt, $rateLimit])
The middleware order matters — RateLimitMiddleware's default key resolver returns the authenticated user's ID when the auth middleware ran first. Without auth-before-rate-limit, the rate limiter falls back to anonymous keys.
Critical rules
- JWT
expclaim REQUIRED by default (v0.3.0+). PassrequireExpiration: falseonly for migration scenarios. - Use
br-jwks-jwt-authfor RS256/ES256 JWKS tokens (v0.6.0+). Do not write a fallback verifier that tries every key. WpClaimsUserMapperdoes NOT includesubby default (v0.3.0+). Re-add it explicitly viaidClaims: ['sub', ...]if your tokens usesubas user ID.- Set
expectedIssuerANDexpectedAudiencefor production JWT. Without them, any JWT signed with your secret is accepted, regardless of issuer / audience — no defense against cross-service token reuse. maxLifetimeSecondscaps token age. A token withexp - iat = 86400 * 365(a year-long token) bypasses your security policy if you don't cap.maxTokenLength: 8192is the DoS guard — rejects oversized tokens before parsing. Don't disable.- Always
->protectedByMiddleware()at the route layer for write routes when auth is via middleware — otherwise WP layer denies first (post-v0.4.0). - Middleware order matters. Auth-before-rate-limit lets the rate limiter key on user ID; reversed order keys on anonymous IP.
CookieNonceAuthMiddlewarerequires the client to sendX-WP-Nonce. WP cookies alone aren't enough.- Custom bearer verifiers must throw
ApiException(or return claims). Returningnull/falseis undefined behavior.
Common mistakes
// WRONG — JWT verifier without exp requirement, no iss/aud check
$verifier = new Hs256JwtVerifier(
secret: 'key',
requireExpiration: false,
);
// Anyone with the secret can issue eternal tokens for any audience.
// RIGHT — production-grade
$verifier = new Hs256JwtVerifier(
secret: 'key',
expectedIssuer: 'https://my-issuer.example.com',
expectedAudience: 'my-api',
requireExpiration: true,
maxLifetimeSeconds: 3600,
);
// WRONG — middleware applied but WP layer still denies
$router->post('/secure', $handler)->middleware($jwt); // (no protectedByMiddleware)
// → 403 from WP layer; middleware never runs
// RIGHT — declare both
$router->post('/secure', $handler)
->protectedByMiddleware('jwt')
->middleware($jwt);
// (or apply via group middleware which handles this automatically)
// WRONG — assuming sub maps to user ID by default
$verifier = new Hs256JwtVerifier(secret: $key);
$middleware = new JwtAuthMiddleware($verifier, userMapper: new WpClaimsUserMapper());
// Token has 'sub' = '5' (user ID 5) but mapper looks for 'user_id'. User is anonymous in the request context.
// RIGHT — explicit idClaims with sub
$mapper = new WpClaimsUserMapper(idClaims: ['sub', 'user_id']);
// WRONG — combining permission and protectedByMiddleware
$router->post('/foo', $handler)
->permission(static fn () => current_user_can('edit_posts'))
->protectedByMiddleware('jwt');
// permission overrides the __return_true that protectedByMiddleware sets — middleware delegation lost.
// RIGHT — pick one
$router->post('/foo', $handler)
->protectedByMiddleware('jwt');
// WRONG — middleware order with rate limit before auth (when you want per-user limits)
->middleware([$rateLimit, $jwt])
// → rate limit keys on anonymous IP because auth hasn't run yet.
// RIGHT
->middleware([$jwt, $rateLimit])
// WRONG — application passwords without HTTPS
$middleware = new ApplicationPasswordAuthMiddleware();
// On HTTP, the password is sent base64-encoded in plaintext over the wire.
// RIGHT — only deploy on HTTPS
// WRONG — sharing JWT secret across multiple services
$verifier = new Hs256JwtVerifier(secret: $sharedSecret); // ← also used by 3 other services
// Any compromise of one service compromises all.
// RIGHT — per-service secret OR upgrade to RS256 / ES256 via Rs256JwksJwtVerifier
// WRONG — long-running tokens without rotation
maxLifetimeSeconds: 86400 * 30, // 30-day tokens
// Plus no refresh-token flow → users with stolen tokens have a month of access.
// RIGHT — short-lived access tokens + refresh tokens
maxLifetimeSeconds: 3600, // 1 hour
// (refresh-token implementation is your concern; better-route doesn't ship one)
Cross-references
- Run
br-routesfor the route-layer->protectedByMiddleware()mechanics — auth middleware needs it for v0.4.0+ writes. - Run
br-jwks-jwt-authfor RS256/ES256 OIDC/OAuth JWT verification from JWKS. - Run
br-hmac-signaturefor signed server-to-server/webhook endpoints where no user bearer token is involved. - Run
br-rate-limitingfor combining auth with rate limiting — middleware order matters. - Run
br-error-contractfor the standard error envelope — auth failures return401 invalid_tokenshape. - Run
br-install-and-migratefor the v0.3.0 JWT breaking changes (exp, sub) summary.
What this skill does NOT cover
- Token issuance / refresh-token flow. Better-route VERIFIES tokens; issuance is your responsibility (use any standard JWT library on your auth server).
- OAuth authorization-code, refresh-token, and client credentials flows. Better-route provides middleware primitives, not a complete OAuth server/client implementation.
- WP-side capability mapping. Once authenticated, the user is mapped via
WpClaimsUserMapper; subsequent permission checks usecurrent_user_canagainst the mapped user. - Multi-factor auth. WP user accounts handle MFA via plugins; better-route consumes the resolved authenticated user.
- API-key-style auth (single static keys per consumer). Use
BearerTokenAuthMiddlewarewith a verifier that matches against your keys table.
References
- JwtAuthMiddleware: [libraries/better-route/src/Middleware/Jwt/JwtAuthMiddleware.php:24-29](JwtAuthMiddleware.php) —
__construct(verifier, requiredScopes, userMapper). - Hs256JwtVerifier: [libraries/better-route/src/Middleware/Jwt/Hs256JwtVerifier.php:18-26](Hs256JwtVerifier.php) — full constructor with v0.3.0 defaults.
- Rs256JwksJwtVerifier: [libraries/better-route/src/Middleware/Jwt/Rs256JwksJwtVerifier.php](Rs256JwksJwtVerifier.php) — RS256/ES256 JWKS verifier added in v0.6.0.
- HttpJwksProvider: [libraries/better-route/src/Middleware/Jwt/HttpJwksProvider.php](HttpJwksProvider.php) — HTTPS JWKS fetch + transient cache.
- WpClaimsUser
…
Source & license
This open-source skill is cataloged on AgentStack and links to its original source — we do not rehost the code.
- Author: Lonsdale201
- Source: Lonsdale201/wp-agent-skills
- License: MIT
Install and usage instructions live in the source repository linked above.
Reviews
No reviews yet — be the first.
Write a review
Versions
- v0.1.0 Imported from the upstream source.