d076 / sanctum-refresh-tokens
Refresh token realization for laravel sanctum
Requires
- php: ^8.3
- ext-json: *
- illuminate/auth: ^12.0|^13.0
- illuminate/console: ^12.0|^13.0
- illuminate/contracts: ^12.0|^13.0
- illuminate/database: ^12.0|^13.0
- illuminate/notifications: ^12.0|^13.0
- illuminate/support: ^12.0|^13.0
- laravel/framework: ^12.0|^13.0
- laravel/sanctum: ^4.0
Requires (Dev)
- larastan/larastan: ^3.9
- orchestra/testbench: ^10.0|^11.0
- pestphp/pest: ^3.0
README
Refresh tokens on top of Laravel Sanctum. Sanctum issues long-lived personal access tokens; this package adds a short-lived access token paired with a longer-lived, single-use refresh token, so a client can silently obtain a fresh pair without re-authenticating — the standard pattern for SPAs and mobile apps.
Features
- Access + refresh token pairs issued together, each with its own TTL.
- Single-use rotation — exchanging a refresh token deletes it and its bound access token, then issues a brand-new pair. Replaying a used token fails.
- Hashed at rest — refresh tokens are stored as SHA-256 hashes and compared in
constant time (
hash_equals); the plaintext is only ever returned to the client. - TTL enforced on lookup — expired refresh tokens are never matched.
- Credential login, logout and password reset helpers that revoke the right tokens.
- Override-friendly — no routes or controllers are shipped; you wire your own. Services are bound behind interfaces, and the user model's email/password fields are configurable.
- Prune command for housekeeping expired tokens.
Requirements
| Version | |
|---|---|
| PHP | ^8.3 |
| Laravel | 12, 13 |
| Sanctum | ^4.0 |
Tested against PHP 8.3 / 8.4 / 8.5 and Laravel 12 / 13 on SQLite, PostgreSQL and MySQL.
Installation
composer require d076/sanctum-refresh-tokens
Publish and run the migration (creates the personal_refresh_tokens table):
php artisan vendor:publish --tag=sanctum-refresh-tokens php artisan migrate
This package builds on Sanctum's personal_access_tokens table, so make sure
Sanctum itself is installed and migrated (php artisan install:api on a fresh app).
Upgrading from 3.x
4.0 adds an abilities column to personal_refresh_tokens (so a token's scope is
preserved across refreshes). Re-publish and migrate to pick it up:
php artisan vendor:publish --tag=sanctum-refresh-tokens php artisan migrate
Refresh tokens issued before the upgrade have no stored scope and fall back to ['*']
on their next refresh.
Setup
Extend your authenticatable model from AuthenticatableUser:
use D076\SanctumRefreshTokens\Models\AuthenticatableUser; class User extends AuthenticatableUser { // ... }
AuthenticatableUser already pulls in Sanctum's HasApiTokens plus this package's
refresh-token behaviour. If you can't change your base class, use the trait directly
and implement the contract instead:
use D076\SanctumRefreshTokens\HasApiTokensInterface; use D076\SanctumRefreshTokens\Traits\HasApiTokens; use Illuminate\Foundation\Auth\User as Authenticatable; class User extends Authenticatable implements HasApiTokensInterface { use HasApiTokens; }
Configuration
Token lifetimes are read from Sanctum's config (config/sanctum.php). Add the keys
this package uses alongside Sanctum's own:
// config/sanctum.php 'expiration' => env('SANCTUM_ACCESS_TOKEN_EXPIRATION', 60), // access token, minutes 'refresh_token_expiration' => env('SANCTUM_REFRESH_TOKEN_EXPIRATION', 43200), // refresh "remember me", minutes (30 days) 'refresh_token_expiration_no_remember' => env('SANCTUM_REFRESH_TOKEN_EXPIRATION_NO_REMEMBER', 1440), // refresh without remember, minutes (1 day)
If a key is absent the built-in defaults above are used, so the package works
out of the box. The optional sanctum.token_prefix is honoured for refresh tokens
too (useful for secret-scanning).
Usage
The package ships no routes or controllers — you stay in control of your API surface. Inject the services where you need them.
Issuing tokens
use D076\SanctumRefreshTokens\Services\TokenService; $tokens = (new TokenService($user))->createTokens();
createTokens() accepts optional overrides:
$tokens = (new TokenService($user))->createTokens( accessTokenExpiresAt: now()->addMinutes(15), refreshTokenExpiresAt: now()->addDays(7), abilities: ['orders:read', 'orders:write'], );
It returns a TokensDTO:
$tokens->access_token; // plain-text bearer token $tokens->refresh_token; // plain-text refresh token ("{id}|{token}") $tokens->token_type; // "Bearer" $tokens->access_token_expires_at; // Carbon|null $tokens->refresh_token_expires_at; // Carbon|null $tokens->model; // morph class of the user $tokens->user; // the user model return response()->json($tokens); // implements Arrayable
Logging in with credentials
use D076\SanctumRefreshTokens\DTOs\LoginDTO; use D076\SanctumRefreshTokens\Services\IAuthService; public function login(Request $request, IAuthService $auth) { $credentials = new LoginDTO( email: $request->input('email'), password: $request->input('password'), model: User::class, remember: $request->boolean('remember'), ); // throws Illuminate\Auth\AuthenticationException on bad credentials $tokens = $auth->setCredentials($credentials)->login(); return response()->json($tokens); }
remember selects between the two refresh-token TTLs (refresh_token_expiration
vs refresh_token_expiration_no_remember).
Already have the user (e.g. social login)? Skip credentials:
$tokens = $auth->setUser($user)->login();
Refreshing
public function refresh(Request $request, IAuthService $auth) { // throws AuthenticationException if the token is unknown, expired or already used $tokens = $auth->refresh($request->input('refresh_token')); return response()->json($tokens); }
The old refresh token and its bound access token are deleted before the new pair is issued, so a stolen-and-replayed token is rejected on the second use.
Logout
Behind the auth:sanctum guard, the authenticated request carries the current
access token, so logout can revoke exactly that pair:
public function logout(Request $request, IAuthService $auth) { $auth->setUser($request->user())->logout(); return response()->noContent(); }
Password reset
Hashes the new password, saves it, and revokes all of the user's access and refresh tokens:
$auth->setUser($user)->resetPassword($newPassword);
Revoking tokens directly
use D076\SanctumRefreshTokens\Services\TokenService; (new TokenService($user))->deleteCurrentTokens(); // current pair only (new TokenService($user))->deleteAllTokens(); // every pair
Pruning expired tokens
A console command removes refresh tokens that expired more than --hours ago
(default 24):
php artisan sanctum:prune-refresh-expired --hours=0
Schedule it next to Sanctum's own pruning:
use Illuminate\Support\Facades\Schedule; Schedule::command('sanctum:prune-expired --hours=0')->hourly(); Schedule::command('sanctum:prune-refresh-expired --hours=0')->daily();
Customisation
Custom email / password columns
If your model doesn't use email / password, expose the column names and the
package will pick them up:
class User extends AuthenticatableUser { public function getEmailField(): string { return 'login'; } public function getPasswordField(): string { return 'pass_hash'; } }
Swapping the service implementations
Both services are bound behind interfaces, so you can rebind your own in a service provider:
use D076\SanctumRefreshTokens\Services\IAuthService; use D076\SanctumRefreshTokens\Services\ITokenService; $this->app->bind(IAuthService::class, MyAuthService::class); $this->app->bind(ITokenService::class, MyTokenService::class);
How it works
- A refresh token is
{id}|{token}, where{token}is 40 random characters plus a CRC32b checksum (and the optionalsanctum.token_prefix). - Only
SHA-256({token})is stored inpersonal_refresh_tokens.token; the column is hidden from serialization. PersonalRefreshToken::findToken()looks up the row by id, verifies the hash withhash_equals(), and only matches rows whoseexpires_atis in the future.- Each refresh token is linked to the access token it was issued with
(
access_token_id); deleting the refresh token cascades to that access token via a model observer. - The token's abilities (scope) are stored on the refresh token row, so refreshing preserves the scope even after the short-lived access token has been pruned.
Testing
composer install composer test # Pest composer analyse # PHPStan (level 6)
A Docker setup is included to run the suite (including the PostgreSQL/MySQL matrix):
docker compose run --rm test composer install docker compose run --rm test vendor/bin/pest # cross-database docker compose up -d --wait pgsql mysql docker compose run --rm -e DB_DRIVER=pgsql test vendor/bin/pest --group=cross-db docker compose run --rm -e DB_DRIVER=mysql test vendor/bin/pest --group=cross-db
Changelog & License
See CHANGELOG.md. Released under the MIT license.