Skip to content

Improve JWT revocation, guard security, claims and token verification - #56

Merged
binaryfire merged 10 commits into
0.4from
upstream-sync-jwt-reconciliation
Oct 7, 2026
Merged

binaryfire merged 10 commits into
0.4from
upstream-sync-jwt-reconciliation

Conversation

@binaryfire

@binaryfire binaryfire commented Oct 7, 2026 •

Copy link
Copy Markdown
Member

This makes JWT token revocation work out of the box, tightens the guard's login and logout behavior, lets custom claims control a token's lifetime and issuer, and lets a service verify tokens with only a public key. Applications can also replace drivers with Jwt::extend(), inject services into custom validations and manage revocations through Jwt::blacklist().

The blacklist is now enabled by default, as it is in jwt-auth. Logout and refresh revoke the old token, and new tokens carry a jti claim. The cost is one cache read per authenticated request, to check whether its token was revoked. The guard keeps the decoded payload for the rest of the request, so later calls in that request don't read the cache again. Applications that never need to revoke a token before it expires can set JWT_BLACKLIST_ENABLED=false to skip that read.

A few shared framework classes change too: Env::writeVariable() and Env::writeVariables() now update the assignment the dotenv loader actually uses, which also fixes Sentry's publish command and install:broadcasting.

Revocation

  • The blacklist required a cache store with tag support, so the default database store threw as soon as the blacklist was turned on. TaggedCache is replaced by CacheStorage, which works on every store. Each key is jwt_blacklist: plus the SHA-256 of the token identifier, so a long custom jti still fits the database store's key column, and identifiers can't be chosen to collide.
  • Clearing every revocation still needs a store with tags. jwt-auth flushes the whole cache on other stores; Hypervel lets the cache's own error surface instead, so unrelated cache entries are never lost.
  • blacklist_store (JWT_BLACKLIST_STORE) picks the cache store for revocations, and null keeps the default store.
  • Jwt::blacklist() gives applications the blacklist, so they can remove one revocation or clear them all without resolving the contract by hand.
  • The refresh window and the blacklist's retention for refreshable tokens both include jwt.leeway, so a revocation lasts as long as any refresh it has to block.

Guard

  • Logging out threw when the blacklist was disabled, which was the default. With the blacklist enabled, user() could authenticate again from the same request header right after logout, during the grace period. Logout now revokes the token only when the blacklist is enabled, clears the guard's state and stops reading the request's token until login() or setToken() is called. A failed blacklist write still throws, keeps the guard's state and dispatches no Logout event.
  • The Logout event now carries the user who logged out.
  • attempt() had no timing protection, so response times showed whether an account existed. Like the session guard, it now runs inside a Timebox using auth.timebox_duration and returns early only on success.
  • Passwords are rehashed on login when hashing.rehash_on_login is enabled, in attempt() and once(), but never in validate(), which only checks credentials.
  • fromUser() is public and issues a token without logging the user in. It replaces the protected makeTokenForUser(). getUser() is public and returns the user the guard has already resolved.
  • id() loads the user like Laravel's guards and returns null for a deleted user. getUserId() still reads the token's subject without loading the user.

Claims and refresh

  • Custom claims could not set exp, nbf, iat, iss or jti, so an application could not give one token a longer lifetime or its own issuer. Model and inline claims may now set every registered claim except sub and prv, both when a token is created and when it is refreshed. An explicit exp takes precedence over the TTL, and an explicit iat over refresh_iat.
  • Date claims had to be Unix timestamps; a Carbon or DateTime value made the Lcobucci provider throw. They now accept integers, integer strings, DateTimeInterface instances and DateInterval values.
  • Refresh keeps iss like any other claim and rebuilds only iat (following refresh_iat), nbf, exp and jti.
  • Refresh skipped the iat check, so a token issued in the future was restamped into a valid one. It is now rejected.
  • RequiredClaims reports missing claims as a JSON array. A missing claim that was not first in required_claims used to produce an object such as {"1":"iss"}.

Token verification

  • An ES256, ES384 or ES512 token whose signature had the wrong length made the decoder throw an internal lcobucci exception, so the guard and auth middleware answered attacker-controlled input with a server error. That signature is now an invalid token. Key and secret configuration errors still surface.
  • An asymmetric Lcobucci provider without a private key failed when it was created, so a service that only verifies tokens from a separate authentication server needed the private key too. It now verifies tokens with the public key, and issuing or refreshing a token fails with "Private key is not set."
  • A Configuration from lcobucci/jwt passed to the Lcobucci constructor now signs and verifies tokens instead of one built from the secret and keys.
  • The manager, provider and guard parameters that take a raw token, and the provider constructors and setters that take the secret, private key or passphrase, are marked #[SensitiveParameter]. PHP then hides those arguments in the exception traces that error trackers such as Sentry report.

Drivers and validations

  • The provider could be chosen two ways: driver picked a manager driver, and providers.jwt picked a class that had to extend Lcobucci. providers.jwt is removed. driver and Jwt::extend() now cover both, the same way Laravel's driver managers work, and the built-in driver is replaced by registering a driver under its name.
  • JwtManager resolves validation classes through the container, passing the JWT configuration as config, so custom validations can inject services. Each class is resolved once and reused for the life of the worker.
  • The unused PayloadException is removed.

Token sources

  • The cookie parser read the cookie named by jwt.token, the same key the input parser reads, so the cookie and input names could not differ. The new cookie_key_name setting (JWT_COOKIE_KEY_NAME, default token) names the cookie.
  • The Authorization header parser no longer falls back to the HTTP_AUTHORIZATION and REDIRECT_HTTP_AUTHORIZATION server values. The request already copies both into the header, so the fallbacks never ran.
  • Cookie decryption stays with the framework's EncryptCookies middleware. jwt-auth's decrypt_cookies option decrypts the cookie itself and cannot read cookies that middleware wrote, so it is not included.

Commands and environment files

  • Env::writeVariable() and Env::writeVariables() found existing assignments by matching raw lines, so they missed forms the dotenv loader accepts, such as KEY = value, export KEY=value and quoted names. Without overwrite they appended a second assignment, which the loader then used; with overwrite, an exported or later duplicate kept the old value. Text inside a multiline value could also be mistaken for an assignment. The writer now reads entries with phpdotenv's parser, replaces the last assignment of the key across all its lines and keeps an export prefix. An empty value is filled without overwrite, and a value that references other variables is never treated as equal to the new one.
  • jwt:secret detects an existing secret in every form the loader accepts, including an unresolved reference such as ${OTHER}, and replaces an empty secret without asking. --always-no takes precedence over --force. After writing a new secret, it warns that the server and other long-running processes must be restarted.
  • jwt:generate-certs --algo=ec failed with its default options, because the curve defaulted to prime256v1 while --sha defaulted to 512. The curve now follows the SHA variant, and an explicit curve that doesn't match still fails.

Tests

tests/Jwt now covers the provider, claims, blacklist, storage, manager, guard, parser, middleware, config and command behavior tested by jwt-auth, adapted to array payloads and configuration in place of runtime setters, plus regression tests for each fix above. The token sources are tested end to end through the auth middleware, including encrypted cookies. tests/Support/EnvFileTest covers the environment writer cases.

Documentation

The JWT guide now covers the blacklist default and the cache check it adds, choosing a blacklist store, removing and clearing revocations, public-key-only verification, custom drivers and validations, token sources and cookie handling, the guard's full method list, and making the JWT guard the default. Its user model example extended the base Eloquent model, which the guard cannot log in, and now extends Hypervel\Foundation\Auth\User. The Laravel porting guide gains a section for applications moving from jwt-auth. The package README lists the remaining differences from jwt-auth, with the reason and the Hypervel equivalent for each. docs/upstream-sync/sync.yaml records the jwt-auth revisions the package matches and how each repository maps onto it.

Verification

The full parallel suite, the Testbench package suite, PHPStan and formatting pass.


Summary by cubic

Makes JWT token revocation work out of the box: the blacklist is now enabled by default, and logout and refresh revoke the old token. The guard's login and logout are hardened, custom claims can set a token's lifetime and issuer, and services can verify tokens with only a public key.

The blacklist previously required a tag-capable cache store; CacheStorage now works on every store, and JWT_BLACKLIST_STORE selects which store holds revocations. Each authenticated request costs one cache read to check its token; set JWT_BLACKLIST_ENABLED=false if you never revoke before expiry. While the blacklist is enabled, tokens must carry a jti claim.

Revocation and the Guard

  • Logout no longer throws when the blacklist is off and stops re-authenticating from the same request header; a failed blacklist write still throws and dispatches no Logout event, which now carries the user.
  • attempt() runs inside a Timebox, so response times no longer reveal whether an account exists, and passwords rehash on login when hashing.rehash_on_login is enabled.
  • fromUser() and getUser() are public; id() now loads the user like Laravel's guards and returns null for deleted users.
  • Jwt::blacklist() exposes revocations so applications can remove one entry or clear them all.

Claims, Drivers, and Verification

  • Custom claims may now set exp, nbf, iat, iss, and jti, with exp taking precedence over the TTL; date claims accept timestamps, DateTimeInterface, and DateInterval values.
  • An asymmetric provider with no private key verifies with the public key only, applying subclass constraints from buildConfig(); issuing a token still fails with "Private key is not set." A Configuration passed to the Lcobucci provider signs and verifies instead of being ignored.
  • providers.jwt is replaced by the driver setting and Jwt::extend().
  • Tokens and signing credentials are marked #[SensitiveParameter] so exception traces don't leak them, and malformed ECDSA signatures are rejected as invalid tokens instead of surfacing a server error.
  • Env::writeVariable() and writeVariables() now replace the assignment the dotenv loader actually uses, which also fixes Sentry's publish command and install:broadcasting; jwt:secret detects existing secrets in every form the loader accepts.

Written for commit 7b747f1. Summary will update on new commits.

View guided diff Turn on auto-fix

Note

Improve JWT revocation, security guards, claims, and token verification

  • Blacklisting is now enabled by default and backed by a new CacheStorage class that works with tagged and untagged cache stores, configurable via jwt.blacklist_store in jwt.php. Removes the old TaggedCache default and its taggable-store validation.
  • JwtGuard gains rehashOnLogin and timeboxDuration settings, plus a public fromUser() method to issue tokens without credential checks, and getUser() to read the cached user. Logout now invalidates the request token only when the blacklist is enabled and marks the guard as logged out.
  • Lcobucci provider supports public-key-only asymmetric verification (refuses to sign without a private key), accepts date claims in multiple forms, converts ECDSA signature-length errors to TokenInvalidException, and marks secrets, keys, and tokens as sensitive parameters so they stay out of exception traces.
  • Cookie token extraction uses the new jwt.cookie_key_name config instead of jwt.token, and AuthHeaders no longer falls back to HTTP_AUTHORIZATION/REDIRECT_HTTP_AUTHORIZATION server variables.
  • Refresh behavior: leeway now extends the refresh window in both Blacklist and JwtManager, iss is carried through refresh instead of rebuilt, and iat, nbf, exp, jti are rebuilt unless supplied as custom claims.
  • Env writer rewrites assignments via Dotenv parsing, fixing duplicate keys, multiline values, and export prefixes; jwt:secret command uses it to detect and replace existing secrets and warns that long-running processes must be restarted.
  • Risk: existing setups relying on disabled blacklist, TaggedCache storage, the removed jwt.providers.jwt config key, JwtGuard::id(), server-variable Authorization fallbacks, or old revocation key formats need review. Blacklist keys now use prefixed SHA-256 hashes, so previously stored php-open-source-saver/jwt-auth-style revocation entries are not compatible.

Macroscope summarized 7b747f1.

A token signed with ES256, ES384 or ES512 whose signature had the wrong
length made Lcobucci::decode() throw lcobucci's ConversionFailed instead
of TokenInvalidException. The guard and auth:jwt middleware therefore
answered attacker-controlled input with a server error. Decoding now
treats that signature as unverifiable, while key and secret
configuration errors still propagate.

Decoded claims map dates to timestamps with array_map. The getValue()
branch is gone because lcobucci/jwt 5 returns plain claim values, and
the provider contract and isAsymmetric() gain their titles.

Tests reconcile the provider and token-validation coverage of
tymondesigns/jwt-auth 2.x (6c70930a9) and PHP-Open-Source-Saver/jwt-auth
main (1bdb72de5):

- Port the ES256 key fixtures and round trip from tymondesigns 5d188de,
  plus its nbf, jti and aud round trip.
- Add the fork's test that a token signed with a replaced secret is
  rejected.
- Turn the undecodable-token cases into one data provider that covers
  the original's token-validator inputs.
- Add a regression test for the wrong-length ECDSA signature.

The lcobucci v4 compatibility in 5d188de and its method_exists() switch
in 033b2df do not apply, since the package requires lcobucci/jwt ^5.0.
The README now explains why the Namshi provider is not included.

Validation: LcobucciTest and the Jwt suite pass; composer lint:fix and
composer analyse report no changes or errors.
Custom claims could not set exp, nbf, iat, iss or jti, so an application
could not give one token a longer lifetime or its own issuer. Date
claims also had to be Unix timestamps: a Carbon or DateTime value made
the Lcobucci provider throw an error. Model and inline custom
claims may now set every registered claim except sub and prv, both when
a token is created and when it is refreshed. An explicit exp takes
precedence over the TTL, and an explicit iat over refresh_iat. Date
claims accept integers, integer strings, DateTimeInterface instances
and DateInterval values, which one provider helper converts to whole
seconds.

Refresh now carries iss like any other claim and rebuilds only iat
(per refresh_iat), nbf, exp and jti. IssuedAtClaim is no longer a
temporal validation, so refresh rejects a token issued in the future
instead of restamping it into a valid one. The refresh window and the
blacklist retention for refreshable tokens both include jwt.leeway, so
a revocation lasts as long as any refresh it must block.

RequiredClaims lists missing claims as a JSON array. Before, a missing
claim that was not first in required_claims produced a JSON object such
as {"1":"iss"}.

Tests reconcile the claims, payload and validator coverage of
tymondesigns/jwt-auth 2.x (6c70930a9) and PHP-Open-Source-Saver/jwt-auth
main (1bdb72de5):

- Port the datetime claim forms and the multidimensional claim round
  trip.
- Cover custom registered claims at creation and refresh, the refresh
  issuer, future nbf and iat on refresh with and without leeway, and the
  leeway at the end of the refresh window and in blacklist retention.
- Cover complete, subset and multiple missing required claims.

Upstream's payload, claim and collection objects are replaced by
arrays, so their DTO-only tests have no equivalent. The original's
6c70930a9 casts the TTL to int, which turns a null TTL into an
immediately expiring token; Hypervel already casts the configured
values while keeping null, so that cast is not ported.

The config comments, README and JWT guide describe claim precedence,
what refresh rebuilds and where leeway applies. Validation classes and
JwtManager gain their missing method titles.

Validation: the Jwt suite passes; composer lint:fix and composer analyse
report no changes or errors.
The blacklist required a cache store with tag support, so the default
database cache store made the provider throw as soon as the blacklist
was enabled. Its keys were also the raw token identifiers, which a
custom jti claim can make longer than the database store's key column.

TaggedCache becomes CacheStorage and works on every store. Writes go
through the blacklist tag when the store supports tags, and reads and
deletes go through it only in all-mode, where tags namespace the keys.
Other stores use plain keys. Every physical key is jwt_blacklist: plus
the SHA-256 of the identifier, so its length is fixed and
application-chosen identifiers cannot be crafted to collide.

Clearing the blacklist still removes only blacklist entries. Both
upstreams flush the whole cache when the store has no tags; Hypervel
lets the cache's own tag error surface instead, so other cache entries
are never lost.

The new blacklist_store setting (JWT_BLACKLIST_STORE) picks the cache
store used by the default storage, and null keeps the default store.
Blacklist now takes the refresh TTL as a required parameter after the
storage, keeping null as unlimited.

Tests reconcile the blacklist and storage coverage of
tymondesigns/jwt-auth 2.x (6c70930a9) and PHP-Open-Source-Saver/jwt-auth
main (1bdb72de5):

- BlacklistTest's cases already had equivalents; only the constructor
  call changes.
- The storage test's tagged cases map to the all-mode and any-mode
  cases, and its untagged put, forever, get and forget cases are
  ported. Its whole-cache flush case is replaced by a test that the
  untagged clear throws without flushing the store, and the
  TaggedStorage stub is not needed because tag support comes from the
  real store.
- New tests cover clearing without touching unrelated entries, stores
  with tags disabled, long identifiers within a 255-character key,
  distinct identifiers that share a long prefix, and blacklist store
  selection.

Upstream's setLaravelVersion() minutes conversion has no equivalent,
because CacheStorage always converts minutes to seconds.

The config comments, README and JWT guide describe the store setting,
which stores share revocations between servers, the clearing
requirement and what the grace period does.

Validation: the Jwt suite passes; composer lint:fix changed no files and
composer analyse reports no errors.
The JWT provider could be chosen two ways: the driver setting picked a
manager driver, and providers.jwt picked a class for the built-in
lcobucci driver, which had to extend Lcobucci. The providers.jwt
setting is removed. The driver setting and Jwt::extend() now cover both
cases, the same way Laravel's driver managers work, and replacing the
lcobucci driver is done by registering a driver under that name.

Applications had no supported way to reach the blacklist, so removing
one revocation or clearing them all meant resolving the contract by
hand. JwtManager::blacklist() is now public, through Jwt::blacklist(),
and resolves the blacklist on first use. This also removes a null check
that normal use could never reach. Decoding and refreshing still skip
the blacklist entirely when it is disabled.

Tests reconcile the manager and refresh-claim coverage of
tymondesigns/jwt-auth 2.x (6c70930a9) and PHP-Open-Source-Saver/jwt-auth
main (1bdb72de5):

- A custom driver can replace lcobucci, a disabled blacklist is never
  resolved during decoding or refreshing, and blacklist() returns the
  same resolved instance.
- The fork's repeated-refresh iat tests are ported to the claim
  factory: one factory refreshes the same payload twice, two minutes
  apart, and keeps or restamps iat exactly as configured.
- Upstream's setter tests have no equivalent because these settings
  come from configuration. Upstream's getPayloadFactory() has no
  equivalent because payloads are arrays. The show_black_list_exception
  tests are not ported because the option is not included.

The JWT guide gains a section on removing and clearing revocations,
explains when to register custom drivers, and describes which guard
methods throw for invalid tokens and how to keep those exceptions out
of logs. Its list of upstream differences is removed so the package
README is the only copy. The README now covers the facade's blacklist
and driver access and the driver setting, and gives the correct reason
show_black_list_exception is not included: disabling it in upstream
makes revoked tokens valid.

Validation: the Jwt suite passes, FacadeDocblocksTest passes,
composer lint:fix changed no files and composer analyse reports no
errors.
Logging out through the JWT guard threw when the blacklist was
disabled, which is the default. With the blacklist enabled, user()
could still authenticate again from the same request header right after
logout, during the blacklist grace period. Logout now revokes the
token only when the blacklist is enabled, clears the guard's state and
stops reading the request's token until login() or setToken() is
called. The Logout event now carries the user who logged out. A failed
blacklist write still throws, keeps the guard's state and dispatches no
Logout event.

attempt() had no timing protection, so response times showed whether an
account existed. Like the session guard, it now runs inside a cloned
Timebox using auth.timebox_duration, and returns early only on
success. Passwords are rehashed on login when hashing.rehash_on_login is
enabled, in attempt() and once(), but never in validate(), which only
checks credentials.

The guard gains a public fromUser() method that issues a token without
logging the user in, replacing the protected makeTokenForUser(), and
getUser() becomes public to return the user the guard has already
resolved. The id() override is removed, so id() loads the user like
Laravel's guards and returns null for a deleted user; getUserId() still
reads the token subject without loading the user.

Tests reconcile the guard, JWTAuth and auth-adapter coverage of
tymondesigns/jwt-auth 2.x (6c70930a9) and PHP-Open-Source-Saver/jwt-auth
main (1bdb72de5):

- Invalid, expired and blacklisted tokens leave the guard
  unauthenticated and make userOrFail() throw. A token whose user no
  longer exists also leaves it unauthenticated, while getUserId() still
  returns the subject.
- The Authenticated event fires once when the token user resolves, as
  added by fork #298, and no event fires for a missing user, the null
  case fixed by fork #300. Both behaviors were already present.
- Logout covers revocation, a disabled blacklist, an expired token, the
  forceForever flag, write failures and the Logout event's user.
- Timing tests check that failures wait for the configured duration and
  that a success never leaves the shared timebox returning early.
- byId(), onceUsingId(), fromUser(), tokenById() and macros are
  covered. Upstream tests for forwarding to the JWT object, request
  setters and token-validity check() have no equivalent because those
  APIs are not included.

Fork #301 is not applicable: it scopes tymon.jwt for Octane, and the
Hypervel guard already keeps request state in the coroutine context.
Original #2292 is not applicable: it replaces user()'s docblock with
{@inheritdoc}, and Hypervel uses title docblocks.

The JWT guide documents fromUser(), getUser(), password rehashing, the
id() and getUserId() difference and the new logout behavior. The README
lists the guard's lack of method forwarding and the id() difference
from upstream.

Validation: the Jwt and Auth suites pass, composer lint:fix changed no
files and composer analyse reports no errors.
The Cookie parser read the cookie named by jwt.token, the same key
InputSource reads from request input, so an application could not name
the cookie and the input key separately. A new cookie_key_name setting
(JWT_COOKIE_KEY_NAME, default "token") names the cookie, following
PHP-Open-Source-Saver/jwt-auth's option of the same name.

AuthHeaders no longer falls back to the HTTP_AUTHORIZATION and
REDIRECT_HTTP_AUTHORIZATION server values. The request bridge and
Symfony's request already copy both into the Authorization header, so
the fallbacks never ran.

Cookie decryption stays with the framework's EncryptCookies middleware.
Upstream's decrypt_cookies option decrypts the cookie itself, which
cannot read cookies written by that middleware, so it is not included.

Tests reconcile the parser and middleware coverage of
tymondesigns/jwt-auth 2.x (6c70930a9) and PHP-Open-Source-Saver/jwt-auth
main (1bdb72de5):

- Bearer parsing covers other schemes, an empty token, a tab after the
  scheme, surrounding whitespace and trailing hyphens.
- InputSource reads form and JSON bodies, and the body wins over the
  query string, like the request's input() method.
- The parser chain skips extractors that return null or an empty
  string, and stops at the first token. A custom extractor configured
  in jwt.parser is resolved from the container and used.
- The provider passes jwt.token to InputSource and cookie_key_name to
  Cookie.
- The auth middleware accepts a valid token and rejects a missing
  token, an invalid token and a token whose user no longer exists, which
  routes without the middleware treat as guests. These replace
  upstream's jwt.auth and jwt.check middleware tests.
- An encrypted cookie authenticates after EncryptCookies decrypts it,
  and a cookie that cannot be decrypted is ignored.

The query-string-over-input, route-parameter, Lumen, mutable parser
chain and refresh middleware tests have no equivalent because those
parsers, setters and middleware are not included.

The JWT guide explains the parser order, input precedence, the cookie
setting, how EncryptCookies and CSRF protection apply to cookie tokens,
custom extractors and routes that allow guests. The README lists the
token-source, decrypt_cookies and middleware differences from upstream.
The Testbench .env.example lists JWT_COOKIE_KEY_NAME.

Validation: the Jwt suite passes, composer lint:fix changed no files
and composer analyse reports no errors.
The blacklist is now enabled by default, as in both upstream packages,
so logout and refresh revoke the old token. New tokens include a jti
claim and authenticated requests check the blacklist in the cache.
Applications that don't need to revoke tokens before they expire can set
JWT_BLACKLIST_ENABLED to false.

Env::writeVariable() and Env::writeVariables() found existing
assignments by matching raw lines, so they missed assignments the dotenv
loader accepts, such as "KEY = value", "export KEY=value" and quoted
names. Without overwrite they appended a second assignment, which the
loader then used. With overwrite, an exported or later duplicate kept
the old value. Text inside a multiline value could also be taken for an
assignment. The writer now reads entries with phpdotenv's parser,
replaces the last assignment of the key across all of its lines and
keeps an export prefix. An empty value is filled without overwrite, and
a value that references other variables is never treated as equal to
the new value. jwt:generate-certs, Sentry's publish command and
install:broadcasting use the same writer.

jwt:secret now detects an existing secret in every form the loader
accepts, including an unresolved reference such as "${OTHER}", and
replaces an empty secret without asking. --always-no takes precedence
over --force. After writing a new secret, the command warns that the
server and other long-running processes must be restarted.

jwt:generate-certs --algo=ec failed with its default options, because
the curve defaulted to prime256v1 while --sha defaulted to 512. The
curve now defaults to the one the SHA variant requires, and an explicit
curve that does not match still fails.

The provider's binding closures are typed, and the config's TTL note is
reworded.

Tests reconcile LaravelServiceProviderTest, DefaultConfigValuesTest and
EnvHelperTraitTest from PHP-Open-Source-Saver/jwt-auth main
(1bdb72de5); tymondesigns/jwt-auth 2.x (6c70930a9) has no matching
tests:

- Every config default is asserted with the JWT environment variables
  cleared.
- The provider passes the grace period, refresh TTL and leeway to the
  blacklist, and the secret, algorithm and keys to the Lcobucci driver.
- Both Env writers update the assignment the loader uses for spaced,
  exported, quoted-name, duplicate, empty, referenced and multiline
  entries.
- jwt:secret covers plain, spaced, exported and referenced secrets with
  --always-no and --force, confirmation, empty secrets and the restart
  warning.
- jwt:generate-certs covers the curve derived from each SHA variant and
  the default EC options.

The Laravel 13 support changes in both upstreams
(PHP-Open-Source-Saver/jwt-auth#297, tymondesigns/jwt-auth#2299) only
update their own Composer constraints and CI, so nothing applies here.

The JWT guide explains the blacklist default and how to disable it, that
invalidate() needs the blacklist, the --always-no precedence, restarting
after a new secret, the EC curve defaults, and choosing a shared --dir
because certificate paths in .env are absolute.

Validation: the Jwt suite, the Env writer tests and the Sentry publish
and broadcasting install command tests pass, composer lint:fix changed
no files and composer analyse reports no errors.
Bearer tokens and signing credentials could appear in exception traces.
Error trackers such as Sentry report frame arguments when PHP keeps
them, so a failed decode, refresh or invalidation could send the raw
token, and a provider failure could send the secret, private key or
passphrase. Token parameters on the manager contract and JwtManager
(decode, refresh, invalidate and the refresh decode), the provider
contract, Lcobucci::decode() and the guard's token decode, plus the
Provider and Lcobucci constructors, setSecret(), setKeys() and
Lcobucci::getKey(), are now marked #[SensitiveParameter].

An asymmetric Lcobucci provider without a private key failed when it
was created, so a service that only verifies tokens from a separate
authentication server needed the private key too. Such a provider now
verifies tokens with the public key, using lcobucci's parser, validator
and a SignedWith constraint, and issuing or refreshing a token still
fails with the existing "Private key is not set." error. Other providers
build their configuration when created, as before, so constraints added
by a buildConfig() subclass still apply when decoding. A Configuration
passed to the constructor signs and verifies tokens instead of one built
from the secret and keys, and the setters replace it with a rebuilt one.

JwtManager now resolves validation classes through the container,
passing the jwt configuration as the config argument, so custom
validations can inject services. Each class is resolved once and reused
for the worker.

The unused PayloadException is removed.

The README now credits both upstream repositories, explains the
worker-shared guard and manager design, and lists the differences with
their equivalents: array payloads, the missing JWTAuth, JWTFactory and
JWTProvider facades, an optional JwtSubject, no method forwarding or
runtime setters, the no-token refresh() and payload() results, the
fork's id() behavior, the required claims and 120-minute TTL, and
subject locking. The JWT guide covers public-key-only verification and
custom validations, including why they must not keep per-request state.

The sync notes for PHP-Open-Source-Saver/jwt-auth main (1bdb72de5) and
tymondesigns/jwt-auth 2.x (6c70930a9) record the source, test and docs
mapping, the processing order and the required adaptations, and add the
original repository's entry.

Tests cover RSA and ECDSA verification with only the public key,
including rejecting a forged payload, the missing-private-key error
now raised when issuing a token, a buildConfig() subclass constraint
during decoding, a given
Configuration, container-resolved validations that are resolved once,
and credentials and tokens kept out of exception traces.

Validation: the Jwt suite and FacadeDocblocksTest pass, composer
lint:fix only moved attributes onto their own lines, and composer
analyse reports no errors.
The JWT guide now covers the remaining guard features from both
jwt-auth repositories' docs: making the JWT guard the application's
default with AUTH_GUARD, reading the current token's claims with
payload(), authenticating a specific token with setToken() and revoking
a token forever through logout(true). The method list adds check() and
getTTL(). It also says that passing a curve that does not match the SHA
variant to jwt:generate-certs fails.

The User Models example extended the base Eloquent model, which does
not implement Authenticatable, so the guard could not log in or issue
tokens for it. It now extends Hypervel\Foundation\Auth\User, matching
both upstream guides. The credits now name both upstream repositories
without claiming which one the package started from.

The Laravel porting guide gains a section for applications moving from
tymon/jwt-auth or php-open-source-saver/jwt-auth. It covers publishing
the new config file instead of reusing the old one, replacing the
facades, runtime setters and middleware, enabling the query, body or
cookie parsers when clients send tokens there, and rotating the secret
or key pair, since revocations stored by the old package use different
cache keys.

The sync registry records PHP-Open-Source-Saver/jwt-auth main checked
through 1bdb72de5 (last reviewed PR 301) and tymondesigns/jwt-auth 2.x
through 6c70930a9 (last reviewed PR 2292).

Validation: composer test:parallel and composer test:testbench pass.
@coderabbitai

coderabbitai Bot commented Oct 7, 2026 •

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration
  • Configuration used: Repository: hypervel/components-backup/.coderabbit.yaml
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 5b7e2005-e863-49af-9144-7bd16d6d3ece

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
  • Autopilot · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@qodo-free-for-open-source-projects

Copy link
Copy Markdown

PR Summary by Qodo

Enable JWT revocation by default and harden authentication

🐞 Bug fix ✨ Enhancement 🧪 Tests 📝 Documentation ⚙️ Configuration changes 🕐 40+ Minutes

Grey Divider

AI Description

• Enable cache-backed JWT revocation by default, with a configurable store and an opt-out.
• Harden guard login, logout, claims, refresh, and signature verification behavior.
• Fix dotenv assignment writes and expand JWT tests, configuration, and migration guidance.
Diagram

graph TD
  R["Incoming request"] --> P["Token parser"] --> G["JWT guard"] --> M["JWT manager"] --> V["JWT provider"]
  M --> X["Claim validations"]
  M --> B["Blacklist"] --> C[("Cache store")]
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Keep revocation opt-in
  • ➕ Avoids a cache read for applications that do not revoke tokens.
  • ➖ Logout and refresh would not revoke tokens without additional configuration.
2. Require a taggable revocation store
  • ➕ Makes clearing all revocations uniformly available.
  • ➖ Breaks the default database-cache setup and narrows storage choices.

Recommendation: Keep the PR's default-on revocation with an explicit opt-out: it gives logout and refresh dependable behavior without requiring cache tags for ordinary checks. Document the per-request cache cost and the tag requirement for clearing all entries, as this PR does.

Files changed (47) +2877 / -615

Enhancement (8) +285 / -148
ClaimFactory.phpAllow registered claim overrides during issuance and refresh +13/-20

Allow registered claim overrides during issuance and refresh

• Reserves only identity claims, allowing custom expiration, timestamps, issuer, and token identifiers. Refresh rebuilds token-specific claims while retaining eligible issuer and issued-at values.

src/jwt/src/ClaimFactory.php

ManagerContract.phpRedact raw tokens in manager contract traces +5/-2

Redact raw tokens in manager contract traces

• Marks decode, refresh, and invalidation token arguments as sensitive.

src/jwt/src/Contracts/ManagerContract.php

ProviderContract.phpRedact provider decode arguments +9/-1

Redact provider decode arguments

• Marks raw decode tokens as sensitive and documents the provider's encode and decode methods.

src/jwt/src/Contracts/ProviderContract.php

JwtGuard.phpHarden JWT credential checks and logout state +91/-62

Harden JWT credential checks and logout state

• Adds timeboxed credential attempts, conditional password rehashing, public 'fromUser()' and 'getUser()' methods, and sensitive-argument annotations. Logout conditionally revokes the token, preserves state on revocation failure, and prevents reuse of the request token afterward.

src/jwt/src/JwtGuard.php

JwtManager.phpResolve extensible JWT drivers and validations +24/-24

Resolve extensible JWT drivers and validations

• Uses the built-in driver directly while allowing manager driver replacement, resolves validations through the container, and exposes the blacklist. Extends refresh eligibility by leeway and redacts token arguments in traces.

src/jwt/src/JwtManager.php

JwtServiceProvider.phpWire configurable blacklist storage and token parsers +19/-37

Wire configurable blacklist storage and token parsers

• Selects the configured cache store without requiring tags, gives input and cookie parsers independent names, and supplies guard timing and password-rehash settings.

src/jwt/src/JwtServiceProvider.php

Provider.phpProtect provider credentials in traces +5/-2

Protect provider credentials in traces

• Marks constructor secrets and keys, plus secret and key setter arguments, as sensitive.

src/jwt/src/Providers/Provider.php

CacheStorage.phpStore revocations on tagged or untagged caches +119/-0

Store revocations on tagged or untagged caches

• Adds cache storage with bounded SHA-256 identifier keys and tag-mode-aware reads and writes. Clearing all entries uses tags and leaves unrelated cache data untouched when tags are unavailable.

src/jwt/src/Storage/CacheStorage.php

Bug fix (7) +222 / -72
Blacklist.phpRetain revocations through refresh leeway +10/-4

Retain revocations through refresh leeway

• Extends blacklist retention to cover the refresh window plus configured leeway. Clarifies its constructor and expiration behavior.

src/jwt/src/Blacklist.php

JwtGenerateCertsCommand.phpChoose the EC curve from the SHA variant +4/-2

Choose the EC curve from the SHA variant

• Derives the default EC curve from the selected SHA variant so default ES512 certificate generation succeeds.

src/jwt/src/Console/JwtGenerateCertsCommand.php

JwtSecretCommand.phpDetect dotenv secrets before replacing them +18/-10

Detect dotenv secrets before replacing them

• Uses the dotenv parser to recognize existing secrets, including references. Honors '--always-no' over '--force' and warns that long-running processes need restarting after a write.

src/jwt/src/Console/JwtSecretCommand.php

Lcobucci.phpSupport public-key verification and safer token decoding +122/-31

Support public-key verification and safer token decoding

• Verifies asymmetric tokens without a private key, honors a supplied Lcobucci configuration, and accepts date objects and intervals in temporal claims. Treats malformed ECDSA signature lengths as invalid tokens and redacts signing credentials and raw tokens.

src/jwt/src/Providers/Lcobucci.php

IssuedAtClaim.phpReject future-issued tokens during refresh +4/-2

Reject future-issued tokens during refresh

• Stops classifying issued-at validation as refresh-skippable, preventing future-issued tokens from being restamped into valid ones.

src/jwt/src/Validations/IssuedAtClaim.php

RequiredClaims.phpReport missing claims as a JSON array +4/-1

Report missing claims as a JSON array

• Reindexes missing claim names before encoding them so errors remain arrays even when the first required claims are present.

src/jwt/src/Validations/RequiredClaims.php

Env.phpWrite the dotenv assignment the loader uses +60/-22

Write the dotenv assignment the loader uses

• Parses dotenv entries and their line spans to replace the last matching assignment without mistaking multiline content for another variable. Preserves export prefixes and handles empty, duplicate, spaced, quoted, and referenced values.

src/support/src/Env.php

Refactor (1) +1 / -3
AuthHeaders.phpRead Authorization from normalized headers +1/-3

Read Authorization from normalized headers

• Removes redundant server-variable fallbacks because the request already exposes those values through its headers.

src/jwt/src/Http/Parser/AuthHeaders.php

Tests (19) +2106 / -287
BlacklistTest.phpTest refresh-leeway revocation retention +19/-5

Test refresh-leeway revocation retention

• Checks that blacklist entries remain stored through refresh-window leeway and adjusts construction to supply refresh TTL.

tests/Jwt/BlacklistTest.php

ClaimFactoryTest.phpTest claim precedence and refresh preservation +140/-9

Test claim precedence and refresh preservation

• Covers registered claim overrides, reserved identity claims, repeated refreshes, issuer retention, and explicit refresh claim precedence.

tests/Jwt/ClaimFactoryTest.php

JwtGenerateCertsCommandTest.phpTest inferred EC certificate curves +15/-2

Test inferred EC certificate curves

• Checks curve selection for SHA variants and successful ES512 generation with default options.

tests/Jwt/Console/JwtGenerateCertsCommandTest.php

JwtSecretCommandTest.phpTest secret replacement decisions +41/-8

Test secret replacement decisions

• Covers exported, spaced, referenced, and empty secret assignments, flag precedence, and restart messaging.

tests/Jwt/Console/JwtSecretCommandTest.php

id_ecdsaAdd EC signing fixture +5/-0

Add EC signing fixture

• Adds a private EC key for ECDSA signing and verification tests.

tests/Jwt/Fixtures/keys/id_ecdsa

id_ecdsa.pubAdd EC verification fixture +4/-0

Add EC verification fixture

• Adds the matching public EC key for ECDSA and public-key-only verification tests.

tests/Jwt/Fixtures/keys/id_ecdsa.pub

ParserTest.phpExpand token-source parsing tests +104/-5

Expand token-source parsing tests

• Checks bearer-header edge cases, request-body precedence over query input, and extractor ordering.

tests/Jwt/Http/ParserTest.php

JwtConfigTest.phpAssert JWT defaults and environment overrides +58/-17

Assert JWT defaults and environment overrides

• Verifies default blacklist, store, cookie, parser, and claim settings alongside their environment overrides.

tests/Jwt/JwtConfigTest.php

JwtGuardContextTest.phpTest token sources through authentication routes +96/-1

Test token sources through authentication routes

• Exercises authenticated and guest requests through middleware, including encrypted and undecryptable cookies.

tests/Jwt/JwtGuardContextTest.php

JwtGuardCoroutineSafetyTest.phpAdapt coroutine guard setup +8/-6

Adapt coroutine guard setup

• Supplies the new rehash and timebox constructor settings in coroutine-isolation tests.

tests/Jwt/JwtGuardCoroutineSafetyTest.php

JwtGuardEventTest.phpTest guard events around credentials and logout +170/-9

Test guard events around credentials and logout

• Checks credential-check events, token issuance without login events, logout user payloads, and the absence of logout events after failed revocation.

tests/Jwt/JwtGuardEventTest.php

JwtGuardSubjectLockTest.phpAdapt subject-lock guard setup +8/-6

Adapt subject-lock guard setup

• Supplies the new guard constructor settings while retaining subject-lock coverage.

tests/Jwt/JwtGuardSubjectLockTest.php

JwtGuardTest.phpExercise guard security and state transitions +377/-46

Exercise guard security and state transitions

• Adds coverage for logout with and without revocation, failed writes, cached users, token issuance without login, password rehashing, and timeboxed attempts.

tests/Jwt/JwtGuardTest.php

JwtManagerTest.phpTest driver, validation, and refresh behavior +276/-22

Test driver, validation, and refresh behavior

• Covers driver replacement, lazy blacklist resolution, container-injected validations, future timestamp rejection, refresh leeway, blacklist access, and sensitive traces.

tests/Jwt/JwtManagerTest.php

JwtServiceProviderTest.phpTest JWT service wiring across cache stores +83/-145

Test JWT service wiring across cache stores

• Checks separate cookie and input names, custom extractor resolution, selected blacklist stores, and guard/provider configuration.

tests/Jwt/JwtServiceProviderTest.php

LcobucciTest.phpTest signing and verification edge cases +335/-2

Test signing and verification edge cases

• Covers ECDSA round trips and malformed signatures, date claim forms, public-key-only verification, supplied configurations, and credential redaction.

tests/Jwt/Providers/LcobucciTest.php

CacheStorageTest.phpTest tagged and untagged revocation storage +258/-0

Test tagged and untagged revocation storage

• Exercises reads, writes, deletion, and safe flushing across tag modes and untagged stores. Verifies bounded, distinct cache keys for long identifiers.

tests/Jwt/Storage/CacheStorageTest.php

RequiredClaimsTest.phpTest missing-claim error formatting +22/-4

Test missing-claim error formatting

• Asserts that missing claims are reported as JSON arrays whether one or several names are absent.

tests/Jwt/Validations/RequiredClaimsTest.php

EnvFileTest.phpTest dotenv-aware assignment writing +87/-0

Test dotenv-aware assignment writing

• Covers both environment writers against duplicates, exports, spacing, quoted names, empty values, references, and multiline assignments.

tests/Support/EnvFileTest.php

Documentation (9) +226 / -72
sync.yamlRecord jwt-auth reconciliation points +11/-3

Record jwt-auth reconciliation points

• Records reviewed revisions for both jwt-auth repositories and maps their source, tests, and documentation to Hypervel.

docs/upstream-sync/sync.yaml

jwt.mdExpand JWT configuration and usage guide +159/-54

Expand JWT configuration and usage guide

• Documents default revocation, cache selection, token sources, claims, public-key verification, extensibility, and guard methods. Corrects the authenticatable user-model example.

src/docs/jwt.md

porting-from-laravel.mdAdd jwt-auth migration guidance +10/-0

Add jwt-auth migration guidance

• Explains facade, configuration, middleware, and parser replacements. Advises rotating signing credentials because existing revocations use different cache keys.

src/docs/porting-from-laravel.md

README.mdExplain deliberate differences from jwt-auth +27/-9

Explain deliberate differences from jwt-auth

• Lists Hypervel's array-payload, shared-worker, configuration, middleware, parser, and blacklist differences alongside their equivalents.

src/jwt/README.md

TemporalValidation.phpClarify refresh-skipped validations +3/-0

Clarify refresh-skipped validations

• Documents the marker used for validations replaced by refresh-window checks.

src/jwt/src/Contracts/TemporalValidation.php

AbstractValidation.phpDocument validation responsibilities +9/-0

Document validation responsibilities

• Documents validation construction, payload checks, and timestamp conversion.

src/jwt/src/Validations/AbstractValidation.php

ExpiredClaim.phpClarify expiration validation +3/-0

Clarify expiration validation

• Documents the expiration check performed on JWT payloads.

src/jwt/src/Validations/ExpiredClaim.php

NotBeforeClaim.phpClarify not-before validation +3/-6

Clarify not-before validation

• Updates documentation for the check that prevents premature token use.

src/jwt/src/Validations/NotBeforeClaim.php

Jwt.phpExpose blacklist facade method to tooling +1/-0

Expose blacklist facade method to tooling

• Adds the 'blacklist()' facade annotation for the manager's public blacklist access.

src/support/src/Facades/Jwt.php

Other (3) +37 / -33
composer.jsonDeclare direct phpdotenv dependency +2/-1

Declare direct phpdotenv dependency

• Adds phpdotenv as a direct dependency for JWT secret detection through its parser.

src/jwt/composer.json

jwt.phpEnable blacklist and configure its store +34/-32

Enable blacklist and configure its store

• Enables revocation by default and adds blacklist-store and cookie-name settings. Replaces tagged-cache defaults and removes the separate JWT provider-class setting.

src/jwt/config/jwt.php

.env.exampleShow configurable JWT cookie name +1/-0

Show configurable JWT cookie name

• Adds the 'JWT_COOKIE_KEY_NAME' example setting.

src/testbench/hypervel/.env.example

@qodo-free-for-open-source-projects

qodo-free-for-open-source-projects Bot commented Oct 7, 2026 •

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (5) 📘 Rule violations (0) 📎 Requirement gaps (0) 🎨 UX issues (0) 🔗 Cross-repo conflicts (0) 📜 Skill insights (0)

Grey Divider


Action required

1. Public-key verifiers skip issuer checks ✓ Resolved
Description
The no-private-key branch of Lcobucci::configure() constructs verification objects with only a
SignedWith constraint instead of using constraints supplied by an overridden buildConfig(). A
custom provider that enforces an issuer or audience through that method accepts correctly signed
tokens that fail its additional checks when deployed with only a public key.
Code

src/jwt/src/Providers/Lcobucci.php[R240-243]

+        if ($this->config === null && $this->isAsymmetric() && ! $this->getPrivateKey()) {
+            $this->parser = new TokenParser(new JoseEncoder);
+            $this->validator = new TokenValidator;
+            $this->validationConstraints = [new SignedWith($this->signer, $this->getVerificationKey())];
Evidence
The normal configuration path takes its constraints from buildConfig(), and the repository tests
demonstrate a subclass adding an issuer constraint there. The new public-key-only path never calls
that method and installs only SignedWith.

src/jwt/src/Providers/Lcobucci.php[212-252]
tests/Jwt/Providers/LcobucciTest.php[559-572]
tests/Jwt/Providers/LcobucciTest.php[727-739]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Public-key-only verification bypasses constraints installed by custom `buildConfig()` implementations.
## Fix Focus Areas
- src/jwt/src/Providers/Lcobucci.php[212-252]
- tests/Jwt/Providers/LcobucciTest.php[559-572]
- tests/Jwt/Providers/LcobucciTest.php[727-739]
## Recommended Fix
Give public-key-only providers a way to build their parser, validator, and full validation-constraint set through the same customization path as full-key providers, without requiring a signing key. Add a regression test using an asymmetric provider with a custom issuer constraint and no private key.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended

2. Logout fails when user lookup fails 🐞 Bug ☼ Reliability
Description
JwtGuard::logout() now calls user() rather than reading the already cached user, so it may call
retrieveById() before attempting token invalidation. If that lookup fails—for example, because the
user database is unavailable—the exception prevents the blacklist write and leaves the token usable
even when the revocation cache is available.
Code

src/jwt/src/JwtGuard.php[428]

+        $user = $this->user();
Evidence
The changed call resolves the user before invalidate(). An uncached user() reaches the
provider's retrieveById() call, so an exception there exits logout before its blacklist write or
state cleanup.

src/jwt/src/JwtGuard.php[139-190]
src/jwt/src/JwtGuard.php[423-443]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Logout now requires a successful user lookup before it can revoke a token, although revocation needs only the token.
## Fix Focus Areas
- src/jwt/src/JwtGuard.php[139-190]
- src/jwt/src/JwtGuard.php[423-443]
## Recommended Fix
Make token invalidation independent of resolving the user. Use an already cached user for the logout event, or handle lookup failure without skipping revocation and guard-state cleanup; test logout with a failing user provider and a working blacklist.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


3. Every existing token stops working on upgrade 🐞 Bug ☼ Reliability
Description
Changing blacklist_enabled from false to true makes JwtManager::decode() and
decodeForRefresh() pass older tokens without a jti to Blacklist::has(), where getKey()
throws TokenInvalidException. For apps using the unpublished default configuration, tokens issued
before the upgrade then make user() return null and refresh() throw; those tokens also cannot be
revoked normally.
Code

src/jwt/config/jwt.php[238]

+    'blacklist_enabled' => (bool) env('JWT_BLACKLIST_ENABLED', true),
Evidence
JwtManager::encode() adds jti only when the blacklist is enabled, so tokens issued under the
former default lack that claim. The new default makes decoding call Blacklist::has(), whose
getKey() rejects a missing jti with TokenInvalidException. JwtGuard::user() catches the
exception and returns null, while decodeForRefresh() also encounters it, preventing refresh.

src/jwt/src/JwtManager.php[64-91]
src/jwt/src/JwtManager.php[160-171]
src/jwt/src/Blacklist.php[160-173]
src/jwt/src/JwtGuard.php[170-176]
src/jwt/config/jwt.php[235-238]
src/jwt/src/JwtManager.php[63-88]
src/jwt/src/Blacklist.php[169-190]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Enabling the blacklist by default rejects still-valid tokens issued under the former default because they lack `jti`. This prevents authentication and refresh for those tokens, which also cannot be revoked normally.
## Fix Focus Areas
- src/jwt/config/jwt.php[235-251]
- src/jwt/src/JwtManager.php[63-88]
- src/jwt/src/JwtManager.php[160-171]
- src/jwt/src/Blacklist.php[169-190]
## Recommended Fix
Provide an upgrade-compatible path for tokens without `jti`. In `decode()` and `decodeForRefresh()`, skip blacklist lookup for such tokens so they remain usable until expiry, while accounting for the fact that they cannot be revoked through the normal blacklist key; alternatively, stage or retain the previous default until existing tokens expire before enforcing the blacklist. Add an upgrade regression test using an old token without `jti`, and document the upgrade behavior in the README or porting guide.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


4. Revoked tokens become valid again after upgrade 🐞 Bug ⛨ Security
Description
CacheStorage::storageKey() uses jwt_blacklist: plus a SHA-256 hash of the jti, but the removed
TaggedCache stored revocations under the raw jti in all-mode or jwt_blacklist: in any-mode. When
an app with existing revocations deploys the new default storage, blacklist lookups miss those
records, so tokens revoked by logout or refresh can be accepted until they expire or leave the
refresh window.
Code

src/jwt/src/Storage/CacheStorage.php[R113-118]

+    protected function storageKey(string $key): string
+    {
+        // Hashing bounds application-chosen identifiers to fit limited key columns,
+        // such as the database store's. SHA-256 keeps distinct identifiers apart.
+        return self::KEY_PREFIX . hash('sha256', $key);
+    }
Evidence
The removed TaggedCache::storageKey() returned either the raw key or 'jwt_blacklist:' . $key,
with all-mode reads going through the blacklist tag. The replacement default storage reads only
'jwt_blacklist:' . hash('sha256', $key); no migration or fallback reads the former keys. Because
JwtManager::decode() relies on Blacklist::has() to reject revoked tokens, it does not find
revocations written before deployment.

src/jwt/src/Storage/CacheStorage.php[64-70]
src/jwt/src/Storage/CacheStorage.php[110-118]
src/jwt/src/Blacklist.php[100-113]
src/jwt/config/jwt.php[318-329]
src/jwt/src/Storage/CacheStorage.php[61-74]
src/jwt/src/Storage/CacheStorage.php[110-117]
src/jwt/src/JwtManager.php[77-88]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The new default storage hashes blacklist keys, making revocations written under the former `TaggedCache` key formats invisible after an upgrade.
## Fix Focus Areas
- src/jwt/config/jwt.php[318-329]
- src/jwt/src/Storage/CacheStorage.php[61-78]
- src/jwt/src/Storage/CacheStorage.php[110-118]
## Recommended Fix
Add a bounded compatibility lookup in `get()` when the hashed key is missing: check the raw `$key` through the tagged cache in all-mode, or `'jwt_blacklist:' . $key` in any-mode, and return an existing value. Have `destroy()` remove both new and legacy keys. Alternatively, migrate existing records before switching the default storage. Test that a token revoked before the storage change remains blocked afterward; the fallback can be removed after one maximum refresh window. At minimum, document the upgrade impact.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


View medium (1)
5. Older published configs break the blacklist 🐞 Bug ☼ Reliability
Description
The blacklist binding resolves jwt.providers.storage and calls $app->make($storageClass) for
anything other than CacheStorage. mergeConfigFrom() merges only top-level keys, so an app whose
published config/jwt.php still names Hypervel\Jwt\Storage\TaggedCache keeps that value, but the
class is deleted. With the blacklist enabled, resolving it throws a binding resolution error on
every authenticated request, logout and refresh, instead of a clear upgrade message.
Code

src/jwt/src/JwtServiceProvider.php[R57-62]

+            $storageClass = $config->string('jwt.providers.storage', CacheStorage::class);
          $storage = match ($storageClass) {
-                TaggedCache::class => new TaggedCache($this->cacheStoreForJwtBlacklist(
-                    $app,
-                    $config->boolean('jwt.blacklist_enabled')
-                )),
+                CacheStorage::class => new CacheStorage(
+                    $app->make('cache')->store($config->get('jwt.blacklist_store'))
+                ),
              default => $app->make($storageClass),
Evidence
ServiceProvider::mergeConfigFrom does array_merge($packageDefaults, $appConfig) and merges
deeper only for the keys in mergeableOptions(), so an app's providers array replaces the
package's entirely. The deleted config line was `'storage' =>
Hypervel\Jwt\Storage\TaggedCache::class, and that file is removed in this PR. The match` falls to
default => $app->make($storageClass) for a class that no longer exists.

src/support/src/ServiceProvider.php[154-185]
src/jwt/src/JwtServiceProvider.php[54-63]
src/jwt/src/JwtManager.php[236-239]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Published configs from before this PR set `providers.storage` to the deleted `Hypervel\Jwt\Storage\TaggedCache`. The shallow config merge keeps that value, so the blacklist fails to resolve.
## Fix Focus Areas
- src/jwt/src/JwtServiceProvider.php[57-63]
## Recommended Fix
Add `'Hypervel\Jwt\Storage\TaggedCache'` as a second arm of the `CacheStorage::class` match case, so legacy configs build `CacheStorage`. Alternatively, keep a deprecated `TaggedCache` class that extends `CacheStorage`. Mention the rename in the upgrade notes.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Informational

6. Custom provider classes are silently ignored 🐞 Bug ⛨ Security
Description
createLcobucciDriver() now always builds a stock Lcobucci, and the jwt.providers.jwt setting
is no longer read. An app whose published config names a custom Lcobucci subclass (for example one
that adds verification checks) now runs the base provider without any error or warning. Any extra
checks or behavior in that subclass silently stop applying until the app moves it to
Jwt::extend().
Code

src/jwt/src/JwtManager.php[R45-50]

  public function createLcobucciDriver(): Lcobucci
  {
-        $class = $this->config->string('jwt.providers.jwt', Lcobucci::class);
-
-        if (! is_a($class, Lcobucci::class, true)) {
-            throw new RuntimeException('JWT provider must be an instance of ' . Lcobucci::class);
-        }
-
-        return new $class(
+        return new Lcobucci(
          (string) $this->config->get('jwt.secret'),
          $this->config->string('jwt.algo'),
          $this->config->array('jwt.keys'),
Evidence
The removed code read $this->config->string('jwt.providers.jwt', Lcobucci::class) and instantiated
that class. The new code calls new Lcobucci(...) unconditionally, and nothing detects a leftover
providers.jwt value that the shallow config merge keeps in older published configs.

src/jwt/src/JwtManager.php[42-52]
src/support/src/ServiceProvider.php[154-185]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`jwt.providers.jwt` is no longer honored, so apps with a custom provider subclass in their published config silently fall back to the stock Lcobucci provider.
## Fix Focus Areas
- src/jwt/src/JwtManager.php[45-51]
## Recommended Fix
In `createLcobucciDriver()`, check `jwt.providers.jwt`. If it is set to a class other than `Lcobucci::class`, throw an exception telling the developer to register the class with `Jwt::extend()`. During a deprecation period, you could instead keep instantiating that class and log a deprecation warning.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Tip of the day
💡 Did you know, you can route each severity your way: inline, summary, both, or drop

More tips ↗ | Customize Qodo ↗ | Qodo docs ↗

Grey Divider

Qodo Logo

Comment thread src/jwt/src/Providers/Lcobucci.php Outdated
Comment thread src/jwt/src/JwtGuard.php
Comment thread src/jwt/config/jwt.php
Comment thread src/jwt/src/Storage/CacheStorage.php
Comment thread src/jwt/src/JwtServiceProvider.php
Comment thread src/jwt/src/JwtManager.php
@greptile-apps

greptile-apps Bot commented Oct 7, 2026 •

Copy link
Copy Markdown

RetriggerConfidence Score: 2/5

[High risk] Refactors JWT authentication library with token validation and revocation changes.

The PR should not merge until the two existing upgrade problems are addressed.

Fix All in Claude CodeFindings

  1. P1 Security Revoked tokens work again ▶
  2. P1 Published settings break authentication ▶

Summary

This PR improves JWT revocation, login and logout behavior, custom claims, token verification, and environment-file updates.

  • The latest changes apply custom verification checks even when only a public key is configured.
  • Added tests cover switching between verification and signing, and using supplied signing configurations.
  • The JWT guide now explains that blacklist checks require a jti claim.

Reviews (2) · Last reviewed commit: "Apply custom JWT constraints when verify..." · Reviewed by Greptile

{
// Hashing bounds application-chosen identifiers to fit limited key columns,
// such as the database store's. SHA-256 keeps distinct identifiers apart.
return self::KEY_PREFIX . hash('sha256', $key);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 security Revoked tokens work again

CacheStorage cannot read revocations written by TaggedCache. The old store used the raw jti inside the blacklist tag, or jwt_blacklist: plus the raw jti in direct-key mode. The new store reads only hashed keys.

If an application switches to the new storage without rotating its signing keys, previously revoked tokens that still pass signature and claim checks can authenticate or refresh again. Preserve reads of the old keys until those tokens can no longer be used, or require signing-key rotation during the upgrade.

How this was verified: The old and new stores derive different keys for the same jti, and Blacklist::has() treats a missing entry as not revoked.

Knowledge Base Used: Authentication and authorization flows

Fix in Claude Code Fix in Codex

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not changing this. Hypervel 0.4 is an unreleased breaking release, and we're not carrying compatibility code for blacklist entries written by earlier versions. A fallback lookup would also add a second cache read to every authenticated request whose token isn't revoked, which is nearly all of them. Applications moving from jwt-auth are told in the porting guide to rotate their secret or key pair, so tokens revoked under the old keys no longer verify.

Comment on lines +57 to 62
$storageClass = $config->string('jwt.providers.storage', CacheStorage::class);
$storage = match ($storageClass) {
TaggedCache::class => new TaggedCache($this->cacheStoreForJwtBlacklist(
$app,
$config->boolean('jwt.blacklist_enabled')
)),
CacheStorage::class => new CacheStorage(
$app->make('cache')->store($config->get('jwt.blacklist_store'))
),
default => $app->make($storageClass),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Published settings break authentication

An existing published jwt.php still selects Hypervel\Jwt\Storage\TaggedCache, but this PR deletes that class. Application configuration overrides the package defaults, so upgrading does not switch that setting to CacheStorage.

If an application keeps its published configuration and has the blacklist enabled, the first token check or revocation tries to resolve the deleted class and fails. JWT authentication, logout, and refresh then stop working. Keep a compatible class or translate the old built-in storage setting before resolving it.

Knowledge Base Used: Configuration and dependency resolution

Fix in Claude Code Fix in Codex

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not changing this. Hypervel 0.4 is an unreleased breaking release, so we're not keeping an alias or a special case for the removed TaggedCache class. As the porting guide says, publish the new config/jwt.php instead of reusing an old one. A stale class name fails with "Target class [...] does not exist." the first time the blacklist is used, which points straight at the setting.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That makes sense for an unreleased breaking release. Since applications are expected to republish config/jwt.php, treating the stale TaggedCache class as a migration error is intentional, and the failure message identifies the setting that needs updating. I’m withdrawing this finding.

An asymmetric Lcobucci provider without a private key built its own
parser, validator and SignedWith constraint instead of calling
buildConfig(). A subclass that added validation constraints in
buildConfig(), such as a required issuer, had them silently skipped on
services that only hold the public key, so tokens that failed those
checks were accepted there.

The provider now keeps one Configuration in every mode. Without a
private key, buildConfig() puts the public key in lcobucci's signing-key
slot, which lcobucci requires even for verification, and encode() fails
with "Private key is not set." before building a token. Parsing,
validation and constraints all come from that configuration, so the
separate verification-only properties and getSigningConfig() are gone.
A Configuration passed to the constructor is still used as given and
can sign without local keys, and the setters still replace it with one
built from the new settings.

New tests cover constraints added by buildConfig() with only the public
key for RSA and ECDSA, switching a provider between verifying and
signing through setKeys(), and signing with a given asymmetric
configuration and no local keys.

The JWT guide now says that while the blacklist is enabled tokens must
include a jti claim, so enabling it rejects existing or externally
issued tokens without one.

Validation: the JWT test suite, composer lint:fix and composer analyse
pass.
@binaryfire
binaryfire merged commit e68e9f2 into 0.4 Oct 7, 2026
51 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant