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 RequestScopeAppScope 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.

  • MiddlewareCurrentUserInterface, the global-vs-route middleware distinction, and RequestScope self-injection.