Skip to main content
Refresh tokens in this package are single-use. Every successful exchange burns the presented token, issues a replacement pair, and atomically updates the rotation digest stored on the device row. The server owns this state, which means replay attacks and concurrent rotation attempts can be detected and acted on immediately — something that self-verifying access tokens alone cannot provide.
The old refresh token is immediately invalidated the moment rotation succeeds. Clients must replace their stored refresh token with the new value returned in the response. Presenting the previous token again will be treated as a replay attack, the device will be revoked, and a RefreshFailed event will be dispatched.

How refresh works

Call refresh() on the guard instance — not on the Auth facade’s JWT service — and pass the raw refresh token string from the client. The method returns a RefreshResult on success, or null on any failure.
$guard   = auth()->guard('api');
$rotated = $guard->refresh($refreshToken);
On success, RefreshResult contains two public string properties:
PropertyDescription
$accessTokenNewly issued access JWT for the authenticated context
$refreshTokenNewly issued refresh JWT — the old one is now burned
On failure, refresh() returns null. A RefreshFailed event has already been dispatched by the time null is returned — you do not need to dispatch it yourself.

The full exchange pattern

use SineMacula\Laravel\Authentication\Facades\Auth;

$guard   = auth()->guard('api');
$rotated = $guard->refresh($refreshToken);

if ($rotated === null) {
    // RefreshFailed event already dispatched with a machine-readable reason
    abort(401);
}

return [
    'access_token'  => $rotated->accessToken,
    'refresh_token' => $rotated->refreshToken,
];
After a successful exchange, the guard also binds the resolved identity, principal, and device — the same event sequence as a full login fires, followed by a Refreshed event so your listeners can distinguish a rotation from an initial authentication.

Failure handling and the RefreshFailed event

When refresh() returns null, a RefreshFailed event has been dispatched carrying a RefreshFailureReason backed enum. Listen to this event in your SIEM pipeline or for rate-limiting logic:
use SineMacula\Laravel\Authentication\Events\RefreshFailed;

Event::listen(RefreshFailed::class, function (RefreshFailed $event) {
    Log::warning('Refresh failed', [
        'reason' => $event->reason->value,
        'guard'  => $event->guard,
    ]);
});
The full set of reason codes is documented on the events reference page.

Security properties

Atomic per-device rotation. The rotation digest update and the new token pair are produced in a single operation scoped to the device row. Two concurrent exchange attempts on the same device cannot both succeed. Replay detection. If the package detects that a rotation id has already been consumed — which happens when a client presents a token that was valid in a previous rotation cycle — it treats this as evidence of a compromised or leaked token. The device is immediately revoked and a RefreshFailed event is dispatched with reason rotation_reuse. Any future refresh attempts for that device will fail with device_revoked. Constant-time digest verification. The stored rotation digest is compared against the presented token using hash_equals() to prevent timing side-channel attacks. pid hint validation. When the refresh token carries a pid claim, the guard resolves the principal and confirms it matches the hint before completing the exchange. A mismatch produces principal_mismatch and the exchange fails closed.

Revoking devices

Revoking a device row prevents all future refresh exchanges for that device. Access tokens already issued to that device remain valid until their exp claim is reached — the guard does not consult access-token jti values on the bearer path. For immediate access revocation, shorten your access-token TTL or implement CanBeActive on your identity model so the guard rejects inactive identities on every bearer request.
// Revoke a specific device — blocks refresh, does not invalidate in-flight access tokens
$device->update(['revoked_at' => now()]);