Weave Code
Code Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Jwt Refresh Token Bundle Laravel Package

gesdinet/jwt-refresh-token-bundle

Symfony bundle to manage JWT refresh tokens for LexikJWTAuthenticationBundle. Stores and rotates refresh tokens via Doctrine ORM or MongoDB ODM (or custom object manager), with configurable TTL, security, and endpoints. Requires PHP 8.2+ and Symfony 6.4+.

View on GitHub
Deep Wiki
Context7
v3.0.0

Symfony 8, PHP 8.4 and LexikJWTAuthenticationBundle 3.

This release needs a schema change before the application will run. Refresh tokens gained family and family_valid columns, and Doctrine reads every mapped field.

php bin/console doctrine:migrations:diff
php bin/console doctrine:migrations:migrate

Read UPGRADE-3.0.md first, and UPGRADE-RECTOR.md if you are coming from further back than 2.2.

If you are on Symfony 6.4 or 7.x, stay on 2.2. It is maintained, and Symfony 7.4 is supported until November 2028.

What a session is now

Refresh tokens belong to a chain. A token issued in place of another carries the family of the one it replaced, so a login and every refresh descending from it share one value — which is what makes a session addressable. Without it, "end this session" can only mean "delete this one token", and with single_use that token has usually already been replaced.

Everything below is built on that, and all of it is off by default.

  • reuse_detection — recognises a spent token being presented again and revokes the whole chain. Rotation on its own leaves a stolen token working until the legitimate client happens to refresh, and nobody learns why it broke. It cannot tell theft from a client racing itself, so it revokes either way and dispatches an event for the judgement it cannot make.
  • max_session_lifetime — a ceiling on how long a chain may go on for, whatever ttl says.
  • block_jwts_on_revocation — refuses the JWTs already issued to a user when revokeAllForUser() takes their refresh tokens away. Lexik's blocklist is keyed by jti and cannot do this; what is recorded instead is when the revocation happened.
  • SessionLister — the "where you are signed in" screen and the button next to each row, grouped by chain rather than by token.
  • rate_limiter — consumed before the token is looked at, so a refusal costs no query and its timing says nothing about whether the token exists.
  • cache_pool — stores the tokens in a PSR-6 pool, where expiry is the pool's job. It implements only what a pool can honour and says so about the rest.
  • Per-firewall configurationttl, single_use and the rest on the refresh_jwt authenticator, which is the longest-standing thing this bundle could not do.

Breaking changes

  • PHP 8.4, Symfony 8.0, LexikJWTAuthenticationBundle 3, doctrine/dbal 4
  • check_path is required on refresh_jwt
  • RefreshEvent takes the request, and $firewallName loses its default
  • dbal_columns, when configured, has to name the id column
  • Nine classes are final; the token models and repositories are deliberately not

The full list is in the changelog.

v2.2.2

2.2.1 did not actually fix #431 for everyone. If you are on Symfony 7.0, 7.1, 7.2 or 7.3, or on a patch of 6.4 older than 6.4.37 or of 8.0 older than 8.0.9, upgrade to this instead.

What 2.2.1 got wrong

The check on ttl was moved from a validate() closure to min(), because NumericNode skips min() while it is handling the placeholder an environment variable stands in as.

It does — in symfony/config 6.4.37, 7.4.9, 8.0.9 and 8.1 onwards. That skip was backported to the maintained branches only, and 7.0 through 7.3 are end of life, so they never received it and never will. On any of those, 2.2.1 rejected %env(int:...)% exactly as 2.2.0 did.

What this does

Neither ttl nor max_tokens_per_user is validated in the configuration tree any more.

On the affected versions there is no check that can tell the sample value of 0 — which Symfony substitutes to see whether the configuration would accept the variable — from a 0 written by hand. Refusing to boot an application that reads its ttl from the environment is the worse of the two failures, so the check is gone rather than made conditional.

A ttl of 0 or less is therefore accepted again, as it was in 2.1.0 and earlier. It still produces a token that has expired by the time it is handed over, so it is worth not writing one.

gesdinet_jwt_refresh_token:
    ttl: '%env(int:JWT_REFRESH_TOKEN_LIFETIME_IN_SECONDS)%'

works on every Symfony this release supports.

v2.2.1

Fixes a regression in 2.2.0, reported by @dennismetz in #431.

ttl and max_tokens_per_user from an environment variable

gesdinet_jwt_refresh_token:
    ttl: '%env(int:JWT_REFRESH_TOKEN_LIFETIME_IN_SECONDS)%'

failed to compile with:

Invalid configuration for path "gesdinet_jwt_refresh_token.ttl":
The "ttl" must be a positive number of seconds, and 0 is not.

An environment variable is a placeholder at compile time, and Symfony compiles the configuration a second time with a sample value of the declared type in its place, to check it would be accepted. For an integer that sample is 0, and the check added in 2.2.0 rejects anything below 1 — so it rejected every integer environment variable, whatever the variable held.

NumericNode skips min() and max() while it is handling a placeholder. A validate() closure is not skipped, which is why the built-in constraint is now used for both nodes. The validation itself is unchanged: 0 and negative values are still rejected when written literally.

2.1.0 is not affected — the ttl check did not exist before 2.2.0.

Only ttl and max_tokens_per_user were affected. default_invalid_batch_size already used min(), and cookie.same_site was fixed for the same underlying reason in 2.2.0.

v2.2.0

Two features for keeping refresh tokens under control, one for documenting them, and a fix that made the recommended configuration work as written.

See UPGRADE-2.2.md before upgrading. One configuration is now rejected; everything else is additive or a correction.

An expired JWT can be exchanged

With jwt and refresh_jwt on the same firewall, the JWT authenticator was reached first and rejected the expired token before the refresh authenticator saw it — so the configuration in the README did not work as written, and the advice given for years, to reorder them on the firewall, could never have helped. Symfony orders authenticators by the priority each factory declares, not by the file. This one now sits above Lexik's.

Nothing to change. Applications that split the refresh endpoint into its own firewall to get around it can collapse it back.

Storing hashes instead of tokens

gesdinet_jwt_refresh_token:
    hash_tokens:
        enabled: true

A refresh token gets its holder back into an account without a password, so a copy of the table was a copy of everybody's credentials, next to the passwords that are hashed for exactly that reason. What is stored is now sha256$ and the hash, and a leaked table cannot be used.

Off by default, and turning it on signs nobody out: tokens already stored are taken as they are and rewritten hashed the first time they are used. getRefreshToken() then returns the stored hash, which is worth reading about in the upgrade guide first.

A limit on sessions per user

gesdinet_jwt_refresh_token:
    max_tokens_per_user: 5

Every login stores a token, deliberately, so that a user's devices are separate sessions. Nothing bounded how many accumulated. Signing in beyond the limit revokes the session that has gone longest without being refreshed, expired ones first.

Documented in API Platform

gesdinet_jwt_refresh_token:
    api_platform:
        enabled: true

Lexik documents the login endpoint, but its response schema only carries the JWT, because the refresh token beside it is added by this bundle. The refresh endpoint was documented by nobody, being a firewall authenticator rather than a controller. Both are covered now, from the bundle's own configuration — so with the cookie replacing the body, no refresh_token field is promised that never arrives.

Also

  • A DBAL backend (dbal_connection) storing tokens through a plain connection, with no object manager and no unit of work.
  • refresh_token_manager naming a manager of your own, wiring none of the bundle's storage, so the tokens can live in a PDO repository or anywhere else and Doctrine need not be installed at all.
  • single_use_ttl_update: false so a token issued in place of a single use one inherits the expiry of the one it replaced, bounding the chain.
  • ListRefreshTokenManagerInterface::findAllForUser() and revokeAllForUser() for showing a user their sessions and ending them.
  • One query per refresh instead of two.
  • Symfony 7.0 and 7.1 are installable; two components stopped at ^7.2 while the rest allowed ^7.0, and Composer resolves the intersection.
  • Fixes to the cookie expiry, logout invalidating somebody else's token, reading the token from a body with no Content-Type, delete() reporting the rows it actually removed, and cookie.same_site from an environment variable.

The issue tracker is empty as of this release: 64 issues were reviewed and closed, several of them fixed here.

v2.1.0

Fixes across the batch revocation, the MongoDB ODM and the values read from the request. Nothing has to be changed in an application using the bundle through its configuration and its services: see UPGRADE-2.1.md for the return values an application may be reading.

Fixed

  • revokeAllInvalidBatch() returned the last batch read, which is empty once the loop ends, so it always returned an empty array and gesdinet:jwt:clear reported that there was nothing to revoke after deleting tokens
  • revokeAllInvalidBatch() looped forever with the MongoDB ODM, as its condition tested the repository result with empty(), which is never true for the iterator the ODM returns
  • revokeAllInvalidBatch() skipped expired tokens: each batch is deleted before the next is read, so the remaining tokens shift down and the offset has to stay where it is
  • The document repository reads its results through Query::getIterator(), so they are the iterable the interface promises
  • delete() returns 0 when the token is not in storage, which the ODM reported as 1 regardless
  • The success listener no longer brings the request down when a token has no expiration date
  • A refresh token without a username is rejected with an InvalidTokenException instead of a TypeError while building the passport
  • refresh_token_class reports a configuration error when the class cannot be loaded, instead of a TypeError while building the container
  • gesdinet:jwt:clear rejects a --batch-size that is not a positive number, which read no tokens and reported success while leaving every expired token in place
  • Both request extractors check what they read before returning it, and the authentication token checks the serialized state it is given

Changed

  • AuthenticationSuccessHandler::onAuthenticationSuccess() is typed ?Response, matching the handler it decorates. What is returned at runtime has not changed
  • RefreshTokenRepositoryInterface documents, through a [@method](https://github.com/method) tag, that findOneBy() takes an optional $orderBy argument
  • The php constraint is written as ^8.2, the same minimum without claiming support for a future PHP 9

Quality

Test coverage is at 98%, PHPStan runs at level 10 and the security scan runs the project's own Psalm. The suite supports PHPUnit 10.5, 12 and 13.

Full Changelog: https://github.com/markitosgv/JWTRefreshTokenBundle/compare/v2.0.0...v2.1.0

v1.5.1

Fixed

  • gesdinet:jwt:clear crashed with array_map(): Argument #2 ($array) must be of type array, Doctrine\ODM\MongoDB\Iterator\CachingIterator given when using the MongoDB ODM.

    The ORM repository returns an array, but the ODM one returns a CachingIterator, and RefreshTokenManager::revokeAllInvalid() passed it straight through to the command. It now always returns the RefreshTokenInterface[] documented by RefreshTokenManagerInterface, whatever the repository returns.

Reported in #424 by @melkamar.

Full Changelog: https://github.com/markitosgv/JWTRefreshTokenBundle/compare/v1.5.0...v1.5.1

v2.0.0

What's Changed

New Contributors

Full Changelog: https://github.com/markitosgv/JWTRefreshTokenBundle/compare/v1.5.0...v2.0.0

v2.0.0-beta

What's Changed

New Contributors

Full Changelog: https://github.com/markitosgv/JWTRefreshTokenBundle/compare/v1.5.0...v2.0.0-beta

This is a pre-release as non-production ready for testing. Bugs or testing is welcome

v1.4.0
v1.3.0
v1.2.1

Fix issues with symfony 7 release

v1.2.0
v1.1.3
v1.1.2
v1.1.1
v1.1.0

[B/C Break] Change the object mappings to mapped superclasses

v1.0.1
v1.0.0

Stable release with Symfony 6 support

What's Changed

New Contributors

Full Changelog: https://github.com/markitosgv/JWTRefreshTokenBundle/compare/v0.12.0...v1.0.0

v1.0.0-beta4
v1.0.0-beta3
v1.0.0-beta2
v1.0.0-beta
v0.12.0
v0.11.1
v0.10.1
v0.10.0
v0.9.1

Alias RefreshTokenManagerInterface

v0.9.0

Add Symfony 5 support

v0.8.3

Fix date mapping for ODM

v0.8.2
v0.8.1

Change YML to XML mapping to support ODM

v0.7.1

Remove Symfony 4.3 event deprecations

v0.7.0

Using symfony Guard Add Single use option Return id fields on getters

v0.6.2
v0.6.0
Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
calmfox/watch-sylius
damienfern/grpc-symfony-bundle
atoolo/index-bundle
atoolo/genai-bundle
coprotoai/laravel-ticket
davidjln/llm-carbon-bundle
cryonighter/valid-request-bundle
coolms/taxonomy-bundle
coolms/field-bundle
articulate-orm/symfony
aaix/laravel-tall-architect
ephoto/akeneo-connector
emmanuelballery/eb-plantumlbundle
emielburgman/symfony-visitor-beacon
emielburgman/symfony-visit-storage
emielburgman/symfony-security-headers
emielburgman/symfony-log-viewer
emarref/xdebug-bundle
emarref/pubnub-bundle
elriseio/finance-money-bundle