yivoff/jwt-refresh-bundle
Symfony bundle that adds refresh-token support to JWT authentication, letting clients exchange a long-lived refresh token for new access tokens instead of re-authenticating. Token lifetime, storage, and rotation are configurable.
The bundle slots into an existing Lexik JWT setup: Lexik mints short-lived access tokens, this bundle mints long-lived, rotating refresh tokens that can be exchanged for a new access token without the user re-entering credentials.
Install and register. The package is small and unmaintained-ish (3 stars, 0 dependents), so pin an exact version and verify the composer.json constraints against your Symfony version before going further.
composer require yivoff/jwt-refresh-bundle
// config/bundles.php
Yivoff\JwtRefreshBundle\YivoffJwtRefreshBundle::class => ['all' => true],
Configure it against the firewall that already has Lexik wired up:
# config/packages/yivoff_jwt_refresh.yaml
yivoff_jwt_refresh:
firewall: api # must be a Lexik-enabled firewall
ttl: 2592000 # refresh-token lifetime in seconds (30d example)
parameter_name: refresh_token # request field the client sends
cookie: false # true = also emit an HttpOnly cookie
Import the bundle's routes. The endpoints are shipped with the bundle; look in vendor/yivoff/jwt-refresh-bundle/Resources/config/ for the exact resource filename in your installed version, then:
# config/routes/yivoff_jwt_refresh.yaml
yivoff_jwt_refresh:
resource: '@YivoffJwtRefreshBundle/Resources/config/routing.xml'
prefix: /api
Confirm before writing any code:
bin/console debug:router | grep -i refresh
bin/console debug:container Yivoff
You should see the refresh (and usually a verify) route, plus the TokenGeneratorInterface, TokenStorageInterface and TokenHasherInterface services from Yivoff\JwtRefreshBundle\Contracts.
First use case — issue a refresh token at login. You control the login response (Lexik returns the access token; you add the refresh token), so call the generator there:
use Yivoff\JwtRefreshBundle\Contracts\TokenGeneratorInterface;
public function login(UserInterface $user, TokenGeneratorInterface $generator): JsonResponse
{
return $this->json([
'token' => $this->jwtManager->create($user), // Lexik
'refresh_token' => $generator->createForUser($user),
]);
}
Then the client exchanges it:
curl -X POST https://app.local/api/token/refresh \
-d 'refresh_token=<value>'
# → { "token": "...", "refresh_token": "..." }
Standard rotation loop (stateless clients). Treat the refresh response as a full credential swap: the returned refresh_token replaces the old one, which is revoked server-side on exchange. On the client, persist the new value before retrying the original request, and guard the refresh call with a single-flight mutex so concurrent 401s don't race each other.
async function authedFetch(url, init) {
let res = await fetch(url, withAuth(init));
if (res.status !== 401) return res;
const rotated = await singleFlight(() => refresh());
store.refreshToken = rotated.refresh_token;
store.accessToken = rotated.token;
return fetch(url, withAuth(init));
}
Cookie mode for browser SPAs. Set cookie: true so the bundle sets the refresh token as an HttpOnly cookie. Keep the access token in memory (never localStorage), let the cookie ride along to the refresh endpoint with credentials: 'include', and tighten cookie flags (SameSite=Lax/Strict, Secure) in the bundle config or by wrapping the response in a listener.
Logout / revocation. The storage contract exposes a revoke operation; call it from your logout controller (or a LogoutEvent listener) using the identifier you received at issue time. On cookie mode, clear the cookie in the same response so the client doesn't hold a token the server has dropped.
Custom storage for scale. The bundled default is fine for a single app, but if you run multiple API pods or want TTLs enforced by the store, implement TokenStorageInterface over Redis (key = token identifier, value = hash, TTL = ttl) and alias it:
services:
Yivoff\JwtRefreshBundle\Contracts\TokenStorageInterface:
class: App\Security\RedisTokenStorage
Custom hasher. If you want to change how tokens are hashed at rest (the bundle hashes before persisting so a DB leak doesn't expose usable tokens), swap TokenHasherInterface. Note that this is a migration point: changing the hasher invalidates every stored token, so all users must re-login.
Enriching the new access token. The bundle delegates to Lexik's JWTManager, so to add claims to the refreshed token, hook Lexik's JWTCreatedEvent — the same listener that fires on normal login will fire here. This is how you keep parity between "logged in" and "refreshed" tokens.
Testing. Override TokenStorageInterface with an in-memory implementation in config/packages/test/ and inject a fixed ClockInterface if your version supports it, so refresh-expiry tests are deterministic rather than sleeping.
composer.json constraints against your Symfony/Lexik versions — expect to need --with-all-dependencies and possibly local patches for Symfony 6/7. If this is greenfield, compare against gesdinet/jwt-refresh-token-bundle, which solves the same problem with far more usage; the trade-off is that this bundle is leaner and its contracts are easier to swap.firewall key must name a firewall whose user_provider matches the one Lexik uses. If the refresh request 401s with "user not found," it's almost always provider misconfiguration, not a bad token.security: false or a dedicated firewall with only your auth mechanism).ttl (refresh lifetime, days/weeks) with Lexik's lexik_jwt_authentication.token_ttl (minutes). Keep them clearly named in your own config or you'll debug the wrong clock.token vs token/access_token, expiry keys) is version-specific. Wrap the controller with your own thin endpoint (decorating the controller service, or a listener that reshapes the response) if your clients expect a fixed schema.bin/console doctrine:migrations:diff and review the generated table before deploying — don't let schema:update create it silently in prod.debug:router (route exists?) → debug:container Yivoff (services wired?) → the 401 body message (expired / invalid / not found are distinct and point at different bugs) → profiler, where the token-storage query shows whether the hash actually matched.TokenGeneratorInterface, TokenStorageInterface, TokenHasherInterface) are aliased in the container, so you can replace any one without forking the bundle — prefer that over modifying vendor code, which you'll lose on the next composer update (and there will be one, given the package's age).How can I help you explore Laravel packages today?