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
One rule chooses the key for every algorithm, and every branch of it fails closed:
- A
kidin the header — the key under that id is used. If there is none, it throws: no fallback to another key, so a token cannot influence what verifies it. - No
kid, one key — that key is used.kidis optional under RFC 7515 §4.1.4, and plenty of issuers omit it. - No
kid, several keys — it throws. Taking whichever came first would make the outcome depend on array order. - No keys at all — it throws “No key was provided for verification.”
ECDSA signature format conversion
ECDSA is where JWT and OpenSSL disagree on encoding, and the library bridges it in both
directions. The JWT standard (RFC 7518 §3.4) transmits an ECDSA signature as the raw
concatenation of the two integers R and S, each padded to the width of the curve.
OpenSSL both produces and expects an ASN.1 DER structure. So encode() unwraps what
openssl_sign() returned, and decode() rewraps what the token carried:
encode: openssl_sign() → DER → signatureFromDER() → R ‖ S → token
decode: token → R ‖ S → signatureToDER() → DER → openssl_verify()
R ‖ S = R (n bytes) ‖ S (n bytes) n = 32 / 48 / 66
DER = 30 <len> 02 <len> R 02 <len> S
SEQUENCE INTEGER INTEGERThe conversion handles both of DER’s quirks, and both point one way only:
- sign — if the top bit of
RorSis set, a leading0x00is prepended so the value is not misread as negative; - minimality — surplus leading zeros are stripped instead. The raw form is zero-padded by definition, and for P-521 the top octet is almost always zero: 521 bits do not fill 66 octets. Leaving it in would produce invalid DER.
Length too: DER’s short form stops at 127 bytes, and the ES512 SEQUENCE is longer, so it
needs the long form.
This is what lets the library both accept ECDSA tokens from any standards-based issuer and issue ones that any third-party verifier will accept.
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