Security model
The guarantees the library makes on the verifying side, and the internals that back them: constant-time comparison, the algorithm pin that defeats algorithm confusion, and the raw-signature and JWKS conversions done in pure PHP.
A JWT is signed, not encrypted
The header and payload are only base64url-encoded — anyone with the token can decode and read them. The signature makes the token tamper-evident, not secret. Two consequences the library can’t paper over:
- Never place secrets in claims. Use a claim as an identifier and resolve sensitive data server-side.
- Integrity depends entirely on the signing key staying private (asymmetric) or secret (HMAC).
Constant-time signature comparison
For HMAC, verification recomputes the MAC and compares it to the token’s signature with
hash_equals, not ===:
// From verify() — HMAC branch
return hash_equals(hash_hmac($hash, $data, $keyMaterial, true), $signature);A naive === on strings can short-circuit on the first differing byte, and the tiny
timing difference can, over many attempts, leak the correct signature byte by byte.
hash_equals always compares the full length, closing that side channel. RSA and ECDSA
go through openssl_verify, which performs public-key verification rather than a string
compare, so the same class of timing leak doesn’t apply.
The algorithm pin defeats confusion attacks
Every PublicKey is constructed with an algorithm, and decode() refuses to proceed
unless it equals the token header’s alg:
if ($publicKey->getAlgorithm() !== $algorithm) {
throw new JWTException("The key's algorithm ... does not match the token's ...");
}This blocks the classic algorithm-confusion attack. An RSA public key is, by design,
public. Without the pin, an attacker could take your RS256 public key, craft a token
with alg: HS256, and sign it using the public key bytes as an HMAC secret — and a
verifier that blindly trusts the header’s alg would accept it. Because the key here is
pinned to RS256, a token claiming HS256 never reaches verification.
alg: none is rejected too
A token with no alg, or alg: none, resolves to the string none, which isn’t in the
supported-algorithms table — so decode() throws “Algorithm ‘none’ is not supported.”
The infamous “none” bypass simply has no code path here.
Key selection is explicit
How the verifying key is chosen depends on the family, and both paths fail closed:
- HMAC — the first key in the list is used. An empty list throws “No secret key was provided for HMAC algorithm.”
- RSA / ECDSA — the header’s
kidis looked up in your map. A missingkidthrows, and akidabsent from the map throws. There is no silent fallback to “some other key,” so a token can’t smuggle itself past with an unexpected key id.
ECDSA signature format conversion
ECDSA is where JWT and OpenSSL disagree on encoding, and the library bridges it on the
verify path. The JWT standard (RFC 7518) transmits an ECDSA signature as the raw
concatenation of the two integers R and S, each fixed-width. OpenSSL, however,
expects an ASN.1 DER structure. So before calling openssl_verify, decode()
rewraps the raw signature into DER:
raw signature = R (n bytes) ‖ S (n bytes)
│
signatureToDER()
▼
DER = 30 <len> 02 <len> R 02 <len> S
SEQUENCE INTEGER INTEGERThe conversion also handles ASN.1’s signed-integer rule: if the top bit of R or S is
set, a leading 0x00 is prepended so the value isn’t misread as negative. This is what
lets the library verify spec-compliant ECDSA tokens from any standards-based issuer.
JWKS: PEM built by hand
The JWK parser turns a provider’s key set into usable PublicKeys without any ASN.1
library. For an RSA key it takes the base64url n (modulus) and e (exponent) and
assembles a full SubjectPublicKeyInfo DER structure — algorithm identifier OID, bit
string, the RSAPublicKey sequence — then base64-wraps it into PEM for
openssl_pkey_get_public. EC keys are built the same way from crv, x, and y,
selecting the curve OID and padding the coordinates to the curve’s field size.
// Simplified shape of what createPemFromRsa() emits
"-----BEGIN PUBLIC KEY-----\n" . base64(SubjectPublicKeyInfo) . "-----END PUBLIC KEY-----\n"Because it’s all standard DER, the resulting keys are indistinguishable from ones loaded
off disk — which is why parsed JWKS keys work directly in decode().
Parse errors are swallowed by design
parseKeySet() catches per-key exceptions and skips the offending key, so one malformed
entry in a provider’s document doesn’t break the others. The trade-off: a key you expect
can silently go missing, surfacing later as a “Key with ‘kid’=… was not found” at
decode time rather than at parse time.
What the library does not check
Verification proves authenticity and freshness — not authorization or intent:
iss/audare not validated. Assert them yourself for tokens you didn’t issue (see Verifying with JWKS).- Only
exp,nbf,iatamong time claims are enforced, each widened byleeway.expis compared as(now - leeway) >= exp, so the token is valid up to but not including its expiry second.
Related
- Algorithms — the supported-algorithm matrix
- Verifying with JWKS — third-party token validation
- API reference —
JWT,JWK, key objects