Threat Model¶
This document describes what daycry/jwt defends against, what it explicitly does not defend against, and the assumptions you must satisfy in your application code for the protections to hold.
What the library protects against¶
When decode() succeeds with the default validateClaims, the library has confirmed that:
- Integrity / authenticity — the token's signature was produced with the same key/algorithm pair that the verifier is configured with. A tampered claims body, a tampered header (including
algswapping), or any unauthorised mutation rejects validation.SignedWithis part of the default$validateClaims, anddecode()refuses to run without it: if$validate = truebutSignedWithis missing from$validateClaims,JWTConfigurationExceptionis thrown rather than silently skipping signature verification. (tryDecode()is the non-throwing wrapper — it returnsnullinstead of throwing, but applies the exact same constraint set.) - Issuer pinning (
iss) — the token'sissmatchesConfig\JWT::$issuer. Tokens from a different issuer (even if signed with the same key) are rejected. - Audience pinning (
aud) — the token listsConfig\JWT::$audienceamong its audiences. Tokens minted for a different service are rejected. - Application identifier (
jti) — the token'sjtimatchesConfig\JWT::$identifier. - Temporal validity —
iatis not in the future,nbfhas passed andexphas not, all subject to the configuredleewayand your choice ofLooseValidAt(default, also accepted under the legacy aliasValidAt) orStrictValidAt.
Configuration mistakes that would weaken these guarantees fail loudly: JWTConfigurationException is thrown the moment you try to encode or decode without a configured signer / signingKey / verifyingKey / issuer / audience / identifier. An empty string counts as "not configured" for issuer / audience / identifier, exactly like null — you cannot weaken a claim check by blanking it. A mismatch between $algorithmType and the $algorithm signer class (e.g. asymmetric left on the default HMAC signer, or symmetric paired with an RSA/ECDSA signer) is also caught up front with a descriptive JWTConfigurationException instead of a cryptic lower-level key error.
What the library does not protect against¶
These threats live outside the JWT specification. The library cannot mitigate them on its own — your application must.
Token theft / leakage¶
- Storing tokens in
localStorageexposed to XSS. - Logging full bearer tokens in access logs, error messages, or analytics.
- Sending tokens over plain HTTP. Always use HTTPS.
Replay attacks¶
- The library has no state. A captured token remains valid until
exp. If your threat model requires single-use semantics: - Issue short-lived access tokens (
+5 minutes) and use a refresh-token flow. - Track used
jtivalues in a server-side store (Redis, DB) and reject duplicates. - Bind tokens to a transport — e.g. a TLS channel binding or DPoP-style proof-of-possession header.
Token revocation¶
- Once issued, a JWT is valid until
exp. There is no built-in "log out everywhere" primitive. Implement either: - Short
expplus refresh-token rotation. - A server-side allow/deny list keyed by
jtioruid. - Key rotation (rotate
jwt.signer/signingKeyto invalidate all outstanding tokens at once — heavy but effective).
Privacy of payload¶
- JWTs are signed, not encrypted. Anyone holding the token can read every claim. Do not store secrets, PII you would not log, or anything subject to regulation in the payload. If you need encryption, use JWE — outside this library's scope.
Side-channel attacks against the host¶
- The library uses
lcobucci/jwtwhich useshash_hmac()for HMAC and OpenSSL bindings for RSA/ECDSA. Both rely on PHP's underlying implementations being constant-time. Side-channel attacks against those are out of scope here; consult the upstream projects.
Compromised signing key¶
- If
jwt.signer(HMAC) or the private key (RSA / ECDSA) leaks, an attacker can mint arbitrary tokens. The library cannot detect this — only key rotation can recover.
Algorithm confusion (alg=none, RS256↔HS256 swap)¶
lcobucci/jwt 5rejectsalg=nonetokens and refuses to verify a token if itsalgdoes not match the expected family. The library inherits these protections. On top of that, the library validates your own configuration: if$algorithmTypeand the$algorithmsigner class disagree —symmetricrequires anLcobucci\JWT\Signer\Hmac\*signer,asymmetricrequires anRsa\*orEcdsa\*signer —buildConfiguration()throwsJWTConfigurationExceptionbefore any token is parsed. This closes the door on a misconfigured verifier (e.g. an asymmetric setup accidentally left on the default HMACSha256) that an attacker could otherwise exploit for an RS256↔HS256 swap. However, if your verifier accepts tokens from multiple issuers/algorithms simultaneously, you are responsible for picking the correct verifier per issuer (extractClaimsUnsafe()is the inspection-before-verify hook for this case).
Denial of service via large tokens¶
- The library does not enforce a maximum token size. A request body of several megabytes will be parsed before validation can reject it. If exposed to untrusted clients, gate the input upstream (e.g. a max body size on your reverse proxy).
Operational hardening checklist¶
| # | Action |
|---|---|
| 1 | Generate keys with php spark jwt:key (HMAC) or php spark jwt:keypair (RSA/ECDSA). Never commit them. |
| 2 | Rotate the signing key at least every 12 months, and immediately if you suspect compromise. |
| 3 | Keep Config\JWT::$validate = true and SignedWith in $validateClaims in production. Setting $validate = false skips all validation and logs a warning via log_message() on every decode() — it exists for tests/debug only, never for production. |
| 4 | Set $expiresAt as short as your UX allows (+15 minutes for access tokens is a common baseline). For one-off short-lived tokens, prefer JWT::for()->withExpiresAt('+5 minutes')->encode(...) — a per-instance override that never mutates the shared config. |
| 5 | Use a distinct $identifier per environment (dev, staging, prod) so dev tokens never validate against prod. |
| 6 | Serve the API exclusively over HTTPS. Enable HSTS where possible. |
| 7 | Strip Authorization headers from access logs and APM payloads. |
| 8 | Set $allowUnsafeExtraction = true only in code paths that genuinely need claim inspection without verification. Audit those paths. |
| 9 | When using asymmetric keys, store the private key with mode 0600 and outside the document root. On Windows, chmod() cannot enforce permissions (jwt:keypair warns about this) — restrict the file with NTFS ACLs (e.g. icacls) instead. Avoid passing --passphrase on the command line, where it leaks into the process list and shell history; the command warns when you do. Prefer secrets managers (Vault, AWS Secrets Manager, GCP Secret Manager) over committed config. |
| 10 | If you accept tokens from external partners, configure a separate JWT instance per issuer with that issuer's public key — never share verification keys across trust domains. |
Reporting issues¶
Security issues should not be filed as public GitHub issues. Follow SECURITY.md for the private-disclosure path.