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

Revocation & logout

use RoundlyConsulting\RefreshTokens\Enums\RevocationReason;

RefreshTokens::revoke($plainFromClient);            // logout with token in hand; bool, idempotent
RefreshTokens::sessions($user)->revokeAll();         // global logout — returns count revoked
RefreshTokens::sessions($user)->revokeAll(RevocationReason::CredentialsChanged);
RefreshTokens::session($row)->revoke();              // one row you hold (reason defaults to Manual)

RefreshTokens::revoke() takes the plaintext (reason defaults to Logout). It returns true when it ended a live session and false for an unknown token or one whose session had already ended — so calling it twice is safe. To revoke a row you already hold, use session($row)->revoke() (reason defaults to Manual):

RefreshTokens::session($row)->revoke();                           // the model or its key — reason Manual
RefreshTokens::session($row)->revoke(RevocationReason::Security);  // every verb takes an explicit reason
RefreshTokens::revoke($plainFromClient, RevocationReason::Security);

Logging out with an already-rotated token

A spent token still ends its session. It no longer holds it — the session lives on in the row it was rotated into — so revoking the spent row alone would let whoever rotated it keep refreshing. Instead:

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

// The client logs out with the token it held before the rotation:
RefreshTokens::revoke($a->plainText);   // true — outside rotation.grace the whole family is revoked
                                        // as ReuseDetected and RefreshTokenReuseDetected fires
  • Outside rotation.grace the spent token is a theft signal, handled exactly like a replay at redeem(): the whole family is revoked as ReuseDetected, every live access reference is denied, RefreshTokenReuseDetected fires and revoke() returns true. The reason you passed is not used — the rows record why they really died.
  • Inside rotation.grace (your client’s own refresh racing its logout) the family’s live rows are revoked with your reason, SessionRevoked fires for each, and no reuse event fires.
  • A session caught mid-rotation is sealed either way, so the in-flight replacement is refused.

Every revoke persists revoked_reason, calls the bound AccessTokenRevoker once per live access reference, and dispatches SessionRevoked.

Revocation reasons

Every revoke verb takes an optional RevocationReason, persisted in revoked_reason and carried on SessionRevoked:

CaseValueWhen
RotatedrotatedSuperseded by a rotation.
LogoutlogoutExplicit single-session logout.
LogoutAlllogout_allGlobal logout / revoke-all.
ReuseDetectedreuse_detectedFamily revoke on a theft signal.
ExpiredexpiredSwept past expiry.
ManualmanualAdmin / host revocation.
CredentialsChangedcredentials_changedPassword / 2FA / passkey change.
AccountDisabledaccount_disabledOwner disabled or deleted.
SessionLimitsession_limitEvicted by a per-owner session cap.
SecuritysecurityAny other security response.

Log out everywhere on a password change

The canonical reaction to a credential change is to revoke every session. Wire it from your change-password flow or from a model observer — the package ships the verb but never hooks your User model for you:

// In a change-password action:
$user->update(['password' => Hash::make($newPassword)]);
$user->revokeAllSessions(RevocationReason::CredentialsChanged);

// …or via a saved observer:
User::saved(function (User $user): void {
    if ($user->wasChanged('password')) {
        $user->revokeAllSessions(RevocationReason::CredentialsChanged);
    }
});

Logout can’t be outrun by a refresh

A session revoked mid-rotation — its token already redeemed, the replacement not yet issued — has no active row for that instant. The session verbs (sessions()->revokeAll(), revokeOthers(), revokeAllExcept(), revoke()) seal such a session too: they relabel its redeemed row with the reason, deny its access reference and fire SessionRevoked. The in-flight replacement is then refused — rotate() returns null, an explicit issue() throws InvalidTokenFamilyException or comes back already revoked.

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.