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\Config\Config;
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, Config $config)
    {
        parent::__construct($config->required('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()];
    }
}

Supplying your own secret

Extend JwtAuthMiddleware with a constructor taking only RequestScope and (optionally) your own Config, both class-typed, and pass your secret to parent::__construct() — the pattern in the example above. Kinetis builds a subclass shaped this way automatically, with no extra setup.

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.

Warning

Use Config::required('JWT_SECRET'), not Config::string('JWT_SECRET', '') with an empty-string default — a missing secret should fail clearly and immediately, not surface as an unrelated error later.

Issuing tokens: JwtIssuer

use Kinetis\AuthJwt\JwtIssuer;
use Kinetis\Config\Config;

$config = Config::fromEnvironment(); // or constructor-injected, wherever this runs
$issuer = new JwtIssuer($config->required('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.

The denylist needs a real cache. Configure Redis (REDIS_URL or REDIS_HOST — see Persistence) so the CacheInterface binding is RedisSimpleCache, or pass any other real PSR-16 implementation directly. RevocationStore refuses to construct over NullSimpleCache — the default binding when no Redis is configured — since a denylist that never stores anything would let every revoked token stay valid until it expires on its own.

use Kinetis\AuthJwt\RevocationStore;
use Kinetis\Config\Config;
use Psr\SimpleCache\CacheInterface;

final class AppJwtAuthMiddleware extends JwtAuthMiddleware
{
    public function __construct(RequestScope $scope, Config $config, CacheInterface $cache)
    {
        parent::__construct(
            $config->required('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.

Refresh tokens

An access token’s short expiry is what keeps a leaked one from being useful for long — but that only works if a client can get a new one without the user logging in again every hour. Kinetis\AuthJwt\ RefreshTokenStore issues a longer-lived, opaque, cache-backed token for exactly that:

use Kinetis\AuthJwt\JwtIssuer;
use Kinetis\AuthJwt\RefreshTokenStore;
use Kinetis\Http\Attributes\Post;

final readonly class LoginController
{
    public function __construct(
        private JwtIssuer $issuer,
        private RefreshTokenStore $refreshTokens,
    ) {}

    #[Post('/login')]
    public function attempt(#[Body] LoginRequest $data): array
    {
        // verify $data->email/$data->password against your own storage

        return [
            'accessToken' => $this->issuer->issue($user->id()),
            'refreshToken' => $this->refreshTokens->issue($user->id()),
        ];
    }
}

A refresh endpoint redeems the refresh token and issues both a fresh access token and a fresh refresh token together:

use Kinetis\AuthJwt\JwtIssuer;
use Kinetis\AuthJwt\RefreshTokenStore;
use Kinetis\Http\Attributes\Post;
use Kinetis\Http\Responses\ErrorResponse;

final readonly class RefreshController
{
    public function __construct(
        private JwtIssuer $issuer,
        private RefreshTokenStore $refreshTokens,
    ) {}

    #[Post('/token/refresh')]
    public function refresh(#[Body] RefreshRequest $data): ResponseInterface|array
    {
        $redeemed = $this->refreshTokens->redeem($data->refreshToken);

        if ($redeemed === null) {
            return ErrorResponse::create(401, 'Invalid or expired refresh token.');
        }

        return [
            'accessToken' => $this->issuer->issue($redeemed['subject'], $redeemed['claims']),
            'refreshToken' => $this->refreshTokens->issue($redeemed['subject'], $redeemed['claims']),
        ];
    }
}

A refresh token is single-use: redeem() reads it and deletes it in one atomic operation the moment it’s looked up, valid or not, so the same refresh token can never be redeemed twice — even by two requests racing each other, since the cache is required to implement Kinetis\SimpleCache\AtomicConsumeInterface (both RedisSimpleCache and ClusteredRedisSimpleCache do; construction throws otherwise, the same refusal NullSimpleCache already gets). revoke() invalidates one token directly — a “log out this device” action — without needing to redeem it first:

$this->refreshTokens->revoke($data->refreshToken);

RefreshTokenStore has its own revokeAllForUser(), independent of RevocationStore’s: revoking every access token a user holds doesn’t stop a still-valid refresh token from minting new ones, so a complete “log out everywhere” calls both together:

#[Post('/logout-everywhere')]
public function invoke(): array
{
    $this->revocationStore->revokeAllForUser($this->user->id(), ttlSeconds: 3600);
    $this->refreshTokens->revokeAllForUser($this->user->id(), ttlSeconds: 3600 * 24 * 14);

    return ['loggedOut' => true];
}

Each ttlSeconds covers that store’s own longest-lived outstanding token — an access token’s is typically much shorter than a refresh token’s, so the two calls above commonly pass different values.

Defaults to a 14-day expiry (issue(..., ttlSeconds: 1_209_600)), adjustable per call. RefreshTokenStore requires a real cache the same way RevocationStore does.

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.

Warning

Neither JwtIssuer nor JwtAuthMiddleware sets or checks iss (issuer) or aud (audience) claims. If two separate services share the same HS256 secret, each will accept a token the other one issued — there’s nothing here to stop it. Pass your own iss/aud through issue()’s $claims argument and check them yourself (via JwtUser::claim()) if that matters for your setup, or give each service its own secret/key pair instead.

Rotating keys

Swapping a signing key outright invalidates every token issued under the old one at once. A kid (key ID) lets both the old and new key verify at the same time, during an overlap window:

use Kinetis\AuthJwt\JwtIssuer;

// Sign new tokens under the new key, labeled with its own kid.
$issuer = new JwtIssuer(
    file_get_contents('/path/to/2026-private.pem'),
    algorithm: 'RS256',
    kid: '2026-key',
);
use Firebase\JWT\Key;
use Kinetis\AuthJwt\JwtAuthMiddleware;

final class AppJwtAuthMiddleware extends JwtAuthMiddleware
{
    public function __construct(RequestScope $scope)
    {
        parent::__construct([
            '2025-key' => new Key(file_get_contents('/path/to/2025-public.pem'), 'RS256'),
            '2026-key' => new Key(file_get_contents('/path/to/2026-public.pem'), 'RS256'),
        ], $scope);
    }
}

$key accepts a kid => Key map in place of a single string — a token’s own kid header (written by whichever JwtIssuer signed it) selects which entry verifies it, so tokens signed under either key keep working throughout the overlap. Retire an old key once its longest-lived outstanding token has expired: sign everything new under the new kid, wait out the old key’s own token lifetime, then drop it from the map.

Publishing public keys as a JWKS

For RS256/RS384/RS512, Kinetis\AuthJwt\JwkSet builds a standard JWK Set from one or more RSA public keys — the format clients and API gateways expect at a .well-known/jwks.json-style URL:

use Kinetis\AuthJwt\JwkSet;
use Kinetis\Http\Attributes\Get;
use Kinetis\Http\Attributes\Hidden;

final readonly class JwksController
{
    #[Get('/.well-known/jwks.json')]
    #[Hidden]
    public function jwks(): array
    {
        return JwkSet::fromRsaPublicKeys([
            '2025-key' => file_get_contents('/path/to/2025-public.pem'),
            '2026-key' => file_get_contents('/path/to/2026-public.pem'),
        ]);
    }
}

A plain array return, JSON-encoded automatically like any other route — nothing registers this endpoint for you, the same way nothing registers a login or refresh endpoint either. An HS256 key is symmetric and is never published; this only applies to the asymmetric algorithms.

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.

  • Authentication’s “Preventing brute-force login attempts” section — AttemptThrottle, usable ahead of a login endpoint issuing a JWT the same way it’s used ahead of one issuing an opaque token.