PHP cookbook · Intermediate

Protect a PHP API with JWT bearer tokens

Validate JWT access tokens on every API request and expose the authenticated user to controllers.

Approach

Use a maintained library (firebase/php-jwt or lcobucci/jwt) instead of hand-rolled parsing. Read the Authorization: Bearer header, verify the signature with a pinned algorithm and key, check exp, iss and aud, then load the user. Return 401 with WWW-Authenticate: Bearer on failure. Keep access tokens short-lived.

Solution

<?php
declare(strict_types=1);

use Firebase\JWT\JWT;
use Firebase\JWT\Key;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;

final class JwtAuthMiddleware implements MiddlewareInterface
{
    public function __construct(private string $publicKey, private string $issuer, private string $audience,
                                private UserRepository $users, private ResponseFactory $responses) {}

    public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
    {
        $header = $request->getHeaderLine('Authorization');
        if (!str_starts_with($header, 'Bearer ')) {
            return $this->unauthorized('Missing bearer token');
        }
        try {
            JWT::$leeway = 30;   // seconds of clock skew
            $claims = JWT::decode(substr($header, 7), new Key($this->publicKey, 'RS256'));
        } catch (Throwable) {
            return $this->unauthorized('Invalid or expired token');
        }
        $aud = (array) ($claims->aud ?? []);
        if (($claims->iss ?? null) !== $this->issuer || !in_array($this->audience, $aud, true)) {
            return $this->unauthorized('Token not issued for this API');
        }
        $user = $this->users->find((int) $claims->sub) ?? null;
        if ($user === null) {
            return $this->unauthorized('Unknown user');
        }
        return $handler->handle($request->withAttribute('user', $user));
    }

    private function unauthorized(string $message): ResponseInterface
    {
        return $this->responses->problem(401, $message)->withHeader('WWW-Authenticate', 'Bearer');
    }
}

Pitfalls

  • Accepting the algorithm from the token header.
  • Long-lived access tokens without a revocation strategy.
  • Putting sensitive data in claims — JWT payloads are readable.

Variations

  • Laravel: Sanctum (opaque tokens/cookies) or Passport (OAuth2).
  • Symfony: lexik/jwt-authentication-bundle or the built-in access token authenticator with an OIDC handler.

Further reading: firebase/php-jwt

Reference: https://github.com/firebase/php-jwt

More PHP recipes