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+.
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:
config/packages/gesdinet_jwt_refresh_token.yaml → refresh_token_class: App\Entity\RefreshToken.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.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.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.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.
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:
delete() it — there is no built-in logout endpoint.getLastFromUsername() / query the repository by username and remove them all. Wire this to a password-change listener.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).
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.
How can I help you explore Laravel packages today?