terehinis/jwt-refresh-token-bundle
A Symfony bundle that adds refresh token support to JWT authentication. It lets you issue, rotate, and revoke long-lived refresh tokens alongside LexikJWTAuthenticationBundle, with Doctrine-backed storage and configurable expiration for secure, persistent user sessions.
The package is a Symfony bundle, not a Laravel one. It plugs into lexik/jwt-authentication-bundle and Doctrine, so as a Laravel developer you have two realistic paths: (a) run it inside a Symfony service that sits next to your Laravel app, or (b) treat it as a reference implementation and port the pattern to tymon/jwt-auth + a refresh_tokens table. Either way, read the source first — this is a zero-star, zero-dependent fork, so the code you clone is the documentation.
Minimum viable setup (in Symfony):
composer require terehinis/jwt-refresh-token-bundle
Then, in order:
DependencyInjection/Configuration.php (all your options live there), Entity/RefreshToken.php (the schema), Controller/RefreshTokenController.php (the only HTTP surface), and Security/RefreshTokenGenerator*.php.config/bundles.php and import the routing:
# config/routes.yaml
gesdinet_jwt_refresh_token:
path: /api/token/refresh
controller: gesdinet.jwtrefreshtoken.refresh
(The route name still carries the upstream gesdinet alias — this fork is a continuation of gesdinet/jwt-refresh-token-bundle. Expect that naming mismatch everywhere.)RefreshToken entity. Doctrine mappings ship with the bundle, so doctrine:schema:validate then doctrine:migrations:diff.config/packages/gesdinet_jwt_refresh_token.yaml:
gesdinet_jwt_refresh_token:
ttl: 2592000 # 30 days, in seconds
ttl_update: true # sliding expiration on each refresh
single_use: true # rotate token every refresh
cookie:
enabled: true
same_site: lax
path: /api/token
secure: true
http_only: true
lexik_jwt_authentication access-token TTL at something short (5–15 min) and let the refresh token carry the session.First use case: a SPA or mobile client where you want a 5-minute bearer token plus a 30-day session, without forcing re-login. The client hits /api/token/refresh when it gets a 401, then retries the original request.
The canonical loop. Short access token in Authorization: Bearer, opaque refresh token in an HttpOnly cookie or a request body field. On 401: POST to the refresh endpoint, receive a new access token, retry once. Never retry more than once — that's how you turn an auth outage into a retry storm.
Browser vs. native split. For web clients, use the cookie transport: it keeps the refresh token out of JS and out of localStorage, which is the whole point. For iOS/Android, set cookie.enabled: false and use refresh_token_parameter_name: refresh_token in a JSON body — native HTTP stacks don't handle Set-Cookie rotation gracefully.
Single-use rotation is the default you want. With single_use: true every refresh invalidates the presented token and issues a new one. This gives you theft detection for free if you also implement the reuse check: if a token that's already been consumed shows up again, revoke the entire family for that user. The bundle doesn't do that for you — add a replaced_by column and a listener, or accept that stolen tokens live out their TTL.
Sliding vs. absolute expiry. ttl_update: true pushes valid forward on each use, so an active user never re-authenticates. Set ttl_update: false if you have compliance reasons to force a hard re-login after N days regardless of activity. Pick one deliberately; the default is sliding and it surprises people.
Extending the entity. Subclass RefreshToken (or map your own with doctrine_mappings overridden) to add device_id, user_agent, ip, or revoked_at. This is the hook for "log out all devices," which you will be asked for within a month of shipping.
Custom generator. Implement RefreshTokenGeneratorInterface and alias it. The default is a 64-char random string; some teams swap it for a signed JWT so the refresh token is self-describing and the DB lookup becomes optional. Be aware you're trading revocability for statelessness — for refresh tokens, revocability is usually the point, so the default is the right call.
Cleanup. The bundle ships a command to delete expired rows. Schedule it — nightly is fine. Without it the table grows unbounded and the valid index stops being useful.
Integration with the firewall. The refresh route must be PUBLIC_ACCESS and must be excluded from the JWT authenticator, or the authenticator will reject the request before the controller sees it. In security.yaml, put the refresh path under the same firewall as your API but with security: false on the authenticator for that path.
You are on an unmaintained-looking fork. Zero stars, zero dependents, blank star count. The parent (gesdinet/jwt-refresh-token-bundle) is the maintained project. Before committing: check whether the fork's PHP/Symfony constraints are actually ahead of upstream or just stale, diff the two, and pin the exact version in composer.json. If you're on this fork because upstream doesn't support your Symfony version, expect to maintain it yourself.
Laravel has no equivalent drop-in. There's no bridge, no service provider, no package that consumes this. Porting means reimplementing the RefreshToken model, a refresh_tokens migration, a POST route, and a rotation policy — maybe 150 lines. Do that rather than adding a Symfony process to your stack.
The token is opaque, not a JWT. Do not try to base64_decode it, inspect claims, or validate it client-side. It's a random string matched against a DB row. Developers coming from tymon/jwt-auth assume otherwise and waste a day.
Concurrent requests break single-use rotation. Open three tabs, all three fire a 401, all three refresh simultaneously, two of them get a 401 back and the user is logged out. Mitigate client-side with a shared refresh promise/lock, and server-side with a short grace window (accept a consumed token for ~30 seconds if the replaced_by token is the one now in use).
Clock and TTL units. ttl is in seconds, not a DateInterval string. Values like "30 days" silently cast to 30. Also, valid is computed at issuance and compared against server time — set date_default_timezone_set consistently or you'll get off-by-hours bugs that only appear in one environment.
Cookie config bites. path must cover the refresh endpoint but not the rest of the API; same_site: none requires secure: true; and if you're behind a proxy that terminates TLS, Symfony won't mark the cookie secure unless trusted_proxies is configured. Symptom: refresh works on staging, cookie is dropped in production.
user_identity_field mismatch. Defaults to username. If your user provider identifies by email or UUID, set it explicitly or the bundle will write rows keyed on the wrong column and every refresh 401s with no useful log line.
Clearing the cookie on logout is on you. The bundle issues refresh tokens; it does not revoke them on logout. Implement a listener that deletes the row and expires the cookie, otherwise "log out" on a shared machine leaves a valid 30-day token in the browser.
Debugging: the controller swallows most failures into a generic 401. Drop a temporary logger inside the refresh controller and check the refresh_tokens table directly — SELECT * FROM refresh_tokens WHERE username = ? answers 90% of "why won't it refresh" questions in ten seconds.
How can I help you explore Laravel packages today?