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

One rule chooses the key for every algorithm, and every branch of it fails closed:

  • A kid in 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. kid is 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:

text
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      INTEGER

The conversion handles both of DER’s quirks, and both point one way only:

  • sign — if the top bit of R or S is set, a leading 0x00 is 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.

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.