How to Build a Secure REST API in Laravel 11 Using Sanctum
A few months back, a client asked me to turn their existing Laravel web app into something that could also power a mobile app. Simple enough, right? Just expose some endpoints, slap on some authentication, done.

Except the first version I shipped had a problem. I used a basic token system I hacked together myself — stuff a random string in the database, check it on every request. It worked, until it didn’t. Tokens never expired, there was no way to revoke a single device without logging everyone out, and I genuinely lost a few hours debugging why a user’s session on their phone kept dying randomly (turns out I was regenerating the token on every login, which invalidated the old one while the app still had it cached).
That’s when I actually sat down and used Sanctum properly instead of reinventing it. And honestly, once it clicked, I felt a little silly for not using it from day one.
If you’re building an API for a mobile app, an SPA, or just want third-party apps to talk to your Laravel backend, this is the writeup I wish I’d had before I started.
Why Sanctum and not Passport or plain JWT?
I get this question a lot from other devs on forums and in Discord servers. Here’s the honest answer based on what I’ve actually run in production.
Passport is built for full OAuth2 — think “Login with Google” style flows where you’re issuing tokens to third-party apps you don’t control. It’s powerful, but it’s also heavier than most projects need. I used it once for a project that needed proper OAuth2 grants, and setting it up took an entire afternoon just to get the client credentials flow working correctly.
Plain JWT packages are lightweight, but you’re on your own for revocation, refresh logic, and a dozen little edge cases. I tried this route for a side project and ended up writing my own blacklist table anyway — at which point, what’s even the point of avoiding a framework tool?
Sanctum sits in the middle. It’s made specifically for two situations:
- A single-page app or mobile app that’s basically “first-party” — you own both ends.
- Simple token-based APIs where you don’t need the full OAuth2 dance.
For probably 90% of the APIs I’ve built for clients, that’s exactly the situation. So that’s what we’re doing here.
What you’ll need before starting
- Laravel 11 installed (Sanctum ships with the API starter kit now, which is a nice change from Laravel 10 where you had to install it manually every time)
- PHP 8.2 or higher
- Composer
- A tool to test API calls — I use Postman, but Insomnia or even plain
curlworks fine too
If you’re starting a brand-new project, run:
laravel new my-api --api
That --api flag is new-ish and genuinely useful. It scaffolds the project with Sanctum already wired in, instead of you doing it by hand.
If you’re adding this to an existing app (which is what I usually do), install it manually:
composer require laravel/sanctum
Then publish the config and migration:
php artisan vendor:publish --provider="Laravel\Sanctum\SanctumServiceProvider"
php artisan migrate
That migration creates the personal_access_tokens table, which is where all your API tokens actually live. Worth peeking at it once just so you know what’s happening under the hood — it’s not magic, it’s just a database table with a hashed token column.
Step 1: Prep your User model
Open up app/Models/User.php and add the trait:
use Laravel\Sanctum\HasApiTokens;
class User extends Authenticatable
{
use HasApiTokens, Notifiable;
}
This one line is what gives your user model access to methods like createToken() and tokens(). Skip this step and you’ll get a confusing “method does not exist” error later — I’ve done this more than once when copy-pasting between projects.
Step 2: Build a login endpoint that actually returns a token
Here’s where a lot of tutorials get lazy and just show you the token part without the login logic. Let’s do it properly.
In routes/api.php:
use App\Http\Controllers\AuthController;
Route::post('/login', [AuthController::class, 'login']);
Route::post('/register', [AuthController::class, 'register']);
Then in your AuthController:
public function login(Request $request)
{
$request->validate([
'email' => 'required|email',
'password' => 'required',
]);
$user = User::where('email', $request->email)->first();
if (! $user || ! Hash::check($request->password, $user->password)) {
return response()->json([
'message' => 'Invalid credentials'
], 401);
}
$token = $user->createToken('mobile-app-token')->plainTextToken;
return response()->json([
'user' => $user,
'token' => $token,
]);
}
That 'mobile-app-token' string isn’t just decoration — it’s the token’s name, and it matters more than you’d think. If your app is used from multiple devices, name the token something identifiable, like iphone-13-jane or based on a device ID you pass in from the client. Later, when a user wants to log out of “just this device,” you’ll be glad you can tell tokens apart.
Step 3: Protect your routes
Now the actual security part. In routes/api.php:
Route::middleware('auth:sanctum')->group(function () {
Route::get('/user', function (Request $request) {
return $request->user();
});
Route::apiResource('posts', PostController::class);
});
Anything inside that group now requires a valid token in the Authorization: Bearer {token} header. No token, no access. This is the part I initially got wrong — I forgot to wrap my routes in the middleware group and spent a solid twenty minutes wondering why anyone could hit my “protected” endpoints without logging in at all.
Step 4: Test it like a real client would
Fire up Postman. Hit your login endpoint, grab the token from the response, then make a request to /api/user with the header:
Authorization: Bearer 1|xxxxxxxxxxxxxxxxxxxxx
If it works, you’ll get back the user’s data. If you forget the header, or send a garbage token, you should get a clean 401. This is a good moment to actually test the failure case too — a lot of devs only test the happy path and never check what happens when the token is wrong or missing.
Step 5: Logging out (revoking tokens)
This is the part my original homemade system completely botched. With Sanctum, revoking a token is one line:
public function logout(Request $request)
{
$request->user()->currentAccessToken()->delete();
return response()->json(['message' => 'Logged out']);
}
Want to log a user out of every device? That’s just as simple:
$request->user()->tokens()->delete();
I added a “log out of all devices” button to one client’s account settings page using exactly that line, and it took maybe five minutes including the frontend button.
Token abilities: the feature almost nobody uses (but should)
Sanctum lets you scope what a token can actually do, using abilities:
$token = $user->createToken('mobile-app-token', ['posts:read'])->plainTextToken;
Then in your controller or route middleware, you check for it:
if ($request->user()->tokenCan('posts:read')) {
// allowed
}
I use this for a client whose app has both a regular user role and a limited “read-only” API integration for a partner company. Instead of building a whole separate permissions system, I just issue tokens with different abilities. Way less code, way fewer bugs.
SPA authentication is a different beast
Quick heads up if you’re building a single-page app (React, Vue, whatever) that lives on the same domain, or a subdomain, as your Laravel backend — you don’t actually want token auth at all. Sanctum has a separate “SPA mode” that uses cookies and CSRF protection instead, which is more secure for that specific setup.
This trips people up constantly because Sanctum’s name gets attached to both use cases. If your frontend and backend share a root domain, go read the SPA authentication section of the docs before you build the token flow — it’s genuinely a different setup with EnsureFrontendRequestsAreStateful middleware and cookie config, not just tokens in headers.
Mistakes I’ve made (so you don’t have to)
Not setting token expiration. By default, Sanctum tokens don’t expire. For most internal tools that’s fine, but for anything customer-facing, I now set an expiration in config/sanctum.php:
'expiration' => 60 * 24 * 7, // 7 days, in minutes
Storing the token in localStorage on a web app. I did this once early on for a web dashboard and later switched to the cookie-based SPA approach after reading more about XSS risks. For mobile apps, secure device storage (like Keychain on iOS or Keystore on Android) is the right call, not localStorage-style plain storage.
Forgetting rate limiting. Sanctum handles authentication, not throttling. I always add Laravel’s built-in throttle middleware to login and register routes:
Route::post('/login', [AuthController::class, 'login'])
->middleware('throttle:5,1');
That’s five attempts per minute. Without it, your login endpoint is basically an open invitation for brute-force attempts.
Returning the full user model without thinking about it. Early on I returned $user directly from several endpoints, which meant password hashes and other fields I didn’t want exposed were technically in the response (Laravel hides the password field by default, but other sensitive columns aren’t automatically hidden). Now I use API Resources (php artisan make:resource UserResource) to control exactly what gets returned. It’s a small extra step that’s saved me from accidentally leaking data more than once.
A quick real-world example
For that original client project, the final setup ended up being:
- Regular users get a token on login, scoped with abilities based on their subscription tier
- A partner integration gets a long-lived token with read-only abilities, generated manually through an admin panel
- All login attempts are throttled
- Tokens expire after 30 days of inactivity, refreshed automatically when the app makes a request
It’s not fancy. It’s just Sanctum, used the way it’s meant to be used, with a few extra guardrails around it. The API has been running for over a year now without a single auth-related incident, which honestly feels like a small miracle given how many ways I managed to mess up my first attempt.
If you’re on the fence about which auth package to reach for in Laravel 11, and you’re not building a full OAuth2 marketplace of third-party apps, just start with Sanctum. It covers the actual problems you’ll run into — logins, tokens, revocation, scoping — without dragging in complexity you don’t need yet. You can always layer on something heavier later if your API genuinely outgrows it, but I’d bet for most projects, it won’t.