Repository navigation
Improve JWT revocation, guard security, claims and token verification - #56
Conversation
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.
|
Important Review skippedAuto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the ⚙️ Run configuration
You can disable this status message by setting the Use the checkbox below for a quick retry:
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. Comment |
PR Summary by QodoEnable JWT revocation by default and harden authentication
AI Description
Diagram
High-Level Assessment
Files changed (47)
|
Code Review by Qodo
1.
|
|
| { | ||
| // 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); |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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.
| $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), |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
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 throughJwt::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
jticlaim. 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 setJWT_BLACKLIST_ENABLED=falseto skip that read.A few shared framework classes change too:
Env::writeVariable()andEnv::writeVariables()now update the assignment the dotenv loader actually uses, which also fixes Sentry's publish command andinstall:broadcasting.Revocation
TaggedCacheis replaced byCacheStorage, which works on every store. Each key isjwt_blacklist:plus the SHA-256 of the token identifier, so a long customjtistill fits the database store's key column, and identifiers can't be chosen to collide.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.jwt.leeway, so a revocation lasts as long as any refresh it has to block.Guard
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 untillogin()orsetToken()is called. A failed blacklist write still throws, keeps the guard's state and dispatches noLogoutevent.Logoutevent 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 aTimeboxusingauth.timebox_durationand returns early only on success.hashing.rehash_on_loginis enabled, inattempt()andonce(), but never invalidate(), which only checks credentials.fromUser()is public and issues a token without logging the user in. It replaces the protectedmakeTokenForUser().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
exp,nbf,iat,issorjti, 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 exceptsubandprv, both when a token is created and when it is refreshed. An explicitexptakes precedence over the TTL, and an explicitiatoverrefresh_iat.DateTimevalue made the Lcobucci provider throw. They now accept integers, integer strings,DateTimeInterfaceinstances andDateIntervalvalues.isslike any other claim and rebuilds onlyiat(followingrefresh_iat),nbf,expandjti.iatcheck, so a token issued in the future was restamped into a valid one. It is now rejected.RequiredClaimsreports missing claims as a JSON array. A missing claim that was not first inrequired_claimsused to produce an object such as{"1":"iss"}.Token verification
authmiddleware answered attacker-controlled input with a server error. That signature is now an invalid token. Key and secret configuration errors still surface.Configurationfrom lcobucci/jwt passed to theLcobucciconstructor now signs and verifies tokens instead of one built from the secret and keys.#[SensitiveParameter]. PHP then hides those arguments in the exception traces that error trackers such as Sentry report.Drivers and validations
driverpicked a manager driver, andproviders.jwtpicked a class that had to extendLcobucci.providers.jwtis removed.driverandJwt::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.JwtManagerresolves validation classes through the container, passing the JWT configuration asconfig, so custom validations can inject services. Each class is resolved once and reused for the life of the worker.PayloadExceptionis removed.Token sources
jwt.token, the same key the input parser reads, so the cookie and input names could not differ. The newcookie_key_namesetting (JWT_COOKIE_KEY_NAME, defaulttoken) names the cookie.Authorizationheader parser no longer falls back to theHTTP_AUTHORIZATIONandREDIRECT_HTTP_AUTHORIZATIONserver values. The request already copies both into the header, so the fallbacks never ran.EncryptCookiesmiddleware. jwt-auth'sdecrypt_cookiesoption decrypts the cookie itself and cannot read cookies that middleware wrote, so it is not included.Commands and environment files
Env::writeVariable()andEnv::writeVariables()found existing assignments by matching raw lines, so they missed forms the dotenv loader accepts, such asKEY = value,export KEY=valueand 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 anexportprefix. An empty value is filled without overwrite, and a value that references other variables is never treated as equal to the new one.jwt:secretdetects an existing secret in every form the loader accepts, including an unresolved reference such as${OTHER}, and replaces an empty secret without asking.--always-notakes 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=ecfailed with its default options, because the curve defaulted toprime256v1while--shadefaulted to 512. The curve now follows the SHA variant, and an explicit curve that doesn't match still fails.Tests
tests/Jwtnow 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 theauthmiddleware, including encrypted cookies.tests/Support/EnvFileTestcovers 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.yamlrecords 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;
CacheStoragenow works on every store, andJWT_BLACKLIST_STOREselects which store holds revocations. Each authenticated request costs one cache read to check its token; setJWT_BLACKLIST_ENABLED=falseif you never revoke before expiry. While the blacklist is enabled, tokens must carry ajticlaim.Revocation and the Guard
Logoutevent, which now carries the user.attempt()runs inside aTimebox, so response times no longer reveal whether an account exists, and passwords rehash on login whenhashing.rehash_on_loginis enabled.fromUser()andgetUser()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
exp,nbf,iat,iss, andjti, withexptaking precedence over the TTL; date claims accept timestamps,DateTimeInterface, andDateIntervalvalues.buildConfig(); issuing a token still fails with "Private key is not set." AConfigurationpassed to theLcobucciprovider signs and verifies instead of being ignored.providers.jwtis replaced by thedriversetting andJwt::extend().#[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()andwriteVariables()now replace the assignment the dotenv loader actually uses, which also fixes Sentry's publish command andinstall:broadcasting;jwt:secretdetects existing secrets in every form the loader accepts.Written for commit 7b747f1. Summary will update on new commits.
Note
Improve JWT revocation, security guards, claims, and token verification
CacheStorageclass that works with tagged and untagged cache stores, configurable viajwt.blacklist_storein jwt.php. Removes the oldTaggedCachedefault and its taggable-store validation.JwtGuardgainsrehashOnLoginandtimeboxDurationsettings, plus a publicfromUser()method to issue tokens without credential checks, andgetUser()to read the cached user. Logout now invalidates the request token only when the blacklist is enabled and marks the guard as logged out.Lcobucciprovider supports public-key-only asymmetric verification (refuses to sign without a private key), accepts date claims in multiple forms, converts ECDSA signature-length errors toTokenInvalidException, and marks secrets, keys, and tokens as sensitive parameters so they stay out of exception traces.jwt.cookie_key_nameconfig instead ofjwt.token, andAuthHeadersno longer falls back toHTTP_AUTHORIZATION/REDIRECT_HTTP_AUTHORIZATIONserver variables.BlacklistandJwtManager,issis carried through refresh instead of rebuilt, andiat,nbf,exp,jtiare rebuilt unless supplied as custom claims.Envwriter rewrites assignments via Dotenv parsing, fixing duplicate keys, multiline values, and export prefixes;jwt:secretcommand uses it to detect and replace existing secrets and warns that long-running processes must be restarted.TaggedCachestorage, the removedjwt.providers.jwtconfig key,JwtGuard::id(), server-variable Authorization fallbacks, or old revocation key formats need review. Blacklist keys now use prefixed SHA-256 hashes, so previously storedphp-open-source-saver/jwt-auth-style revocation entries are not compatible.Macroscope summarized 7b747f1.