Weave 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 for managing JWT refresh tokens with LexikJWTAuthenticationBundle. Provides secure refresh token handling, a configurable endpoint, Doctrine ORM/ODM support, and easy integration with Symfony security. Requires PHP 8.2+ and Symfony 6.4/7.2+/8.0+.

View on GitHub
Deep Wiki
Context7

Getting Started

This bundle adds a persisted refresh-token layer on top of LexikJWTAuthenticationBundle. It does not issue JWTs itself — Lexik does — it stores opaque refresh tokens in Doctrine, exposes an endpoint that trades an expired access token for a new pair, and gives you the table to revoke sessions. Note up front: this is Symfony-only. There is no Laravel adapter, and the equivalent ground is already covered by Sanctum/Passport, so if you are working in a Laravel codebase this package is the wrong tool (the closest concept mapping is Sanctum's token rotation plus Passport's refresh grants).

Assuming a Symfony app with Lexik already issuing access tokens:

composer require doctrine/orm doctrine/doctrine-bundle gesdinet/jwt-refresh-token-bundle

The Doctrine packages are deliberately not pulled in transitively — installing without them fails at runtime, not at install time.

Then, in order:

  1. config/packages/gesdinet_jwt_refresh_token.yaml → refresh_token_class: App\Entity\RefreshToken.
  2. src/Entity/RefreshToken.php — an empty subclass of Gesdinet\JWTRefreshTokenBundle\Entity\RefreshToken with #[ORM\Entity] and #[ORM\Table(name: 'refresh_tokens')]. It inherits all mapping from the bundle's mapped superclass.
  3. config/routes.yaml → a route at /api/token/refresh with no controller: key. The authenticator answers the path before routing would; a controller entry points at a class that no longer exists.
  4. config/packages/security.yaml → add refresh_jwt: { check_path: /api/token/refresh } to the same firewall that already has json_login and jwt, and add PUBLIC_ACCESS for the login and refresh paths.
  5. php bin/console make:migration — read the file before running it (see Gotchas).

Your first use case is the standard one: a client holds a short-lived JWT, it expires, the client POSTs its refresh token, and gets a new JWT back.

curl -X POST https://app.test/api/token/refresh -d 'refresh_token=...'
# => {"token":"<new jwt>","refresh_token":"<same or rotated>"}

Where to look first when something is wrong: check_path versus the route path, then the refresh_tokens table. Nine times out of ten it is the first one.

Implementation Patterns

Client flow. Keep the JWT in memory, keep the refresh token in an HttpOnly; Secure; SameSite cookie rather than localStorage. Intercept 401 on the client, call the refresh endpoint once, retry the original request, and de-duplicate concurrent refreshes with a single-flight promise — otherwise ten parallel 401s burn ten refresh attempts, which matters a lot once you enable rotation.

Configuration surface worth knowing day to day:

gesdinet_jwt_refresh_token:
    ttl: 2592000            # seconds, default one month
    ttl_update: true        # sliding window: extends expiry on each refresh
    single_use: true        # rotate: issue a new token, delete the old one
    return_expiration: true # adds refresh_token_expiration to the response
    token_parameter_name: refresh_token
    cookie:
        enabled: true
        http_only: true
        secure: true
        same_site: lax
        remove_token_from_body: true
    user_provider: app_user_provider   # only when you have several

Rotation vs. sliding window. single_use: true is the right default for anything long-lived: a stolen refresh token is dead the moment the legitimate user refreshes. ttl_update: true is the opposite trade — the token survives as long as it keeps being used, which suits server-to-server integrations and is dangerous for user sessions. Do not reach for ttl: 315360000 (ten years) to emulate "never"; combine a sane ttl with single_use instead.

Programmatic token management. Inject Gesdinet\JWTRefreshTokenBundle\Model\RefreshTokenManagerInterface to build the features the bundle does not ship:

  • Logout: look up the token and delete() it — there is no built-in logout endpoint.
  • Logout everywhere: iterate getLastFromUsername() / query the repository by username and remove them all. Wire this to a password-change listener.
  • Device list: the table carries username and valid, so you can enumerate active sessions if you add a user_agent/created_at column to your subclass.

Events. The bundle dispatches a refresh-token-created event and a refresh event (names shift between majors — check the version's Event/ directory, e.g. gesdinet.jwtrefreshtoken.refresh_token_created). Use these for audit logging ("refresh from IP X"), for throttling, and for reuse detection: on refresh, if the presented token is not found but was recently rotated, revoke the whole family for that user.

Housekeeping. Schedule php bin/console gesdinet:jwt:clear (optionally with a cutoff datetime) in cron. Expired rows are not garbage-collected automatically, and getLastFromUsername() gets slower as the table grows.

Testing. A WebTestCase that logs in through json_login, grabs refresh_token from the JSON body, then posts it to the refresh path, is the highest-value test in the whole feature — it catches check_path mismatches, missing migration, and firewall ordering in one shot.

Custom persistence. If you cannot use Doctrine, implement Doctrine\Persistence\ObjectManager and point object_manager at it (a service id, not a manager name — see below).

Gotchas and Tips

The route has no controller, and that is not a bug. Unable to find the controller for path "/api/token/refresh" means the authenticator never claimed the request. check_path defaults to /login_check, so refresh_jwt: ~ leaves the refresh route unhandled and it falls through to the router. Set check_path explicitly — path or route name — and keep it identical to the route.

Watch the default collision. If you already have json_login: { check_path: /api/login } and add refresh_jwt without check_path, both can end up on /login_check. Always set it.

Do not try to order the authenticators. jwt and refresh_jwt sit on one firewall and the bundle's declared priority puts refresh first regardless of YAML order. That ordering is what makes an expired-JWT request exchangeable at all; reached second, jwt would reject it. Leave it alone.

auto_mapping: false breaks the subclass silently. The base class is a mapped superclass and Doctrine only registers it while auto-mapping is on. With it off you get "No identifier/primary key specified for Entity … sub class of …". Fix by declaring the bundle mapping explicitly:

doctrine:
    orm:
        mappings:
            GesdinetJWTRefreshToken:
                type: xml
                is_bundle: false
                dir: '%kernel.project_dir%/vendor/gesdinet/jwt-refresh-token-bundle/config/doctrine'
                prefix: 'Gesdinet\JWTRefreshTokenBundle\Entity'

The same applies with multiple entity managers, where auto-mapping only lands on one. Also: object_manager takes a service id (doctrine.orm.custom_entity_manager), not the manager name.

Read the migration before running it. doctrine:migrations:diff compares your mapping against the whole database; in a project with hand-managed or shared tables it will happily generate DROP TABLE for everything Doctrine does not know about. Set doctrine.dbal.schema_filter (e.g. ~^refresh_tokens$~) before generating, and never run doctrine:schema:update --force on a database you did not create.

ttl has no "never". 0 and negative values are rejected outright — they describe a token already expired at issuance. Very large values work but leave an unrevocable, un-clearable credential: gesdinet:jwt:clear finds rows by expiry, so they accumulate forever.

Tokens are stored in plaintext. The refresh token column holds the literal bearer string, and lookup is by equality. A database read (backup leak, read replica, SQL injection) is account takeover for every active session. Mitigate by adding a derived column and overriding the lookup in a custom manager/service, or accept it and treat the table with the same care as password hashes.

Cookie mode is not automatically CSRF-safe. SameSite=Lax helps but does not cover cross-site POSTs from a sibling subdomain; if you set same_site: none (needed for some SPA/API split-domain setups) you must add CSRF protection and correct CORS with Access-Control-Allow-Credentials. The bundle is stateless — no CSRF token is generated for you.

Identifier changes orphan tokens. The username column stores whatever getUserIdentifier() returned at issuance. Switch from email to numeric id, and every outstanding refresh token stops resolving. Same story when a user is deleted: rows linger, so hook deletion into the manager.

Timezone drift. Validity is a datetime compared against PHP's now. If your DB session timezone and php.ini disagree, tokens expire early or late by hours. Verify with one row in refresh_tokens.

Rate-limit it. There is no controller to hang a RateLimiter on. Use a kernel.request listener matching check_path, or a listener on the refresh event, and throttle per IP and per username.

Upgrading: the 3.0 line dropped older Symfony and renamed things; UPGRADE-RECTOR.md walks 1.5 → 3.x one release at a time with Rector rule sets. Do not jump majors in one commit — the authenticator and route changes are behavioural, not just signature changes.

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.
middlewares/firewall
qubiqx/montapacking
nyoncode/laravel-package-console
pnscripts/filament-broadquill-lite
pnscripts/laravel-sentfold-lite
vleap/mx-sdk-laravel-internal
twidpay/utils
htetmyathlaing/scout-elasticsearch-driver
artisan-toolbox/front-awesome
ls/client-assistant
users-au/laravel-client
cgv/localized-resource
noith/settings
kda/laravel-package
kda/filament-dynamic-navigation
bypersoft/signbox
pairus/product-data
fayne/laravel-filament-cms-core
nodisrupt/laravel-agent
sierratecnologia/media-manager