Posted Sep 29, 2026 · 4 min read · 0 views
Building a Secure REST API in Laravel with Sanctum
If you are building a mobile app, a single page app, or a public API, you need a clean way to authenticate users. Laravel Sanctum is the simplest official option. It gives you lightweight token authentication without the weight of OAuth.
In this tutorial we will build a small REST API with registration, login, logout, and a protected resource, all secured with Sanctum.
What we are building
A simple task manager API with these endpoints:
| Method | Endpoint | Purpose |
|---|---|---|
| POST | /api/register | Create an account |
| POST | /api/login | Get an access token |
| POST | /api/logout | Revoke the current token |
| GET | /api/tasks | List the user's tasks |
| POST | /api/tasks | Create a task |
Step 1: Set up the project
Create a new Laravel project and install the API scaffolding:
composer create-project laravel/laravel task-api
cd task-api
php artisan install:api
The install:api command installs Sanctum, publishes its migration, and creates routes/api.php for you. Now configure your database in .env and run the migrations:
php artisan migrate
Make sure your User model uses the HasApiTokens trait:
use Laravel\Sanctum\HasApiTokens;
class User extends Authenticatable
{
use HasApiTokens, HasFactory, Notifiable;
}
Step 2: Build the auth controller
Create a controller:
php artisan make:controller Api/AuthController
Add the register, login, and logout methods:
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\ValidationException;
class AuthController extends Controller
{
public function register(Request $request)
{
$data = $request->validate([
'name' => 'required|string|max:255',
'email' => 'required|email|unique:users',
'password' => 'required|string|min:8|confirmed',
]);
$user = User::create([
'name' => $data['name'],
'email' => $data['email'],
'password' => Hash::make($data['password']),
]);
return response()->json([
'user' => $user,
'token' => $user->createToken('api-token')->plainTextToken,
], 201);
}
public function login(Request $request)
{
$data = $request->validate([
'email' => 'required|email',
'password' => 'required',
]);
$user = User::where('email', $data['email'])->first();
if (! $user || ! Hash::check($data['password'], $user->password)) {
throw ValidationException::withMessages([
'email' => ['The provided credentials are incorrect.'],
]);
}
return response()->json([
'user' => $user,
'token' => $user->createToken('api-token')->plainTextToken,
]);
}
public function logout(Request $request)
{
$request->user()->currentAccessToken()->delete();
return response()->json(['message' => 'Logged out']);
}
}
Notice that the plain text token is only returned once, at creation time. Laravel stores a hash of it, so you cannot retrieve it later. Tell your client developers to save it securely.
Step 3: Create a protected resource
Generate a model, migration, and controller for tasks:
php artisan make:model Task -mc --api
Edit the migration:
Schema::create('tasks', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->string('title');
$table->boolean('done')->default(false);
$table->timestamps();
});
Allow mass assignment in the model:
protected $fillable = ['title', 'done'];
public function user()
{
return $this->belongsTo(User::class);
}
Add a relationship on the User model as well:
public function tasks()
{
return $this->hasMany(Task::class);
}
Run php artisan migrate, then fill in the controller:
class TaskController extends Controller
{
public function index(Request $request)
{
return $request->user()->tasks()->latest()->paginate(15);
}
public function store(Request $request)
{
$data = $request->validate([
'title' => 'required|string|max:255',
]);
$task = $request->user()->tasks()->create($data);
return response()->json($task, 201);
}
}
Scoping queries through $request->user()->tasks() is important. It guarantees users can only see their own data.
Step 4: Define the routes
Open routes/api.php:
use App\Http\Controllers\Api\AuthController;
use App\Http\Controllers\TaskController;
Route::post('/register', [AuthController::class, 'register']);
Route::post('/login', [AuthController::class, 'login']);
Route::middleware('auth:sanctum')->group(function () {
Route::post('/logout', [AuthController::class, 'logout']);
Route::get('/tasks', [TaskController::class, 'index']);
Route::post('/tasks', [TaskController::class, 'store']);
});
Anything inside the auth:sanctum group requires a valid token.
Step 5: Test the API
Start the server:
php artisan serve
Register a user:
curl -X POST http://localhost:8000/api/register \
-H "Accept: application/json" \
-H "Content-Type: application/json" \
-d '{"name":"Rahim","email":"rahim@example.com","password":"secret123","password_confirmation":"secret123"}'
Copy the token from the response, then call the protected endpoint:
curl http://localhost:8000/api/tasks \
-H "Accept: application/json" \
-H "Authorization: Bearer YOUR_TOKEN_HERE"
Always send the Accept: application/json header. Without it, Laravel may redirect unauthenticated requests to a login page instead of returning a clean 401 JSON response.
Step 6: Harden your API
A working API is not a safe API yet. Add these protections:
- Rate limit login attempts to slow down brute force attacks:
Route::post('/login', [AuthController::class, 'login'])->middleware('throttle:5,1');
- Use token abilities when you need fine grained access:
$token = $user->createToken('mobile', ['tasks:read', 'tasks:write']);
Then check them with $request->user()->tokenCan('tasks:write').
- Set token expiration in
config/sanctum.php:
'expiration' => 60 * 24 * 7, // minutes, so 7 days
- Always use HTTPS in production. Bearer tokens sent over plain HTTP can be stolen easily.
Common mistakes to avoid
- Forgetting the
HasApiTokenstrait, which causes acreateToken() undefinederror - Returning full user models with sensitive fields, so use API Resources or
$hidden - Skipping validation because "the client already checks it"
- Not scoping queries by the authenticated user
Final thoughts
Sanctum keeps API authentication simple: create a token on login, protect routes with auth:sanctum, and revoke the token on logout. From here you can add update and delete endpoints, wrap responses in API Resources, and write feature tests with Sanctum::actingAs().
What would you like to see next: API Resources, testing, or versioning? Let us know in the comments.
Discussion (0)