JWT Authentication¶
Note
Not part of core. Install it separately:
composer require kinetis/auth-jwt
Stateless JWT authentication: a PSR-15 route middleware that verifies an
Authorization: Bearer <token> header’s signature and registers the
decoded claims on the current request as CurrentUserInterface, plus an
issuer for signing tokens. Verification via
firebase/php-jwt — no
database or cache lookup, and no equivalent of Authentication’s
UserProviderInterface: the signed claims are the entire authentication
decision.
use Kinetis\AuthJwt\JwtAuthMiddleware;
use Kinetis\Container\RequestScope;
use Kinetis\Http\Attributes\Get;
use Kinetis\Http\Attributes\Middleware;
use Kinetis\Http\CurrentUserInterface;
final class AppJwtAuthMiddleware extends JwtAuthMiddleware
{
public function __construct(RequestScope $scope)
{
parent::__construct(getenv('JWT_SECRET') ?: '', $scope);
}
}
#[Middleware(AppJwtAuthMiddleware::class)]
final readonly class OrderController
{
public function __construct(
private CurrentUserInterface $user,
) {}
#[Get('/orders')]
public function index(): array
{
return ['userId' => $this->user->id()];
}
}
Why a subclass, not a container binding¶
#[Middleware(...)] only ever carries a class-string, with nowhere to
pass a key directly. JwtAuthMiddleware is deliberately not final, so a
small subclass like the one above can supply your secret through its own
constructor instead — because its only parameters are RequestScope and
(optionally) your own Config, both class-typed, Kinetis can build it
automatically with no extra setup at all.
Warning
Don’t register JwtAuthMiddleware::class itself on AppScope with a
factory that also resolves RequestScope — AppScope will autowire a
brand-new, disconnected RequestScope instead of reaching the real
per-request one, since it falls back to autowiring any real class it has
no explicit binding for. The subclass above avoids this entirely: it’s
resolved through the request’s own RequestScope, which already has
itself registered.
Issuing tokens: JwtIssuer¶
use Kinetis\AuthJwt\JwtIssuer;
$issuer = new JwtIssuer(getenv('JWT_SECRET') ?: '');
$token = $issuer->issue($user->id()); // 1 hour expiry
$token = $issuer->issue($user->id(), ['role' => 'admin']); // extra claims
$token = $issuer->issue($user->id(), ttlSeconds: 3600 * 24 * 30); // 30 days
$token = $issuer->issue($user->id(), ttlSeconds: null); // never expires
sub (the subject — always your passed-in id, coerced to a string),
iat, and jti (a random, unique token ID — see “Revoking tokens” below)
always win over an extra claim of the same name, so a stray
['sub' => ...] in $claims can’t accidentally override the real
subject. Signing only — verifying a password and returning the resulting
token to the client is your own login endpoint’s job.
Reading claims beyond id()¶
CurrentUserInterface::id() only ever guarantees the subject.
JwtAuthMiddleware registers a JwtUser, which exposes the rest of the
token’s claims directly — inject JwtUser instead of CurrentUserInterface
where you need one:
use Kinetis\AuthJwt\JwtUser;
use Kinetis\Http\Attributes\Get;
final readonly class OrderController
{
public function __construct(
private JwtUser $user,
) {}
#[Get('/orders')]
public function index(): array
{
return [
'userId' => $this->user->id(),
'role' => $this->user->claim('role'),
];
}
}
Revoking tokens: RevocationStore¶
A verified signature alone can’t express “this specific token shouldn’t
work anymore” — that’s the one thing a stateless JWT structurally can’t
do on its own. RevocationStore closes that gap with a cache-backed
denylist, keyed by the jti claim every JwtIssuer-issued token already
carries:
use Kinetis\AuthJwt\RevocationStore;
use Psr\SimpleCache\CacheInterface;
final class AppJwtAuthMiddleware extends JwtAuthMiddleware
{
public function __construct(RequestScope $scope, CacheInterface $cache)
{
parent::__construct(
getenv('JWT_SECRET') ?: '',
$scope,
revocationStore: new RevocationStore($cache),
);
}
}
A logout endpoint revokes the current token by injecting JwtUser
(not CurrentUserInterface — you need claim('jti'), which only
JwtUser exposes) and handing it straight to revokeToken():
use Kinetis\AuthJwt\JwtUser;
use Kinetis\AuthJwt\RevocationStore;
use Kinetis\Http\Attributes\Post;
final readonly class LogoutController
{
public function __construct(
private JwtUser $user,
private RevocationStore $revocationStore,
) {}
#[Post('/logout')]
public function invoke(): array
{
$this->revocationStore->revokeToken($this->user);
return ['loggedOut' => true];
}
}
Note
The denylist entry’s TTL is derived from the token’s own exp claim, not
a fixed duration — once the token would have expired naturally anyway,
there’s nothing left to revoke, so the entry is dropped too. A token
issued with ttlSeconds: null (no expiry) has nothing to bound the entry
by; revoking one is effectively a no-op. Give a token you intend to be
able to revoke a real expiry.
revocationStore is optional and null by default — every example
earlier on this page works with zero revocation checking, at zero extra
cache cost.
Logging out everywhere¶
revokeToken() only logs out the one token you hand it — “log out this
session.” To invalidate every token a user currently holds, across every
device they’re logged in on, use revokeAllForUser() instead:
use Kinetis\AuthJwt\RevocationStore;
use Kinetis\Http\Attributes\Post;
use Kinetis\Http\CurrentUserInterface;
final readonly class LogoutEverywhereController
{
public function __construct(
private CurrentUserInterface $user,
private RevocationStore $revocationStore,
) {}
#[Post('/logout-everywhere')]
public function invoke(): array
{
$this->revocationStore->revokeAllForUser($this->user->id(), ttlSeconds: 3600);
return ['loggedOut' => true];
}
}
Any token issued before this call stops working immediately; a fresh
login right afterward — including the user’s own, if they log back in on
this device — still works normally, since its own iat is after the
cutoff.
ttlSeconds here isn’t a token’s own remaining lifetime the way it is for
revokeToken() — there’s no single token to derive it from, since this
covers every token the user might be holding. Pass however long your app’s
longest-lived token can stay valid (matching whatever ttlSeconds you
pass to JwtIssuer::issue()); anything shorter risks the cutoff itself
expiring while an old token is technically still unexpired.
Failure, expiry, and revocation¶
An expired, badly signed, malformed, subject-less, or revoked token all
produce the same 401, with a WWW-Authenticate: Bearer header, before
your controller runs — matching Authentication’s BearerAuthMiddleware
failure shape exactly:
{"error": "Unauthenticated."}
An empty or malformed key on your own side is not caught here — that’s a
misconfiguration, not a client-supplied bad token, and surfaces as a real
error rather than a silent 401.
Algorithms¶
HS256 by default — a shared secret, symmetric algorithm, passed as the
same string to both JwtIssuer and JwtAuthMiddleware. HS384/HS512
work the same way — just a different algorithm name, same shared secret
on both sides.
RS256 (and RS384/RS512) use a key pair instead of a shared secret
— JwtIssuer takes the private key, JwtAuthMiddleware takes the
public one, both as PEM-format strings:
use Kinetis\AuthJwt\JwtAuthMiddleware;
use Kinetis\AuthJwt\JwtIssuer;
$issuer = new JwtIssuer(file_get_contents('/path/to/private.pem'), algorithm: 'RS256');
$token = $issuer->issue($user->id());
final class AppJwtAuthMiddleware extends JwtAuthMiddleware
{
public function __construct(RequestScope $scope)
{
parent::__construct(
file_get_contents('/path/to/public.pem'),
$scope,
algorithm: 'RS256',
);
}
}
Warning
Don’t pass the same key to both sides for RS256 — that only works for
HS*. For an asymmetric algorithm, the middleware only ever needs the
public key; keeping the private key out of anything that only verifies
tokens is the entire point of choosing an asymmetric algorithm in the
first place.
See also¶
Authentication — opaque Bearer tokens against your own storage instead, if you don’t want claims embedded directly in the token, or want every request to hit your own storage regardless.
Middleware —
CurrentUserInterface, the global-vs-route middleware distinction, andRequestScopeself-injection.