CWE-347: Improper Verification of Cryptographic Signature - PHP
Overview
PHP applications most commonly verify JWTs with firebase/php-jwt. In v6 and later, JWT::decode() requires a Firebase\JWT\Key object rather than a bare key string, and each Key binds its key material to exactly one algorithm - this is the library's built-in defense against algorithm confusion, and it is a stronger one than most ecosystems have. Measured on v7.1.0 with PHP 8.5, a Key built for RS256 refuses an HS256 token and an RS512 token, both with UnexpectedValueException: Incorrect key for this algorithm, and refuses alg: none with Algorithm not supported. Where jsonwebtoken and jjwt still accept a different member of the same family, php-jwt does not.
The vulnerability therefore lives in one place: the algorithm string handed to the Key constructor. If it is a hardcoded literal, there is nothing left to confuse. If it comes from the token's own header, the token has chosen its own verification path: an attacker can take a legitimate RS256 token, change the header to HS256, and re-sign it using the server's RSA public key (which is not secret) as the HMAC key. If it comes from configuration that mixes environments or from a per-tenant record, nothing in the code says which value production actually uses.
Webhook and API signature verification (Stripe-, GitHub-, and similar HMAC-signed payloads) is vulnerable when the computed digest is compared with == or === instead of a constant-time function - both operators short-circuit on the first mismatched byte and leak timing information.
The safe replacements are: always wrap verification key material in a Key object bound to a hardcoded, expected algorithm, and use hash_equals() for any raw signature or HMAC comparison.
Common Vulnerable Patterns
Building the Key With an Algorithm the Caller Did Not Fix
<?php
use Firebase\JWT\JWT;
use Firebase\JWT\Key;
// VULNERABLE - the algorithm comes from the token's own header, so the Key
// binding is built to whatever the sender asked for
function verifyAccessToken(string $jwt, string $rsaPublicKeyPem): object
{
[$headerB64] = explode('.', $jwt);
$alg = json_decode(base64_decode(strtr($headerB64, '-_', '+/')), true)['alg'];
return JWT::decode($jwt, new Key($rsaPublicKeyPem, $alg));
}
// Attack: take a legitimate RS256 token, change the header to {"alg":"HS256"},
// and re-sign it with HMAC-SHA256 using the RSA public key PEM text as the
// secret. The Key is then constructed for HS256 with the public key as the
// shared secret, and the forged signature verifies.
Why this is vulnerable: the Key object is only a control while the caller decides both halves of the pairing. Here the key material is fixed and the algorithm is not, so the class that exists to stop the token choosing its verification path is being handed the token's choice. The same defect arrives less obviously through configuration - new Key($pem, config('jwt.algorithm')) where the config is per-environment or per-tenant - because nothing in the code says which value production actually uses.
A Bare Key String Instead of a Key Object
<?php
use Firebase\JWT\JWT;
// VULNERABLE on php-jwt 5.x, and a fatal error on 6.x and 7.x
function verifyAccessToken(string $jwt, string $rsaPublicKeyPem): object
{
return JWT::decode($jwt, $rsaPublicKeyPem, ['RS256', 'HS256']);
}
Why this is vulnerable: on php-jwt 5.x the third parameter was the allowed-algorithm list, and passing a list spanning both families with one unbound key is the classic confusion - the token picks which family of check applies. What makes this worth recognising rather than fixing in place is that it does not survive the upgrade: on v6 and v7 the third parameter is &$headers, so the same call raises Error: Firebase\JWT\JWT::decode(): Argument #3 ($headers) could not be passed by reference - measured on v7.1.0. Passing the bare string as the second argument with no third does not work either: getKey() needs a Key or an array of them, and a string with no kid in the token throws UnexpectedValueException: "kid" empty, unable to lookup correct key. Either way the endpoint is broken rather than exploitable after the upgrade, so a scanner finding on this line in a v6+ codebase is a call site that has never been exercised.
Naive Equality for Webhook Signature Comparison
<?php
// VULNERABLE - == is not constant-time; PHP also loosely compares some
// hex-looking strings numerically, which can produce surprising matches
function verifyWebhookSignature(string $requestBody, string $signatureHeader, string $webhookSecret): bool
{
$expected = hash_hmac('sha256', $requestBody, $webhookSecret);
return $expected == $signatureHeader;
}
Why this is vulnerable: == performs a type-juggling comparison in PHP. For two strings it compares contents and returns at the first difference, so the duration reflects how much of the submitted signature matched - by much less than the usual description implies, though: measured on PHP 8.5.8 with 64-byte values, moving the mismatch from the first byte to the last changes the call by under a nanosecond against a 61 ns baseline, while a length mismatch is clearly separable. The type juggling is the sharper problem, and it is not a timing issue at all: where both strings look numeric, PHP compares them as numbers, so "1e2" == "100" is true on PHP 8.5 and two different signatures can be equal. Neither risk belongs anywhere near a security check, and hash_equals() removes both. CWE-208 covers the timing half in full.
Secure Patterns
Bind the Verification Key to a Single Expected Algorithm
<?php
use Firebase\JWT\JWT;
use Firebase\JWT\Key;
// SECURE - the key is bound to exactly one algorithm; the token header
// cannot select HS256 instead, because a Key built for RS256 will only be
// used to verify an RS256 signature
function verifyAccessToken(string $jwt, string $rsaPublicKeyPem): object
{
return JWT::decode($jwt, new Key($rsaPublicKeyPem, 'RS256'));
}
Why this works: Key pairs the key material and the algorithm together at construction time, before any token is inspected. JWT::decode() uses that pairing directly rather than consulting the token header to decide which algorithm to check against, so a token whose header claims a different algorithm cannot cause a different verification path to run. Measured on v7.1.0: this function accepts the legitimate RS256 token and refuses the HS256 forgery, an RS512 token signed by the real private key, and an alg: none token. The RS512 case is the one worth noting - firebase/php-jwt pins the exact algorithm rather than the family, which is not true of the equivalent one-line fix in Node or Java.
The algorithm being a literal is the whole of it. 'RS256' written in the source cannot be influenced at runtime; the same string read from configuration can.
Resolve Keys by kid From a Trusted Keystore
<?php
use Firebase\JWT\JWT;
use Firebase\JWT\Key;
// SECURE - an associative array of Key objects, each bound to a specific
// algorithm, built entirely from a trusted server-side source
$trustedKeys = [
'key-2024-01' => new Key($rsaPublicKeyPem1, 'RS256'),
'key-2024-02' => new Key($rsaPublicKeyPem2, 'RS256'),
];
$decoded = JWT::decode($jwt, $trustedKeys);
Why this works: JWT::decode() selects a Key from the array using the token's kid purely as a lookup, but every entry in $trustedKeys was populated ahead of time from a trusted keystore or pinned JWKS fetch, with its algorithm fixed by the caller. There is no path for the token to introduce a new kid/algorithm pairing that was not already present in the array. kid is the only header parameter that can be used this way: jku and x5u name a URL the key should be fetched from and jwk/x5c carry key material inline, so honouring any of them lets the sender supply the key their own token verifies against.
Two failure modes to know before shipping this, both measured on v7.1.0 and both UnexpectedValueException: a token carrying no kid is refused with "kid" empty, unable to lookup correct key even when the array holds exactly one entry, and an unrecognised kid is refused with "kid" invalid. That is the correct behaviour, but it means moving from a single Key to an array changes what a legitimate token must contain, which the migration note below picks up.
Constant-Time Comparison for Webhook HMAC Signatures
<?php
// SECURE - webhook HMAC-SHA256 verification with constant-time comparison.
// The controller below requires this function as 'webhook-signature.php'.
function verifyWebhookSignature(string $requestBody, string $signatureHeader, string $webhookSecret): bool
{
$expected = hash_hmac('sha256', $requestBody, $webhookSecret);
return hash_equals($expected, $signatureHeader);
}
Why this works: hash_equals() (PHP 5.6+) is built for comparing security-sensitive strings: where both are the same length it examines every byte before returning, so the timing does not depend on where the first mismatch falls. It also compares strictly as strings, avoiding PHP's loose-comparison numeric-string surprises entirely.
The length is the exception, and the manual says so: two strings of different lengths return false immediately, which on PHP 8.5.8 measures 55 ns against 89 ns for an equal-length comparison. The known string's length is what leaks, so pass the expected signature as the first argument and the user-supplied one as the second, and rely on the length being fixed by the signature format rather than secret. CWE-385 tabulates that length-mismatch behaviour for every language.
Framework-Specific Guidance
Laravel: Verify Against the Raw Request Body
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
// The verifier from the section above, as its own file. An unqualified
// function call does fall back to global scope, so no import is needed beyond
// loading the file.
require_once 'webhook-signature.php';
class WebhookController extends Controller
{
public function handle(Request $request)
{
$signatureHeader = $request->header('X-Signature');
$rawBody = $request->getContent(); // raw bytes, not the parsed/re-encoded payload
// SECURE - verify before touching the parsed payload
if (!verifyWebhookSignature($rawBody, $signatureHeader, config('services.webhook.secret'))) {
abort(401, 'Invalid signature');
}
$payload = $request->json()->all();
// process payload
}
}
Why this works: Request::getContent() returns the exact bytes Laravel received, before any JSON decoding. Verifying against those raw bytes - rather than against json_encode($request->json()->all()) - guarantees the signature is checked against the same content the sender actually signed, avoiding both false rejections from re-encoding differences and mismatches between what was verified and what gets processed.
Considerations
This is one of the few findings that is almost never a false positive. For most weaknesses the first question is whether the value is security-relevant; here, if a signature is being checked at all, something is trusting the result. The narrow exception is data that never crossed a trust boundary - a token your own process minted, held in memory, and verified moments later. If the token arrived over the network, the check matters.
Decide where verification happens, and whether once is enough. A gateway that verifies tokens before forwarding lets backend services skip the work, which is efficient and fine until one service becomes reachable another way - an internal caller, a service mesh retry, a debugging port. Verifying again in the service costs little and does not depend on network topology staying as drawn. If you do rely on the gateway, make it impossible to bypass rather than merely inconvenient.
Symmetric algorithms give every verifier the power to mint. HS256 uses one shared secret, so any service holding it to check tokens can also issue them. With three services and one secret you have three places a forged administrator token can come from. RS256 and EdDSA split that: the issuer holds the private key, verifiers hold only the public one. If more than one service verifies, that separation is worth the extra key management.
JWT::$leeway is static, and that matters. In firebase/php-jwt the skew
allowance is a static property on the class, so setting it anywhere changes
verification for every caller in the process. A generous value set for one
integration silently widens the acceptance window for authentication too. Set it
once, centrally, and keep it small.
Key rotation needs a cache policy decided in advance. Resolving keys by
kid from a JWKS endpoint means an outbound fetch on the verification path.
Cache too briefly and every request becomes a network call, so an issuer outage
takes your authentication down with it; cache too long and a rotated-away key
stays trusted. Cache by kid with a refresh on unknown values, plus a floor on
how often that refresh can fire, so an attacker cannot drive fetches by sending
tokens with random kid values.
Expiry is not revocation. Signature verification proves a token was issued and unmodified; it says nothing about whether the account was disabled a minute ago. Short lifetimes narrow that window and cost a refresh round trip; a revocation list closes it and costs a lookup on every request. Which you need depends on how quickly access must actually stop - "immediately" and "within fifteen minutes" are different systems.
Testing
- Normal: a legitimately issued RS256 token and a correctly HMAC-signed webhook payload are both accepted. Where
$trustedKeysis an array, this is the test that catches a token carrying nokid, which is refused there and accepted by a singleKey. - Boundary: a token signed by a
kidnot present in$trustedKeys, and a signature header with an unexpected length, are both rejected without throwing an unhandled error. - Malicious - algorithm confusion: re-sign a valid RS256 token as HS256 using the server's known RSA public key as the HMAC secret;
JWT::decode()must throwUnexpectedValueException: Incorrect key for this algorithm. - Malicious - cross-algorithm: re-sign a valid token as RS512 using the issuer's real private key;
JWT::decode()must throw the sameIncorrect key for this algorithm. AKeybound toRS256gives you this for free, so a failure here means the algorithm reaching the constructor is not the literal you think it is. - Malicious - alg=none: submit a token with header
{"alg":"none"}and an empty signature segment;decode()must reject it withAlgorithm not supported. - Malicious - tampered webhook payload: flip one byte in the raw request body while keeping the original signature header;
verifyWebhookSignature()must returnfalse.
Common Pitfalls
- Upgrading the
firebase/php-jwtdependency but not the call sites: v6 does not silently keep accepting a bare key string -$keyOrKeyArrayhas no type declaration, so the call still parses, butgetKey()returns aKeyor throws, and a string reaches thekidlookup and throwsUnexpectedValueExceptionat decode time. A three-argument v5 call fails harder still: the third parameter is now&$headers, soJWT::decode($jwt, $key, ['RS256'])raisesError: Argument #3 ($headers) could not be passed by reference(measured on v7.1.0). That is a breaking change disguised as a version bump: PHP gives you no compile-time warning, so aJWT::decode()call on a rarely exercised path can pass CI and fail on the first real request. Grep forJWT::decode(and confirm every call passes aKeyor an array ofKeyobjects as the second argument and nothing as the third. - Reading the algorithm from configuration rather than writing it in the source:
new Key($pem, config('jwt.algorithm'))reads as parameterised and is the whole vulnerability in one line if the value can be influenced. The nearby version is an algorithm array like['RS256', 'HS256']that exists because a staging environment used HS256 for convenience and was never narrowed back down. Write the literal, and if you genuinely need it configurable, validate the value against a hardcoded set before it reaches the constructor. - Comparing HMAC signatures with
hash_equals()but building$expectedfrom user-controlled input: the constant-time property protects the comparison, not the values going into it. If$expectedis derived from something an attacker can also influence - a signature stored in a database row they can write to, for example - then both sides of the comparison are partly theirs, and no comparison function fixes that.
Dependencies and Installation
firebase/php-jwt(Composer) - use v6 or later, which enforces theKeyobject binding; keep at a current maintained version.hash_equals()andhash_hmac()are built into PHP core (PHP 5.6+ forhash_equals()); no additional package is required for constant-time comparison.
Migration Considerations
Binding keys to a single algorithm will reject any previously accepted token signed with an algorithm being removed from the accepted set (for example, a deployment that unintentionally accepted both RS256 and HS256 tokens during a migration). Confirm which algorithm your actual issuer uses in production before narrowing verification, and expect active sessions signed under a now-rejected algorithm to require re-authentication.
Moving from a single Key to a kid-indexed array is the second change to stage carefully, and it is easy to miss because it is not about algorithms at all. JWT::decode() refuses a token with no kid as soon as the second argument is an array - "kid" empty, unable to lookup correct key - so every issuer must be emitting kid in the header before the array lands, not afterwards. Confirm it by decoding the header of a live token rather than by reading the issuer's configuration, and keep the single-Key path until you have.