medzuch/jwt-bundle
medzuch/jwt-bundle is a Symfony bundle for JWT authentication, offering configurable services to issue, validate, and handle JSON Web Tokens in PHP applications. It streamlines token-based auth setup and integration with Symfony's service container.
What shipped: v1.2.0 adds Medzuch\JwtBundle\Refresh\ — a contract-only layer for the refresh half of a session: RefreshTokenStoreInterface (for you to implement), RefreshTokenGenerator (behind medzuch_jwt.refresh_token_generator), and the RefreshToken / RefreshTokenRecord pair they exchange. Nothing in the bundle calls the interface; it exists so the vocabulary is shared between applications.
Framework note first: this is a Symfony bundle. The medzuch_jwt.* service id and config/packages/ wiring do not apply to a Laravel app. The Refresh\ namespace itself is plain PHP value objects and an interface, so it is portable — you just supply the container binding and the persistence yourself, which Laravel devs do as a matter of course anyway.
Minimal steps:
medzuch/jwt-php ^1.2.1. Read UPGRADE.md.Refresh/RefreshTokenStoreInterface.php first — it is the only thing you must satisfy. Then Refresh/RefreshToken.php (carries the client-facing value and derives the hash), Refresh/RefreshTokenRecord.php, Refresh/RefreshTokenGenerator.php.token_hash (unique, 64 chars), subject_id, grant_id/lineage, expires_at, consumed_at nullable, created_at. No migration ships — that is deliberate (§8.1 of docs/plan.md since v0.4).consume(), revokeGrant(), revokeAllFor() against that table.First use case: issue a refresh token at login (generate($lifetime) → hand RefreshToken to the client, persist the RefreshTokenRecord via your store), and spend it at the refresh endpoint with consume().
Spend with a single conditional write. The whole point of consume() is that find() then delete() has a window in it. Implement it as one atomic statement and let the row count tell you what happened:
public function consume(string $presented): ?RefreshTokenRecord
{
if ($presented === '') {
return null; // missing request param → invalid_grant, not an exception
}
$hash = RefreshToken::hashOf($presented);
$updated = DB::table('refresh_tokens')
->where('token_hash', $hash)
->whereNull('consumed_at')
->where('expires_at', '>', now('UTC'))
->update(['consumed_at' => now('UTC')]);
if ($updated === 1) {
return $this->recordFor($hash, alreadyUsed: false);
}
// Zero rows: either never issued, or already spent. Distinguish them.
$row = DB::table('refresh_tokens')->where('token_hash', $hash)->first();
return $row === null ? null : $this->recordFor($hash, alreadyUsed: true);
}
null means "the store never issued this" (or nothing was presented); a record with alreadyUsed: true means "it issued this and it was already spent" — the replay signal.
Bind the generator in a service provider. If RefreshTokenGenerator's constructor is dependency-free you can new it or bind it as a singleton; either way you own the wiring:
$this->app->singleton(RefreshTokenGenerator::class, fn () => new RefreshTokenGenerator());
$this->app->bind(RefreshTokenStoreInterface::class, EloquentRefreshTokenStore::class);
Pass the lifetime at the call site, not in config. generate() takes the lifetime as an argument because it belongs to the flow, not the deployment. Thirty days for a web session, longer for a mobile client — that decision lives in your controller, not in a config/refresh_tokens.php you invent.
Two revocations, two occasions.
revokeGrant() ends one lineage — this is the reaction to alreadyUsed: true. RFC 9700 §4.14.2: the server cannot tell which party submitted the bad token, so it revokes rather than investigates, and the cost is that client authorizing again.revokeAllFor() is the wider hammer for password change or "log out everywhere".Never store the raw token. RefreshToken carries the value for the client and derives the hash for the store from it, so the two halves cannot be handed in disagreeing. Persist RefreshToken::hashOf($value); the method is the single home of the rule.
Testing. An in-memory fake store (array keyed by hash, consumed_at flag) is enough to exercise the flows. The two tests worth writing: calling consume() twice returns alreadyUsed: true the second time, and consume('') returns null.
There is no storage, and there will not be. No entity, no migration, no default implementation. Rotation, device records and "log out everywhere" are questions over your schema. If you are waiting for a drop-in, you have misread the release.
Nothing calls the interface. No auto-decoration, no default service, no listener. If you register the binding and never inject it, everything still "works" and you get no refresh tokens. The bundle owns the spelling; you own the call sites.
No config section — deliberately. A refresh_tokens: key would advertise persistence the package does not have. Do not add one to your app config and expect the bundle to read it.
A retry after a lost response is indistinguishable from a theft. This is stated in the README rather than left to be discovered, and it is the sharp edge: if your client retries a refresh whose response was lost, your store reports alreadyUsed: true, you call revokeGrant(), and the user is logged out of that device. Make the client not retry a refresh it has not seen a response for; re-authenticate instead.
Do not "fix" the hash. It is SHA-256, not password_hash(), on purpose: the input is 256 bits from random_bytes(), so there is nothing to guess, and a slow hash on every refresh is a lever pointed at your own login flow. Swapping in bcrypt is a self-inflicted latency regression, and it also breaks RefreshToken::hashOf() as the single source of truth.
Index token_hash uniquely and store hex (64 chars). Your conditional UPDATE depends on that lookup being fast and collision-free.
Atomicity is on you, and it is engine-dependent. A SELECT ... FOR UPDATE inside a transaction works; on MySQL a bare conditional UPDATE works because of row locking. SQLite serialises writers, which is fine, but do not reach for a read-then-write pattern "just to get the record back" — that reintroduces the exact window consume() was designed to close.
Expiry checks are yours too. Compare expires_at in UTC, in the same statement as the consume if you can, otherwise an expired-but-unconsumed row still answers as a live spend.
No Flex recipe, and none coming (DEC-9, dropped rather than deferred). Symfony-side reasoning: Flex registers the bundle from the package type anyway, and jwt:key:generate plus the README quickstart beats a frozen starter config that no test here could compile. For a Laravel dev this is a non-event — you were never getting one.
Extension point is inverse. This package is the opposite of most: the extension point is the application. When the interface feels thin, the answer is a richer table and a stricter consume(), not a wrapper around the bundle.
How can I help you explore Laravel packages today?