Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Jwt Refresh Token Bundle Laravel Package

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.

View on GitHub
Deep Wiki
Context7

Getting Started

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:

  1. The refresh token entity — 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.
  2. The rotation/validation service — RefreshTokenManager, RefreshTokenGenerator, and the handler behind POST /api/token/refresh. This is where the exchange, TTL check, and rotation live.
  3. The firewall config and the token extractor — 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:

  • Client logs in once, receives { access, refresh }.
  • When the access token expires, the client posts the refresh token to /token/refresh and gets a new access token (and, with rotation, a new refresh token).
  • Each refresh token is a row you can delete → instant revocation ("log out everywhere", password change, de-provisioned user).

In a Laravel app, the shortest equivalent path is:

  • First-party SPA / mobile with simple revocation → laravel/sanctum. Cheapest to operate; no token table you manage by hand.
  • You need refresh-grant semantics / OAuth2 → laravel/passport. Full refresh token + revocation, but heavier.
  • You want access+refresh JWT with blacklist → 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.

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.

Gotchas and Tips

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.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
tequilarapido/trackit
gregsphotography/site-builder-filament
cabaoctavio/laravel-query-log
gregsphotography/site-builder-components
io-digital/clickatell
snowsoft/laravel-admin
oxygen/auth
relicsoftware/barakah-payment-gateway
tobya/work-with-composer
equipc/laravel-email-validator
equipc/laravel-email-database-log
adedsec/nova-translated-labels
wazza/laravel-db-encryption
hokny/laravel-generator
vonsogt/backup
jengo/queues
jeremykenedy/laravel-observability
coleus/frame
thtg88/journalism
filagora/filagora-ai-forums