free-work-agsi/jwt-refresh-token-bundle
A Symfony bundle for issuing and validating JWT refresh tokens, helping keep users signed in by renewing access tokens without re-authentication. Integrates with your existing JWT authentication flow.
First, confirm what you're actually holding. This is a Symfony Bundle (a near-verbatim fork of gesdinet/jwt-refresh-token-bundle), not a Laravel package. It registers into Kernel, the Symfony DI container, and the Security firewall — none of which Laravel executes. For a Laravel codebase, "day-to-day leverage" means reading it as a reference implementation and re-implementing the pattern natively, or adopting the canonical Laravel equivalents. Do not plan on composer require + facade config.
# Before anything else, check provenance and namespace
composer show free-work-agsi/jwt-refresh-token-bundle 2>/dev/null || echo "not on Packagist — likely VCS-only"
# Inspect the source namespace and version to detect fork drift
grep -R "namespace " vendor/free-work-agsi/jwt-refresh-token-bundle/src | head -n 3
Where to look first (in the source). The three files that carry all the value you'd extract:
Entity/RefreshToken.php (or Model/RefreshTokenInterface.php). Note the columns: refresh_token, username, valid, created_at, and usually expires_at. This is the schema you'll mirror.RefreshTokenManager, RefreshTokenGenerator, and the handler behind POST /api/token/refresh. This is where the exchange, TTL check, and rotation live.DependencyInjection/Configuration.php plus the security bundle wiring. This tells you which request header/cookie the token comes from and what the response shape is ({ "token": ..., "refresh_token": ... }).Canonical Symfony setup (only if the target is genuinely Symfony — this is what "getting started" looks like when the framework matches):
composer require free-work-agsi/jwt-refresh-token-bundle
php bin/console doctrine:migrations:diff && php bin/console doctrine:migrations:migrate
# config/packages/gesdinet_jwt_refresh_token.yaml (config keys match upstream)
gesdinet_jwt_refresh_token:
refresh_token_class: App\Entity\RefreshToken
ttl: 2592000 # 30 days — the refresh token lifetime, seconds
ttl_update: true # sliding expiry: extend ttl on each use
firewall: api # which Symfony firewall issues/validates tokens
user_provider: app_user_provider
# config/packages/security.yaml — the login and refresh firewall endpoints
security:
firewalls:
api:
pattern: ^/api
stateless: true
jwt: ~
refresh_jwt: ~ # enables POST /api/token/refresh
First use case: the one you actually want. Regardless of framework, the bundle's purpose is a specific product decision: short-lived access JWT (5–15 min) + a DB-backed, revocable refresh token (days/weeks). The pattern you're adopting:
{ access, refresh }./token/refresh and gets a new access token (and, with rotation, a new refresh token).In a Laravel app, the shortest equivalent path is:
laravel/sanctum. Cheapest to operate; no token table you manage by hand.laravel/passport. Full refresh token + revocation, but heavier.tymon/jwt-auth. Closest conceptual analogue; add a refresh_tokens table for rotation/revocation.If none of those fit your desired rotation semantics, extract this bundle's entity + rotation logic and build a ~200–400 LOC guard/middleware — see Implementation Patterns.
Pattern 1 — The rotate-on-use exchange (the core of the bundle). Each refresh call invalidates the presented token and issues a new one. This is what "rotation invalidates a stolen chain" means — a replayed token fails because it was already consumed.
// Laravel-native re-implementation of what the bundle does internally.
// RefreshToken model: id, user_id, token_hash (unique), expires_at, revoked_at, replaced_by
Route::post('/token/refresh', function (Request $request) {
$raw = $request->input('refresh_token');
$hash = hash('sha256', $raw);
$record = RefreshToken::where('token_hash', $hash)->first();
if (!$record || $record->revoked_at || $record->expires_at->isPast()) {
// reuse of a consumed token = likely theft → revoke the whole family
if ($record) RefreshToken::where('family_id', $record->family_id)->update(['revoked_at' => now()]);
return response()->json(['error' => 'invalid_grant'], 401);
}
// Atomic compare-and-swap so two concurrent refreshes can't both succeed
$claimed = RefreshToken::where('id', $record->id)
->whereNull('revoked_at')
->update(['revoked_at' => now()]);
if (!$claimed) return response()->json(['error' => 'invalid_grant'], 401);
$new = RefreshToken::create([
'user_id' => $record->user_id,
'family_id' => $record->family_id,
'token_hash'=> hash('sha256', $plain = Str::random(64)),
'expires_at'=> now()->addDays(30),
]);
$record->update(['replaced_by' => $new->id]);
return response()->json([
'token' => auth()->setTTL(15)->login(auth()->user()),
'refresh_token' => $plain,
]);
}, 'throttle:20,1');
Pattern 2 — Family-based revocation ("log out all devices"). Give every token minted from one login a shared family_id. Logout revokes the family; password change revokes the user's entire set; admin de-provision revokes by user_id. This is the capability customers/security reviewers name explicitly, and it is why the DB-backed design beats stateless JWT alone.
// Log out everywhere
RefreshToken::where('user_id', $user->id)->whereNull('revoked_at')
->update(['revoked_at' => now()]);
Pattern 3 — Sliding vs fixed TTL. The bundle's ttl_update: true extends the refresh token's lifetime on each rotation, keeping active users logged in indefinitely while idle sessions age out. Mirror that with an expires_at bump on rotation, but add an absolute max (e.g. 90 days) so a token can't live forever via refresh churn — this is a common hardening step the pattern makes easy.
Pattern 4 — Custom entity/service extension (Symfony path). If you're on Symfony and adopting for real, you generally extend the model, not fork it:
// src/Entity/RefreshToken.php
#[ORM\Entity]
class RefreshToken extends Gesdinet\JWTRefreshTokenBundle\Entity\RefreshToken
{
#[ORM\Column(type: 'string', nullable: true)]
private ?string $userAgent = null;
#[ORM\Column(type: 'string', nullable: true)]
private ?string $ip = null;
}
Then point refresh_token_class at it. Override RefreshTokenManager / the token handler if you need custom validation (device binding, IP pinning, custom error responses).
Pattern 5 — Scheduled cleanup. A refresh-token table grows monotonically. The bundle ships a cleanup command (gesdinet:jwt:clear); in Laravel, schedule the equivalent:
// routes/console.php
Schedule::command('model:prune', ['--model' => [RefreshToken::class]])->daily();
// RefreshToken::prunable() => Model::where('expires_at', '<', now()->subDay())
// ->orWhereNotNull('revoked_at')->where('revoked_at', '<', now()->subDays(7));
Pattern 6 — Cookie vs header storage. The bundle supports both an Authorization header and an httpOnly cookie extractor. For browsers, use the httpOnly cookie + CSRF token pair; for native/API clients, use the header. Do not mix them on one endpoint — that's the configuration that silently trades an XSS exposure for a CSRF one.
Pattern 7 — Testing the exchange. The two tests worth writing before anything ships: (a) a rotated token cannot be replayed — post the same refresh token twice, expect 401 on the second; (b) revocation is immediate — revoke, then post the token, expect 401. Add a concurrency test that fires two refreshes in parallel and asserts exactly one succeeds.
Framework mismatch is fatal, not annoying. composer require may even resolve on a Laravel project (Laravel pins a subset of Symfony components), but the bundle will not boot. Don't read "install succeeded" as feasibility — there is no service provider, guard, or config publisher. Plan on the Laravel-native path from the start.
Provenance is the real risk. 0 stars / 0 dependents / score 0 is a governance finding. Treat the package as an internal fork: pin the exact version, vendor it if the VCS source is private, diff against gesdinet/jwt-refresh-token-bundle before adopting, and name an owner for security-patch intake. If there's no divergence from upstream, use upstream.
Rotation is the hard part — don't skip reuse detection. Simple rotation is easy; detecting a consumed token being replayed (the signal of theft) and revoking the affected family is the subtle security behavior. Test it explicitly. If your implementation lets a replayed token mint a new one, you have a silent session-hijack persistence bug.
Concurrent refreshes race. Two parallel requests with the same refresh token must not both succeed. Use an atomic DB update (compare-and-swap on revoked_at IS NULL) or a unique index on replaced_by; don't read-then-write. Clients should also single-flight their refresh calls.
Store hashes, not plaintext. Persist hash('sha256', $token) with a unique index. A plaintext token table turns a read-only DB leak into account takeover. The upstream entity historically stores raw tokens — check what your fork actually does.
The token table will grow unbounded. No cleanup job = a latent operational bug. Schedule pruning from day one, and index on expires_at / revoked_at so the prune doesn't table-scan.
Clock skew and expiry boundaries. TTL comparisons on the boundary produce intermittent, expensive-to-diagnose 401s. Leave a small grace window (e.g. accept tokens up to 30s past nominal expiry) or validate with a monotonic source; don't compare with a client-supplied time.
Signing-key rotation without overlap = coordinated outage. If you rotate the JWT signing key, issue new tokens with a new kid and keep the old key valid until the longest-lived access token expires. Never swap keys atomically across a fleet.
Database unavailability cascades. Every refresh hits the DB. If it's down, all refreshes fail → mass forced logout. Decide the degradation story in advance (serve the still-valid short-lived access tokens; don't fail the auth middleware itself).
Config quirks (Symfony path only). ttl is seconds and is refresh lifetime, not access-token lifetime — don't confuse it with Lexik's lexik_jwt_authentication.token_ttl. firewall must match the security.firewalls key that actually handles login, or the refresh endpoint 401s with no obvious cause. user_provider must resolve the same user the login did.
Extension points, when adopting on Symfony. Prefer refresh_token_class + a subclass, then override the token manager/handler, then events, and only fork as a last resort. Forking severs you from upstream security fixes — which, for a fork-of-a-fork, is the whole risk.
Debugging tip. If refresh returns 401 with no detail, check in this order: is the token expired (expires_at), is it revoked, does the username on the row still resolve via the user provider, and is the request actually hitting the refresh_jwt firewall (route pattern mismatch is the most common silent failure). Log the token id/family, never the raw token.
How can I help you explore Laravel packages today?