NewWe open-sourced 50+ Laravel packages
Custom AI apps, agents and automation — Roundly ConsultingRoundly
All packages

Presenting an already-rotated token — to redeem() or rotate(), or to revoke() (see Revocation & logout) — is a theft signal. The entire token family is revoked, the bound AccessTokenRevoker is called for every live member’s access token, and RefreshTokenReuseDetected fires — all automatically, with no config switch:

$a = RefreshTokens::issue($user, new IssueContext(accessReference: 'acc-a'));
$rotation = RefreshTokens::rotate($a->plainText, new RotationContext(accessReference: 'acc-b'));

// The already-rotated token A is presented again — a theft signal:
RefreshTokens::redeem($a->plainText); // null — and the whole family is revoked

$rotation->newRefreshToken->token->fresh()->revoked_reason; // RevocationReason::ReuseDetected
// AccessTokenRevoker::revoke('acc-b') was called; RefreshTokenReuseDetected fired once

Race-hardened

  • The family revoke re-scans until a pass revokes nothing.
  • A replacement issued into a family around the moment it is killed revokes itself, so a freshly rotated token can never survive the theft response, in any race ordering.
  • If the replay lands while the family is mid-rotation — its newest row already claimed, the replacement not yet inserted — the replayed row is marked ReuseDetected and the in-flight replacement is refused (rotate() returns null; an explicit issue() throws InvalidTokenFamilyException).
  • With the default strict rotation.grace = 0 this includes the loser of two concurrent redemptions of the same token.

Grace window

If your frontend legitimately re-presents a token within a short single-flight window (two tabs refreshing at once, a retried request), give it a few seconds of grace. Inside the window a re-presented token returns null without revoking anything (a logout with it still ends the session — see Revocation & logout):

REFRESH_TOKENS_ROTATION_GRACE=30

Alerting

RefreshTokenReuseDetected fires only when the sweep actually revokes something (revokedCount > 0), so replaying an already-dead token can’t spam your alerting with empty events:

use Illuminate\Support\Facades\Event;
use RoundlyConsulting\RefreshTokens\Events\RefreshTokenReuseDetected;

Event::listen(function (RefreshTokenReuseDetected $event): void {
    // $event->familyId, $event->ownerType, $event->ownerId, $event->revokedCount
    // alert your security channel, notify the account owner, …
});

Request throttling stays host-owned — the package does not rate-limit token presentation. Wrap your refresh endpoint in Laravel’s throttle middleware to blunt replay floods.

Show your open-source love

This package is free and MIT-licensed. If it saves you time, a one-off donation or a Patreon membership keeps it maintained, tested and documented.

More ways to support, including crypto

By donating, you agree to our donation terms.

Want this built into your product?

We integrate our packages into custom Laravel and AI builds. Tell us what you're working on and we'll reply within 48 hours.