janakdom/jwt-refresh-token-bundle
A Symfony bundle for issuing and managing JWT refresh tokens, letting API clients renew expired access tokens securely without re-entering credentials. Supports token persistence and rotation for authentication flows.
Reality check before install. This is a Symfony bundle, not a Laravel package. There's no service provider, no php artisan vendor:publish, no config/jwt-refresh.php. It targets Symfony apps already running LexikJWTAuthenticationBundle, and it depends on Symfony's kernel, DI container, and (typically) Doctrine ORM. For a Laravel developer, the closest mental models are tymon/jwt-auth's jwt.refresh flow or Passport's grant_type=refresh_token: a short-lived access token paired with a long-lived, DB-backed, rotating refresh token. You are not going to composer require this into a Laravel app and have it work — if your estate is Laravel-first, the correct first move is a spike on laravel/sanctum or laravel/passport before touching this repo at all.
If you do have a Symfony service in scope, the minimal path is:
composer require janakdom/jwt-refresh-token-bundle
# Symfony <5.4 only: register in config/bundles.php
php bin/console lexik:jwt:generate-keypair
config/packages/jwt_refresh_token.yaml. The bundle's DependencyInjection/Configuration.php is the real documentation — read it before guessing keys. A typical shape:jwt_refresh_token:
refresh_token_class: App\Entity\RefreshToken
ttl: 2592000 # refresh lifetime in seconds (30 days)
ttl_update: true # sliding window: extend expiry on each use
firewall: api # must match a firewall in security.yaml
doctrine_mappings: true
namespace App\Entity;
use Doctrine\ORM\Mapping as ORM;
use Janakdom\JWTRefreshTokenBundle\Entity\RefreshToken as BaseRefreshToken;
#[ORM\Table(name: 'refresh_tokens')]
#[ORM\Index(columns: ['refresh_token'], name: 'idx_refresh_token')]
#[ORM\Entity]
class RefreshToken extends BaseRefreshToken {}
Migrate: php bin/console doctrine:migrations:diff && php bin/console doctrine:migrations:migrate.
Wire the refresh route (either import the bundle routing or point at the controller explicitly; confirm the service id with bin/console debug:container | grep -i refreshtoken):
# config/routes.yaml
api_refresh_token:
path: /api/token/refresh
methods: POST
controller: janakdom.jwtrefreshtoken.controller.refresh::refresh
# login (Lexik) -> short-lived JWT
curl -sX POST https://app.test/api/login_check \
-H 'Content-Type: application/json' \
-d '{"username":"[email protected]","password":"secret"}'
# => {"token":"eyJ..."}
# refresh -> new JWT (+ rotated refresh token)
curl -sX POST https://app.test/api/token/refresh \
-H 'Content-Type: application/json' \
-d '{"refresh_token":"a1b2c3..."}'
Where to look first, in order: the bundle README → Entity/RefreshToken.php (your schema) → DependencyInjection/Configuration.php (every config key, with defaults) → the refresh controller (response shape) → composer.json (version constraints, and what it forks from).
Emit the refresh token on login via an event listener. Lexik only returns token; there is no refresh token in its response by default. You attach one with an AuthenticationSuccessEvent listener resolving the bundle's token manager service (find its id via debug:container) and creating a token for the authenticated user:
use Lexik\Bundle\JWTAuthenticationBundle\Event\AuthenticationSuccessEvent;
public function onAuthenticationSuccess(AuthenticationSuccessEvent $event): void
{
$user = $event->getUser();
$refreshToken = $this->tokenManager->createForUser($user); // bundle service
$data = $event->getData();
$data['refresh_token'] = $refreshToken->getRefreshToken();
$event->setData($data);
}
Standard client workflow. Issue short access tokens (minutes) plus a long refresh token (days/weeks). Clients refresh proactively before access-token expiry — never on the 401 path only, or you'll double-submit under concurrent requests. Rotation means every successful refresh returns a new refresh token; the old one must be discarded immediately on the client.
Revocation on logout. Delegate to the token store: delete the refresh-token record by value on logout, and on password change delete all records for that username. This is the same pattern as Revoke-All on device management screens.
Multi-device tracking. Store device_id, user_agent, or ip on the entity's extra columns. A per-device session list is a SELECT on this table filtered by user, and "sign out everywhere" is a DELETE on the same filter. This is where the DB-backed refresh token pays off, versus purely stateless JWTs.
Wrap it behind internal interfaces. This package is an artifact to learn from, not a top-level dependency. Extract the token entity and rotation service behind a TokenIssuer / TokenRotator interface owned by your team. That preserves a future swap to Sanctum/Passport or a managed IdP without touching call sites, and it lets you vendor and pin the source.
Reuse detection. A refresh token that has already been rotated and is replayed should be treated as an attack signal — invalidate the entire token family (all tokens for that user/device). Verify the bundle actually does this before relying on it; many forks of the parent gesdinet/jwt-refresh-token-bundle only implement issuance + simple invalidation.
If you are Laravel-only: treat this repo as a reference implementation of the rotation pattern. Rebuild the flow on Sanctum with a personal_access_tokens-adjacent refresh_tokens table, or delegate to Passport. Do not attempt to boot Symfony DI inside Laravel.
This is the single largest pitfall: it is not a Laravel package. No service provider, no facade, no published config. The "Laravel integration" story is a Symfony sidecar or a port. Budget 1–3 sprints for either; neither is an install.
Adoption signals are disqualifying for direct use. Zero stars, zero dependents, score 0, and a name that strongly suggests a re-publish of the well-known gesdinet/jwt-refresh-token-bundle. For authentication code — rotation, replay detection, revocation — an unaudited single-maintainer fork is a supply-chain and bus-factor risk. Treat the repo as a pointer to the upstream original; verify upstream's maintenance cadence, CVE history, and Symfony version support before committing.
Read DependencyInjection/Configuration.php first. Bundle READMEs go stale; the Configuration class is the source of truth for every key, default, and deprecation. Skim composer.json for PHP/Symfony/Lexik/Doctrine constraints — the 0-dependent signal means those constraints are unverified by third parties, so prove they resolve against your lockfile in a throwaway branch before anything else.
Rotation may not be enforced on read. Verify the controller actually invalidates the old token and that the server rejects a reused one. Silent replay acceptance is the classic failure mode of half-implemented refresh systems and it's invisible unless you test it explicitly: refresh, then replay the old token, expect 401.
Revocation must be checked on the refresh path, not just issuance. If logout deletes the DB row but the refresh endpoint doesn't consult it, compromised tokens outlive logout.
Signing-key rotation without invalidating refresh tokens causes mass 401s. Plan a migration that revokes or re-issues refresh tokens alongside any lexik:jwt:generate-keypair rotation.
Firewall ordering matters. The firewall config key must match a name in security.yaml, and getting the order wrong can produce an auth bypass or a hard failure rather than a clear error. After every change run bin/console debug:config security firewalls and bin/console debug:router | grep refresh.
Doctrine migration drift. The entity subclass approach means your migration tool must actually see the inherited columns — run doctrine:migrations:diff and inspect the SQL, don't trust the auto-generated file. Missing or mistyped refresh_token columns produce runtime 500s on refresh that look like auth failures.
Clock skew between app servers. Refresh TTL comparisons on servers with drifting clocks cause intermittent rejection on long-window tokens. Keep NTP tight, or store a small grace margin in the entity's valid check.
ttl_update: true is a sliding window and a business decision, not a config default. Sliding expiry extends "remember me" indefinitely for active users — fine for a mobile consumer app, wrong for an admin console where idle timeout is a requirement. Decide deliberately and document it for your security reviewer.
Add a token reaper job from day one. Every issuance and rotation is a DB write, and expired rows accumulate. Index refresh_token and username, and schedule a nightly delete of expired records.
Where this bundle is genuinely the wrong tool: opaque tokens with instant server-side revocation (plain session or DB tokens are simpler), OAuth2/OIDC proper (league/oauth2-server or a managed IdP), and any Laravel-first codebase before a Sanctum/Passport spike clears.
How can I help you explore Laravel packages today?