Introduction
JWT (JSON Web Token) verification in PHP uses OpenSSL to validate the token signature against the public key. Verification fails when the key format is incompatible, the algorithm does not match between issuer and verifier, the key is base64-encoded when raw bytes are expected, or the token payload has been modified. This causes authentication failures across APIs and microservices.
Symptoms
Firebase\JWT\BeforeValidException: Cannot handle token prior toInvalidArgumentException: Wrong number of segmentsSignature verification failedwith no additional details- Token works in jwt.io debugger but fails in PHP
- Works with HS256 but fails with RS256 keys
```php use Firebase\JWT\JWT; use Firebase\JWT\Key;
try { $decoded = JWT::decode($token, new Key($publicKey, 'RS256')); } catch (\Firebase\JWT\SignatureInvalidException $e) { // "Signature verification failed" // Token appears valid but signature does not match the key } ```
Common Causes
- Public key in wrong format (PEM header missing or corrupted)
- Algorithm mismatch: token signed with RS256 but verifying with HS256
- Key encoded as base64 string when PEM format is expected
- Token issued by different service with different key pair
- Clock skew between token issuer and verifier
Step-by-Step Fix
- 1.Verify key format is correct PEM:
- 2.```php
- 3.// WRONG - raw key without PEM headers
- 4.$publicKey = 'MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A...';
// CORRECT - full PEM format with headers $publicKey = <<<EOD -----BEGIN PUBLIC KEY----- MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA t8G9k8K3mN2pQ7x5Y6z3R4w8V7j2K5m9L0c8H6f4X2e1 ... (base64 encoded key data) ... -----END PUBLIC KEY----- EOD;
// Convert base64 key to PEM function base64ToPem(string $base64): string { return "-----BEGIN PUBLIC KEY-----\n" . chunk_split($base64, 64, "\n") . "-----END PUBLIC KEY-----\n"; } ```
- 1.Ensure algorithm matches:
- 2.```php
- 3.// Check what algorithm the token was signed with
- 4.$parts = explode('.', $token);
- 5.$header = json_decode(base64_decode(strtr($parts[0], '-_', '+/')), true);
- 6.echo "Token algorithm: " . ($header['alg'] ?? 'unknown') . "\n";
// Use matching algorithm in verification use Firebase\JWT\JWT; use Firebase\JWT\Key;
// If token uses RS256 (RSA) $decoded = JWT::decode($token, new Key($publicKey, 'RS256'));
// If token uses HS256 (HMAC shared secret) $decoded = JWT::decode($token, new Key($secret, 'HS256'));
// If token uses ES256 (ECDSA) $decoded = JWT::decode($token, new Key($ecPublicKey, 'ES256')); ```
- 1.Handle clock skew for token timing:
- 2.```php
- 3.use Firebase\JWT\JWT;
- 4.use Firebase\JWT\Key;
JWT::$leeway = 60; // Allow 60 seconds clock skew
$decoded = JWT::decode($token, new Key($publicKey, 'RS256'));
// Or set leeway per-key (Firebase JWT 6.x) $decoded = JWT::decode( $token, new Key($publicKey, 'RS256', null), // Key ID (optional) ['leeway' => 60] ); ```
- 1.Debug signature verification:
- 2.```php
- 3.function debugJwtSignature(string $token, string $publicKey): void {
- 4.[$headerB64, $payloadB64, $signatureB64] = explode('.', $token);
// Decode signature $signature = base64_decode(strtr($signatureB64, '-_', '+/'));
// Verify manually $data = "$headerB64.$payloadB64"; $result = openssl_verify( $data, $signature, $publicKey, OPENSSL_ALGO_SHA256 // For RS256 );
echo "Signature verification: " . ($result === 1 ? "VALID" : "INVALID") . "\n"; if ($result === 0) { echo "OpenSSL error: " . openssl_error_string() . "\n"; } } ```
Prevention
- Always store keys in PEM format with proper headers
- Log the token algorithm header and verify it matches expected algorithm
- Set
JWT::$leewayto handle clock differences between servers - Use a well-maintained library like
firebase/php-jwtorlcobucci/jwt - Rotate keys with overlap period - old tokens remain valid during transition
- Test JWT verification in CI with known-good tokens
- Never trust the algorithm in the token header - enforce expected algorithm in code