Package · jwt · deep dive

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 ===:

php
// 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:

php
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 kid is looked up in your map. A missing kid throws, and a kid absent 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:

text
raw signature  =  R (n bytes) ‖ S (n bytes)

           signatureToDER()

DER  =  30 <len>  02 <len> R   02 <len> S
      SEQUENCE  INTEGER      INTEGER

The 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.

php
// 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 / aud are not validated. Assert them yourself for tokens you didn’t issue (see Verifying with JWKS).
  • Only exp, nbf, iat among time claims are enforced, each widened by leeway. exp is compared as (now - leeway) >= exp, so the token is valid up to but not including its expiry second.