Contents
- Prerequisites
- Step 1: Create a New Laravel 12 Project
- Step 2: Configure Environment Variables
- Step 3: Install Laravel Sanctum
- Step 4: Run Initial Migrations
- Step 5: Create Refresh Token Migration
- Step 6: Create RefreshToken Model
- Step 7: Update User Model
- Step 8: Create Authentication Service
- Step 9: Create Authentication Controller
- Step 10: Set Up API Routes
- Step 11: Configure API Exception Handling
- Step 12: Configure Sanctum
- Step 13: Create Token Cleanup Command
- Step 14: Clear Cache and Verify Configuration
- Step 15: Test the API
- Understanding Token Expiration
- Token Blacklisting Features
- Security Best Practices
- Troubleshooting
- Conclusion
This comprehensive tutorial will guide you through building a secure API authentication system in Laravel 12 using access tokens and refresh tokens with proper token blacklisting.
Prerequisites
- PHP 8.2 or higher
- Composer installed
- MySQL or PostgreSQL database
- Basic understanding of Laravel and REST APIs
Step 1: Create a New Laravel 12 Project
You can install Laravel 12 using two primary methods:
Method 1: Using Composer (Recommended)
composer create-project laravel/laravel laravel-api-auth
cd laravel-api-auth
Method 2: Using Laravel Installer
First, install the Laravel installer globally:
composer global require laravel/installer
Make sure Composer’s global bin directory is in your PATH:
# For Linux/Mac
export PATH="$HOME/.composer/vendor/bin:$PATH"
# Or for newer Composer versions
export PATH="$HOME/.config/composer/vendor/bin:$PATH"
Then create a new Laravel project:
laravel new laravel-api-auth
cd laravel-api-auth
Step 2: Configure Environment Variables
Open .env file and configure your database connection and token expiration settings:
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=laravel_api_auth
DB_USERNAME=your_username
DB_PASSWORD=your_password
# Token Expiration Settings (in minutes)
ACCESS_TOKEN_EXPIRATION=60
REFRESH_TOKEN_EXPIRATION=43200
Create the database:
mysql -u your_username -p
CREATE DATABASE laravel_api_auth;
exit;
Step 3: Install Laravel Sanctum
Laravel Sanctum provides authentication for SPAs and mobile applications.
php artisan install:api
This command installs Sanctum and publishes its configuration and migration files.
Step 4: Run Initial Migrations
php artisan migrate
Step 5: Create Refresh Token Migration
Create a migration for storing refresh tokens with blacklisting support:
php artisan make:migration create_refresh_tokens_table
Edit the migration file in database/migrations/:
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('refresh_tokens', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->onDelete('cascade');
$table->string('token', 500)->unique();
$table->timestamp('expires_at');
$table->boolean('is_revoked')->default(false);
$table->timestamp('revoked_at')->nullable();
$table->timestamp('used_at')->nullable();
$table->timestamps();
// Indexes for performance
$table->index(['user_id', 'is_revoked']);
$table->index('expires_at');
});
}
public function down(): void
{
Schema::dropIfExists('refresh_tokens');
}
};
Run the migration:
php artisan migrate
Step 6: Create RefreshToken Model
php artisan make:model RefreshToken
Edit app/Models/RefreshToken.php:
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class RefreshToken extends Model
{
protected $fillable = [
'user_id',
'token',
'expires_at',
'is_revoked',
'revoked_at',
'used_at',
];
protected $casts = [
'expires_at' => 'datetime',
'revoked_at' => 'datetime',
'used_at' => 'datetime',
'is_revoked' => 'boolean',
];
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function isExpired(): bool
{
return $this->expires_at->isPast();
}
public function isRevoked(): bool
{
return $this->is_revoked;
}
public function isValid(): bool
{
return !$this->isExpired() && !$this->isRevoked();
}
public function revoke(): void
{
$this->update([
'is_revoked' => true,
'revoked_at' => now(),
]);
}
public function markAsUsed(): void
{
$this->update([
'used_at' => now(),
]);
}
}
Step 7: Update User Model
Add the relationship to app/Models/User.php:
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Laravel\Sanctum\HasApiTokens;
class User extends Authenticatable
{
use HasFactory, Notifiable, HasApiTokens;
protected $fillable = [
'name',
'email',
'password',
];
protected $hidden = [
'password',
'remember_token',
];
protected function casts(): array
{
return [
'email_verified_at' => 'datetime',
'password' => 'hashed',
];
}
public function refreshTokens(): HasMany
{
return $this->hasMany(RefreshToken::class);
}
}
Step 8: Create Authentication Service
php artisan make:class Services/AuthService
Edit app/Services/AuthService.php:
<?php
namespace App\Services;
use App\Models\RefreshToken;
use App\Models\User;
use Illuminate\Support\Str;
use Carbon\Carbon;
class AuthService
{
private int $accessTokenExpiration;
private int $refreshTokenExpiration;
public function __construct()
{
// Get token expiration from environment variables (in minutes)
$this->accessTokenExpiration = (int) env('ACCESS_TOKEN_EXPIRATION', 60);
$this->refreshTokenExpiration = (int) env('REFRESH_TOKEN_EXPIRATION', 43200);
}
public function generateAccessToken(User $user): string
{
// Delete old tokens
$user->tokens()->delete();
// Create new access token with expiration from env
$token = $user->createToken(
'access_token',
['*'],
now()->addMinutes($this->accessTokenExpiration)
);
return $token->plainTextToken;
}
public function generateRefreshToken(User $user): string
{
// Revoke all active refresh tokens for this user
$this->revokeUserRefreshTokens($user);
// Generate refresh token
$refreshToken = Str::random(128);
// Store refresh token with expiration from env
RefreshToken::create([
'user_id' => $user->id,
'token' => hash('sha256', $refreshToken),
'expires_at' => Carbon::now()->addMinutes($this->refreshTokenExpiration),
'is_revoked' => false,
]);
return $refreshToken;
}
public function refreshAccessToken(string $refreshToken): ?array
{
$hashedToken = hash('sha256', $refreshToken);
$storedToken = RefreshToken::where('token', $hashedToken)
->where('is_revoked', false)
->first();
// Check if token exists and is valid
if (!$storedToken) {
return null;
}
// Check if token is expired
if ($storedToken->isExpired()) {
$storedToken->revoke();
return null;
}
// Check if token was already used (token rotation security)
if ($storedToken->used_at) {
// Token reuse detected - potential security breach
// Revoke all tokens for this user
$this->revokeAllUserTokens($storedToken->user);
return null;
}
$user = $storedToken->user;
// Mark token as used
$storedToken->markAsUsed();
// Revoke the old refresh token after use
$storedToken->revoke();
// Generate new access token
$accessToken = $this->generateAccessToken($user);
// Generate new refresh token (token rotation)
$newRefreshToken = $this->generateRefreshToken($user);
return [
'access_token' => $accessToken,
'refresh_token' => $newRefreshToken,
'token_type' => 'Bearer',
'expires_in' => $this->accessTokenExpiration * 60, // Convert to seconds
];
}
public function revokeTokens(User $user): void
{
$user->tokens()->delete();
$this->revokeUserRefreshTokens($user);
}
public function revokeUserRefreshTokens(User $user): void
{
RefreshToken::where('user_id', $user->id)
->where('is_revoked', false)
->update([
'is_revoked' => true,
'revoked_at' => now(),
]);
}
public function revokeAllUserTokens(User $user): void
{
// Revoke access tokens
$user->tokens()->delete();
// Revoke all refresh tokens
$this->revokeUserRefreshTokens($user);
}
public function cleanupExpiredTokens(): int
{
// Delete expired and revoked tokens older than 30 days
return RefreshToken::where(function ($query) {
$query->where('expires_at', '<', now())
->orWhere('is_revoked', true);
})
->where('created_at', '<', now()->subDays(30))
->delete();
}
public function getAccessTokenExpiration(): int
{
return $this->accessTokenExpiration;
}
public function getRefreshTokenExpiration(): int
{
return $this->refreshTokenExpiration;
}
}
Step 9: Create Authentication Controller
php artisan make:controller Api/AuthController
Edit app/Http/Controllers/Api/AuthController.php:
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\RefreshToken;
use App\Models\User;
use App\Services\AuthService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\ValidationException;
class AuthController extends Controller
{
public function __construct(
protected AuthService $authService
) {}
public function register(Request $request): JsonResponse
{
$validated = $request->validate([
'name' => 'required|string|max:255',
'email' => 'required|string|email|max:255|unique:users',
'password' => 'required|string|min:8|confirmed',
]);
$user = User::create([
'name' => $validated['name'],
'email' => $validated['email'],
'password' => Hash::make($validated['password']),
]);
$accessToken = $this->authService->generateAccessToken($user);
$refreshToken = $this->authService->generateRefreshToken($user);
return response()->json([
'message' => 'User registered successfully',
'user' => $user,
'access_token' => $accessToken,
'refresh_token' => $refreshToken,
'token_type' => 'Bearer',
'expires_in' => $this->authService->getAccessTokenExpiration() * 60,
], 201);
}
public function login(Request $request): JsonResponse
{
$validated = $request->validate([
'email' => 'required|email',
'password' => 'required',
]);
$user = User::where('email', $validated['email'])->first();
if (!$user || !Hash::check($validated['password'], $user->password)) {
throw ValidationException::withMessages([
'email' => ['The provided credentials are incorrect.'],
]);
}
$accessToken = $this->authService->generateAccessToken($user);
$refreshToken = $this->authService->generateRefreshToken($user);
return response()->json([
'message' => 'Login successful',
'user' => $user,
'access_token' => $accessToken,
'refresh_token' => $refreshToken,
'token_type' => 'Bearer',
'expires_in' => $this->authService->getAccessTokenExpiration() * 60,
]);
}
public function refresh(Request $request): JsonResponse
{
$validated = $request->validate([
'refresh_token' => 'required|string',
]);
$tokens = $this->authService->refreshAccessToken($validated['refresh_token']);
if (!$tokens) {
return response()->json([
'message' => 'Invalid, expired, or revoked refresh token',
], 401);
}
return response()->json([
'message' => 'Token refreshed successfully',
'access_token' => $tokens['access_token'],
'refresh_token' => $tokens['refresh_token'],
'token_type' => $tokens['token_type'],
'expires_in' => $tokens['expires_in'],
]);
}
public function logout(Request $request): JsonResponse
{
$this->authService->revokeTokens($request->user());
return response()->json([
'message' => 'Logged out successfully',
]);
}
public function me(Request $request): JsonResponse
{
return response()->json([
'user' => $request->user(),
]);
}
public function revokeRefreshToken(Request $request): JsonResponse
{
$validated = $request->validate([
'refresh_token' => 'required|string',
]);
$hashedToken = hash('sha256', $validated['refresh_token']);
$token = RefreshToken::where('token', $hashedToken)
->where('user_id', $request->user()->id)
->first();
if (!$token) {
return response()->json([
'message' => 'Token not found',
], 404);
}
$token->revoke();
return response()->json([
'message' => 'Refresh token revoked successfully',
]);
}
}
Step 10: Set Up API Routes
Edit routes/api.php:
<?php
use App\Http\Controllers\Api\AuthController;
use Illuminate\Support\Facades\Route;
// Public routes
Route::post('/register', [AuthController::class, 'register'])->name('register');
Route::post('/login', [AuthController::class, 'login'])->name('login');
Route::post('/refresh', [AuthController::class, 'refresh'])->name('refresh');
// Protected routes
Route::middleware('auth:sanctum')->group(function () {
Route::post('/logout', [AuthController::class, 'logout'])->name('logout');
Route::get('/me', [AuthController::class, 'me'])->name('me');
Route::post('/revoke-token', [AuthController::class, 'revokeRefreshToken'])->name('revoke.token');
});
Step 11: Configure API Exception Handling
To ensure your API always returns JSON responses without redirects, configure exception handling in bootstrap/app.php.
Edit bootstrap/app.php:
<?php
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;
use Illuminate\Auth\AuthenticationException;
use Illuminate\Http\Request;
return Application::configure(basePath: dirname(__DIR__))
->withRouting(
web: __DIR__.'/../routes/web.php',
api: __DIR__.'/../routes/api.php',
commands: __DIR__.'/../routes/console.php',
health: '/up',
)
->withMiddleware(function (Middleware $middleware) {
$middleware->statefulApi();
})
->withExceptions(function (Exceptions $exceptions) {
$exceptions->render(function (AuthenticationException $e, Request $request) {
if ($request->is('api/*')) {
return response()->json([
'message' => $e->getMessage(),
], 401);
}
});
})->create();
Note: $middleware->statefulApi() is a Laravel shorthand that automatically adds Sanctum’s EnsureFrontendRequestsAreStateful middleware. It enables both token-based authentication (for mobile/third-party) and session-based authentication (for SPAs on the same domain).
Important: After updating this file, clear all caches:
php artisan config:clear
php artisan cache:clear
php artisan route:clear
php artisan optimize:clear
Then restart your development server:
php artisan serve
This configuration:
- Enables Sanctum’s stateful API support (session + token auth)
- Catches
AuthenticationExceptionon all API routes - Returns JSON response with 401 status code
- Uses the exception’s actual message
- Prevents any redirect attempts to login routes
- Works automatically without requiring
Accept: application/jsonheader from clients
Step 12: Configure Sanctum
Publish Sanctum configuration:
php artisan vendor:publish --provider="Laravel\Sanctum\SanctumServiceProvider"
Edit config/sanctum.php:
<?php
return [
'stateful' => explode(',', env('SANCTUM_STATEFUL_DOMAINS', sprintf(
'%s%s',
'localhost,localhost:3000,127.0.0.1,127.0.0.1:8000,::1',
env('APP_URL') ? ','.parse_url(env('APP_URL'), PHP_URL_HOST) : ''
))),
'guard' => ['web'],
'expiration' => env('ACCESS_TOKEN_EXPIRATION', 60),
'token_prefix' => env('SANCTUM_TOKEN_PREFIX', ''),
'middleware' => [
'authenticate_session' => Laravel\Sanctum\Http\Middleware\AuthenticateSession::class,
'encrypt_cookies' => Illuminate\Cookie\Middleware\EncryptCookies::class,
'validate_csrf_token' => Illuminate\Foundation\Http\Middleware\ValidateCsrfToken::class,
],
];
Step 13: Create Token Cleanup Command
Create a command to clean up expired tokens:
php artisan make:command CleanupExpiredTokens
Edit app/Console/Commands/CleanupExpiredTokens.php:
<?php
namespace App\Console\Commands;
use App\Services\AuthService;
use Illuminate\Console\Command;
class CleanupExpiredTokens extends Command
{
protected $signature = 'tokens:cleanup';
protected $description = 'Clean up expired and revoked tokens';
public function handle(AuthService $authService): int
{
$this->info('Cleaning up expired and revoked tokens...');
$deletedCount = $authService->cleanupExpiredTokens();
$this->info("Deleted {$deletedCount} expired/revoked tokens.");
return Command::SUCCESS;
}
}
Schedule this command to run automatically. Edit routes/console.php:
<?php
use Illuminate\Support\Facades\Schedule;
Schedule::command('tokens:cleanup')->daily();
This schedules the cleanup command to run once every day at midnight.
Setting Up the Laravel Scheduler
For the scheduled tasks to run, you need to add a single cron entry to your server. Laravel’s scheduler will handle running all scheduled tasks.
On your server (Linux/Mac), add this cron entry:
# Open crontab editor
crontab -e
Add this line:
* * * * * cd /path-to-your-project && php artisan schedule:run >> /dev/null 2>&1
Replace /path-to-your-project with the actual path to your Laravel project.
For local development, you can run the scheduler manually:
# Run the scheduler once (for testing)
php artisan schedule:run
# Or run it continuously in the background (keeps checking every minute)
php artisan schedule:work
Alternative Schedule Options
You can customize when the cleanup runs:
// Run every hour
Schedule::command('tokens:cleanup')->hourly();
// Run every 6 hours
Schedule::command('tokens:cleanup')->everySixHours();
// Run weekly on Sundays at 1:00 AM
Schedule::command('tokens:cleanup')->weekly()->sundays()->at('01:00');
// Run monthly on the first day at 2:00 AM
Schedule::command('tokens:cleanup')->monthlyOn(1, '02:00');
Manual Token Cleanup
You can also run the cleanup manually anytime:
php artisan tokens:cleanup
Step 14: Clear Cache and Verify Configuration
Clear all caches and verify the setup:
php artisan config:clear
php artisan cache:clear
php artisan route:clear
php artisan optimize:clear
Verify your routes are registered:
php artisan route:list --path=api
You should see output like:
POST api/register ........ register › Api\AuthController@register
POST api/login ........... login › Api\AuthController@login
POST api/refresh ......... refresh › Api\AuthController@refresh
POST api/logout .......... logout › Api\AuthController@logout
GET api/me .............. me › Api\AuthController@me
POST api/revoke-token .... revoke.token › Api\AuthController@revokeRefreshToken
Step 15: Test the API
Start the development server:
php artisan serve
Test Registration
curl -X POST http://localhost:8000/api/register \
-H "Content-Type: application/json" \
-d '{
"name": "John Doe",
"email": "john@example.com",
"password": "password123",
"password_confirmation": "password123"
}'
Expected Response:
{
"message": "User registered successfully",
"user": {
"name": "John Doe",
"email": "john@example.com",
"id": 1
},
"access_token": "1|abc123...",
"refresh_token": "def456...",
"token_type": "Bearer",
"expires_in": 3600
}
Test Login
curl -X POST http://localhost:8000/api/login \
-H "Content-Type: application/json" \
-d '{
"email": "john@example.com",
"password": "password123"
}'
Expected Response:
{
"message": "Login successful",
"user": {
"id": 1,
"name": "John Doe",
"email": "john@example.com"
},
"access_token": "2|xyz789...",
"refresh_token": "ghi012...",
"token_type": "Bearer",
"expires_in": 3600
}
Test Protected Route (Get User Info)
curl -X GET http://localhost:8000/api/me \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN"
Replace YOUR_ACCESS_TOKEN with the actual token from login/register response.
Expected Response:
{
"user": {
"id": 1,
"name": "John Doe",
"email": "john@example.com"
}
}
Test Token Refresh
curl -X POST http://localhost:8000/api/refresh \
-H "Content-Type: application/json" \
-d '{
"refresh_token": "YOUR_REFRESH_TOKEN"
}'
Expected Response:
{
"message": "Token refreshed successfully",
"access_token": "3|new_token...",
"refresh_token": "new_refresh...",
"token_type": "Bearer",
"expires_in": 3600
}
Test Token Revocation
curl -X POST http://localhost:8000/api/revoke-token \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"refresh_token": "YOUR_REFRESH_TOKEN"
}'
Expected Response:
{
"message": "Refresh token revoked successfully"
}
Test Logout
curl -X POST http://localhost:8000/api/logout \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN"
Expected Response:
{
"message": "Logged out successfully"
}
Test With Invalid Token
curl -X GET http://localhost:8000/api/me \
-H "Authorization: Bearer invalid_token"
Expected Response:
{
"message": "Unauthenticated."
}
Understanding Token Expiration
The token expiration is controlled by environment variables in your .env file:
# Access token expiration in minutes (default: 60 minutes = 1 hour)
ACCESS_TOKEN_EXPIRATION=60
# Refresh token expiration in minutes (default: 43200 minutes = 30 days)
REFRESH_TOKEN_EXPIRATION=43200
Common configurations:
High-security applications:
ACCESS_TOKEN_EXPIRATION=15 # 15 minutes
REFRESH_TOKEN_EXPIRATION=10080 # 7 days
Standard applications:
ACCESS_TOKEN_EXPIRATION=60 # 1 hour
REFRESH_TOKEN_EXPIRATION=43200 # 30 days
Internal applications:
ACCESS_TOKEN_EXPIRATION=240 # 4 hours
REFRESH_TOKEN_EXPIRATION=129600 # 90 days
After changing these values, run:
php artisan config:clear
php artisan optimize:clear
Token Blacklisting Features
This implementation includes comprehensive token blacklisting and security features:
1. Expired Token Handling
- Expired tokens are automatically detected and rejected
- Expired tokens are marked as revoked when accessed
2. Token Reuse Detection
- Each refresh token can only be used once (token rotation)
- If a token is reused, it indicates potential token theft
- All user tokens are revoked if reuse is detected
3. Manual Token Revocation
- Tokens can be manually revoked via logout endpoint
- Specific refresh tokens can be revoked
- All tokens for a user can be revoked (useful for account compromise)
4. Token Rotation
- New refresh token is issued with each refresh request
- Old refresh token is immediately revoked after use
- Prevents token replay attacks
5. Database Tracking
is_revoked: Boolean flag for blacklisted tokensrevoked_at: Timestamp of revocationused_at: Timestamp of token usageexpires_at: Token expiration time- Indexed columns for fast queries
6. Automatic Cleanup
- Scheduled command to clean up old expired/revoked tokens
- Runs daily via Laravel’s task scheduler
- Keeps database clean and performant
Security Best Practices
- Always use HTTPS in production to prevent token interception
- Store refresh tokens securely on the client side (HttpOnly cookies for web apps, secure storage for mobile)
- Implement rate limiting on authentication endpoints to prevent brute force attacks
- Monitor authentication attempts for security analysis
- Rotate tokens regularly – this implementation does it automatically
- Use short-lived access tokens (15-60 minutes recommended)
- Use longer-lived refresh tokens (7-30 days recommended)
- Validate tokens on every request using Sanctum middleware
- Implement account lockout after multiple failed login attempts
- Log security events for audit trails
Troubleshooting
Issue: “Route [login] not defined” error
Solution: Ensure route names are defined and run:
php artisan route:clear
php artisan config:clear
Issue: Tokens not expiring
Solution: Check .env configuration and clear config cache:
php artisan config:clear
Issue: 500 Internal Server Error
Solution: Check Laravel logs at storage/logs/laravel.log and ensure all migrations are run:
php artisan migrate:status
Issue: Unauthenticated errors
Solution: Ensure the Authorization header is properly formatted:
Authorization: Bearer YOUR_TOKEN
Conclusion
You now have a production-ready Laravel 12 API authentication system with:
- Access tokens for short-term authentication
- Refresh tokens for obtaining new access tokens
- Comprehensive token blacklisting and revocation
- Token rotation for enhanced security
- Environment-based configuration
- Automatic token cleanup
- Protection against token theft and replay attacks
This implementation provides a secure, scalable authentication solution suitable for mobile apps, SPAs, and third-party API integrations.

