laravel blog Binarybox Tutorials

How to Create Laravel 12 API Authentication with Access Token and Refresh Token

Avatar photoPosted by

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 AuthenticationException on 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/json header 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 &amp;&amp; php artisan schedule:run >> /dev/null 2>&amp;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 tokens
  • revoked_at: Timestamp of revocation
  • used_at: Timestamp of token usage
  • expires_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

  1. Always use HTTPS in production to prevent token interception
  2. Store refresh tokens securely on the client side (HttpOnly cookies for web apps, secure storage for mobile)
  3. Implement rate limiting on authentication endpoints to prevent brute force attacks
  4. Monitor authentication attempts for security analysis
  5. Rotate tokens regularly – this implementation does it automatically
  6. Use short-lived access tokens (15-60 minutes recommended)
  7. Use longer-lived refresh tokens (7-30 days recommended)
  8. Validate tokens on every request using Sanctum middleware
  9. Implement account lockout after multiple failed login attempts
  10. 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.