Guide 3 min read Updated

Verify JWT in PHP (firebase/php-jwt & Laravel)

Verify JWT signatures in PHP with firebase/php-jwt and Laravel Sanctum. Sign and verify HS256 and RS256 tokens, validate iss/aud/exp, and verify JWTs against a JWKS endpoint with full PHP code examples.

PHP’s standard JWT library is firebase/php-jwt. Laravel users often pair it with tymon/jwt-auth for a batteries-included JWT guard, or use Laravel Sanctum (which issues opaque tokens, not JWTs — covered briefly for context).

Install

composer require firebase/php-jwt
# Optional Laravel JWT guard
composer require tymon/jwt-auth

Sign and verify an HS256 token (firebase/php-jwt)

use Firebase\JWT\JWT;
use Firebase\JWT\Key;

$secret = getenv('JWT_SECRET'); // 32+ bytes

// Sign
$payload = [
    'sub' => 'user_123',
    'iss' => 'https://auth.example.com',
    'aud' => 'https://api.example.com',
    'iat' => time(),
    'exp' => time() + 15 * 60,
];
$token = JWT::encode($payload, $secret, 'HS256');

// Verify — Key object with algorithm is mandatory since 6.0
JWT::$leeway = 60; // 60s clock skew for exp/nbf
try {
    $decoded = JWT::decode($token, new Key($secret, 'HS256'));
    // firebase/php-jwt does not validate iss/aud — check them yourself
    if ($decoded->iss !== 'https://auth.example.com') {
        throw new Exception('wrong iss');
    }
    $aud = $decoded->aud;
    $audMatch = is_array($aud) ? in_array('https://api.example.com', $aud, true) : $aud === 'https://api.example.com';
    if (!$audMatch) {
        throw new Exception('wrong aud');
    }
} catch (Firebase\JWT\ExpiredException $e) {
    http_response_code(401);
} catch (Firebase\JWT\SignatureInvalidException $e) {
    http_response_code(401);
}

Sign and verify an RS256 token (firebase/php-jwt)

$privateKey = openssl_pkey_get_private('file:///path/to/rsa-private.pem');
$publicKey = openssl_pkey_get_public('file:///path/to/rsa-public.pem');

$token = JWT::encode($payload, $privateKey, 'RS256', 'rsa-key-v1');
$decoded = JWT::decode($token, new Key($publicKey, 'RS256'));

Verify a JWT against a JWKS endpoint (firebase/php-jwt)

use Firebase\JWT\JWK;

// Cache this for 1 hour in APCu — do not fetch on every request
$jwks = json_decode(file_get_contents('https://YOUR_DOMAIN.auth0.com/.well-known/jwks.json'), true);
$keyRing = JWK::parseKeySet($jwks, 'RS256'); // array of Key objects indexed by kid

// Find the kid from the token header
$header = json_decode(base64_url_decode(explode('.', $token)[0]), true);
$key = $keyRing[$header['kid']] ?? null;
if (!$key) {
    throw new Exception('unknown kid');
}

$decoded = JWT::decode($token, $key); // Key already carries the algorithm

JWK::parseKeySet converts the JWKS into an array of Key objects indexed by kid. Cache the result with a TTL. The fetch is the only network call — the token itself never leaves your server.

tymon/jwt-auth (Laravel)

config/jwt.php (published via php artisan vendor:publish --provider="Tymon\JWTAuth\Providers\LaravelServiceProvider"):

return [
    'secret' => env('JWT_SECRET'),
    'algo' => 'HS256',
    'ttl' => 15,              // access token minutes
    'refresh_ttl' => 20160,   // refresh token minutes (14 days)
    'required_claims' => ['iss', 'iat', 'exp', 'sub', 'jti'],
    'lock_subject' => true,
];

Protect routes with the jwt.auth middleware, issue tokens with JWTAuth::fromUser($user), and verify with JWTAuth::parseToken()->authenticate(). tymon/jwt-auth handles iss, iat, exp, sub, jti validation per the required_claims config.


Continue reading