mrellerm/jwt-refresh-token-bundle
A Symfony bundle that adds JWT refresh token support to your authentication flow, letting clients exchange long-lived refresh tokens for new access tokens. Includes token rotation, Doctrine-backed storage, and integration with LexikJWTAuthenticationBundle.
This is a Symfony bundle, not a Laravel package — composer require into a Laravel app will pull in Symfony HTTP/Doctrine dependencies and register nothing usable. As a Laravel developer you only reach for this when you're maintaining a Symfony 3.4/4.x/5.x service (the repo was last touched Feb 2020). In a Laravel codebase the same pattern is tymon/jwt-auth rotations plus a custom fingerprint claim; the design notes below still translate.
Minimal setup in a Symfony project:
composer require mrellerm/jwt-refresh-token-bundle
config/bundles.php:
MrEllerM\JWTRefreshTokenBundle\JWTRefreshTokenBundle::class => ['all' => true],
config/packages/gesdinet_jwt_refresh_token.yaml (the fork keeps the upstream config key):
gesdinet_jwt_refresh_token:
firewall: api
user_provider: app.user_provider
ttl: 2592000
doctrine_orm: true
RefreshToken entity extending the bundle's mapped superclass (or copy it if the fork requires extra fingerprint columns — check the fork's Entity/RefreshToken.php, that's where the divergence lives).bin/console doctrine:migrations:diff + migrate — the table is refresh_tokens, plus whatever fingerprint column the fork adds.gesdinet_jwt_refresh_token.refresh_token service.First use case: POST /api/token/refresh with {"refresh_token": "..."} returns a fresh JWT pair. The fork's twist is that the request must also carry the same fingerprint the token was minted with (header/IP/UA — see the fork's config), so a stolen refresh token alone is not enough.
Where to look first: DependencyInjection/Configuration.php and Entity/RefreshToken.php in the fork. They tell you exactly which fingerprint field and which config knobs exist — the fork's README is thin and the code is ground truth.
Mint fingerprint on login. The fingerprint must be written to the RefreshToken row when the access token pair is first issued. In a Symfony flow that's either a lexik_jwt_authentication.on_authentication_success listener or an event subscriber on the refresh-token creation. The fork exposes the entity setters; you set the fingerprint hash before $refreshTokenManager->save($token).
$token->setFingerprint(hash('sha256', $request->headers->get('X-Device-Fingerprint')));
Validate fingerprint on refresh. The bundle's refresh controller compares the stored fingerprint to the incoming request. Make sure the client sends the same fingerprint value on login and refresh — same header name, same normalization (trim, lowercase).
Rotation with single-use tokens. Keep single_use: true (or revoke_on_refresh) so the old refresh token is deleted on each refresh. Combined with the fingerprint this gives you: access token short-lived, refresh token single-use and bound to a device.
Multiple devices per user. The bundle naturally issues one row per login, so a user on phone + laptop gets two tokens with two fingerprints. Don't try to make the fingerprint per-user — it's per-session/device.
Logout = revoke. Call gesdinet_jwt_refresh_token.refresh_token service's revoke($token) (or the gesdinet:jwt:revoke command if present in the fork) from your logout controller. Do not rely on TTL expiry for logout.
Scheduled cleanup. Run gesdinet:jwt:clear from cron daily to purge expired rows — otherwise the table grows unbounded on a busy API.
Password change / compromise. Iterate refreshTokenManager->revokeAllForUser($user) (or query the repository by username) and force re-login across all devices when credentials change.
Integration with Lexik: the refresh endpoint is outside the firewall's authenticated pattern. Do not add it to security.firewalls.api.pattern or users will be asked for a valid JWT to refresh a JWT — a chicken-and-egg failure that is the single most common setup bug.
src/, or better, apply the fingerprint patch onto upstream gesdinet/jwt-refresh-token-bundle and pin a commit hash. Do not composer update this casually — the fork is behind upstream security fixes.doctrine_orm service wiring and controller signatures will likely break. Test boot before committing.X-Device-Fingerprint header (UUID stored in secure storage) over IP. If you must use IP, configure trusted_proxies and read X-Forwarded-For consistently — otherwise 127.0.0.1 is what gets stored.sha256) before storing; a DB leak shouldn't hand attackers the value needed to pass the check.security.yaml, but the controller still needs to run $this->get('gesdinet_jwt_refresh_token.refresh_token')->refresh($token). Don't put auth on the route hoping the bundle handles it.ttl is the refresh token lifetime in seconds; the JWT itself has a separate lexik_jwt_authentication.token_ttl. Keep JWT short (15 min) and refresh long (30 days). If refresh ttl < JWT ttl users get logged out silently mid-session.RefreshToken id, the fingerprint compare result, and expiry inside a small subscriber — the bundle gives you almost no logging otherwise.gesdinet.jwtrefreshtoken.*) if you need to audit refreshes or emit metrics; the fork inherits them from upstream.Access-Control-Allow-Headers, or the browser will strip it and every refresh fails the fingerprint check.How can I help you explore Laravel packages today?