Redeeming & rotating
redeem() atomically claims and rotates a token. Of N concurrent redemptions of the same token, exactly one wins; every failure mode collapses to null, so a caller cannot tell unknown from expired from revoked from race-lost:
$result = RefreshTokens::redeem($plainFromClient); // ?RedemptionResult
if ($result === null) {
// unknown / expired / revoked / race-lost / reuse — host maps to its own error
throw ValidationException::withMessages(['refresh_token' => 'Please sign in again.']);
}
$owner = $result->user; // the owner model (User, Client, …)
$familyId = $result->familyId; // the stable session id
$spent = $result->redeemedToken; // the now-revoked rowThe claim is a single UPDATE … WHERE revoked_at IS NULL — a compare-and-swap the database serialises. No transaction, no row lock; it works identically on PostgreSQL and SQLite. A winning redeem dispatches RefreshTokenRedeemed.
Guard-scoped redemption
Pass the owner morph class your endpoint serves. A token of another owner type is treated as unknown — null, not consumed, no reuse signal — so presenting a user’s token at the clients endpoint can neither burn the user’s session nor trip reuse detection:
// POST /auth/refresh — users only
$result = RefreshTokens::redeem($plainFromClient, ownerType: (new User)->getMorphClass());
// POST /clients/refresh — API clients only
$result = RefreshTokens::redeem($plainFromClient, ownerType: (new Client)->getMorphClass());One-call rotation
rotate() does redeem + issue a same-family replacement in one call. Mint the new access token first and pass its reference in a RotationContext:
use RoundlyConsulting\RefreshTokens\DataTransferObjects\RotationContext;
$rotation = RefreshTokens::rotate($plainFromClient, new RotationContext(
ownerType: (new User)->getMorphClass(), // optional guard scope
ipAddress: $request->ip(), // current ip/ua replace the inherited ones
userAgent: $request->userAgent(),
accessReference: $newAccess->jti,
ttl: 3600, // optional sliding-lifetime override
meta: ['auth_time' => $authTime], // merged over the inherited meta
));
// ?RotationResult { user, newRefreshToken (NewRefreshToken), redeemedFamilyId }
if ($rotation === null) {
// re-authenticate
}
$plainText = $rotation->newRefreshToken->plainText; // return to the client ONCErotate() fires one RefreshTokenRedeemed for the spent token and one RefreshTokenIssued for the replacement.
What a replacement inherits
Issuing into an existing family — via rotate() or an explicit issue() with familyId — copies the session from the family’s newest row, revoked or not:
- family_started_at and absolute_expires_at — verbatim, so the absolute cap holds across the whole chain.
- meta — merged with the new meta (new keys win).
- Device and geo columns — browser, browser_version, os, os_version, device_type, is_bot, country, city, country_code.
- ip_address and user_agent — from the context when given, otherwise copied too.
The replacement’s expiry is min(now + ttl, absolute_expires_at).
Redeem, mint, then issue
When the replacement must reference an access token minted after the redeem, split the call — inheritance makes this path behave exactly like rotate():
// When the replacement must reference an access token minted AFTER the redeem:
$result = RefreshTokens::redeem($plainFromClient);
if ($result === null) {
// re-authenticate
}
// … mint the new access token for $result->user as $access …
$new = RefreshTokens::issue($result->user, new IssueContext(
accessReference: $access->jti,
familyId: $result->familyId, // inherit the session — behaves exactly like rotate()
));Failure semantics
Treat null (or an exception) as “re-authenticate”. rotate() consumes the old token before the replacement exists and is deliberately not rolled back — un-claiming a consumed token would reopen the double-spend window. If the replacement can’t be minted (a database blip, the owner deleted mid-rotation, or the family killed by a concurrent reuse response), the client simply holds no valid refresh token and signs in again. No token is ever forged.
An explicit familyId is validated: the family must exist for that same owner, must not have been killed by reuse detection, and its newest row must not have been ended by a revoke — otherwise InvalidTokenFamilyException. This prevents grafting a token into another user’s, or a dead, lineage.
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 cryptoBy 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.