Sanctum gives each API client a secret token to send with every request, so Laravel knows who is calling without sessions or cookies.
๐๏ธIt's a festival wristband. You show your ID once at the gate (log in), get a wristband (the token), and from then on staff just check the band instead of asking for ID at every stage.
APIs can't rely on browser sessions
Works for mobile apps
Phones and scripts don't keep Laravel session cookies. A token in a header works everywhere.
Fine-grained access
Abilities let one token read posts while another can also delete them.
Easy to revoke
Lost a phone? Delete that one token and the others keep working.
The token flow
| Mode | Best for | How it identifies the user |
|---|---|---|
| API tokens | Mobile apps, CLI tools, other servers | Authorization: Bearer <token> header |
| SPA cookie auth | A JS frontend on the same top-level domain | Normal Laravel session cookie + CSRF |
Protect the blog API
-
Install Sanctum and prepare the User model
If you ran
php artisan install:apiin the last lesson, Sanctum is already installed and itspersonal_access_tokenstable migrated. Add theHasApiTokenstrait toUser.namespace App\Models; use Laravel\Sanctum\HasApiTokens; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; class User extends Authenticatable { use HasApiTokens, Notifiable; // ... } -
Issue a token at login
Check the email and password, then call
createToken(). TheplainTextTokenis shown only once; the database stores just a hash of it.use App\Models\User; use Illuminate\Http\Request; use Illuminate\Support\Facades\Hash; use Illuminate\Validation\ValidationException; Route::post('/login', function (Request $request) { $request->validate([ 'email' => ['required', 'email'], 'password' => ['required'], 'device_name' => ['required'], ]); $user = User::where('email', $request->email)->first(); if (! $user || ! Hash::check($request->password, $user->password)) { throw ValidationException::withMessages(['email' => 'The credentials are incorrect.']); } $token = $user->createToken($request->device_name); return response()->json(['token' => $token->plainTextToken], 201); });curl -X POST http://127.0.0.1:8000/api/login \ -H "Accept: application/json" -H "Content-Type: application/json" \ -d '{"email":"ada@example.com","password":"password","device_name":"ada-iphone"}'{ "token": "3|kR8vXq2bL0yH7nTzP4mWc9sJdA1fEuG6iOoV5rBx" } -
Guard routes with
auth:sanctumThe
auth:sanctummiddleware rejects any request without a valid token with401 Unauthorized. Inside,$request->user()is the token's owner.use App\Http\Controllers\Api\PostController; Route::get('/posts', [PostController::class, 'index']); // public Route::middleware('auth:sanctum')->group(function () { Route::get('/user', fn (Request $request) => $request->user()); Route::apiResource('posts', PostController::class)->except('index'); });curl http://127.0.0.1:8000/api/user \ -H "Accept: application/json" \ -H "Authorization: Bearer 3|kR8vXq2bL0yH7nTzP4mWc9sJdA1fEuG6iOoV5rBx"{ "message": "Unauthenticated." } -
Limit what a token can do with abilities
Abilities (sometimes called scopes) are labels you attach when creating the token. Check them with
tokenCan().// A read-only token for a reporting script $token = $user->createToken('reports', ['posts:read']); // In a controller if (! $request->user()->tokenCan('posts:delete')) { abort(403, 'This token cannot delete posts.'); }Abilities limit the token. You still need policies to decide whether the user may touch a particular post.
-
Log out by revoking tokens
A token works until you delete it (or it expires, if you set
expirationinconfig/sanctum.php).Route::post('/logout', function (Request $request) { $request->user()->currentAccessToken()->delete(); // this device only // $request->user()->tokens()->delete(); // every device return response()->noContent(); })->middleware('auth:sanctum'); -
In brief: SPA cookie authentication
If your Vue or React frontend lives on the same top-level domain as the API, skip tokens entirely. Sanctum can use Laravel's normal session cookie, which is safer in a browser because JavaScript never holds a secret.
->withMiddleware(function (Middleware $middleware) { $middleware->statefulApi(); })axios.defaults.withCredentials = true; axios.defaults.withXSRFToken = true; await axios.get('/sanctum/csrf-cookie'); // 1. get CSRF cookie await axios.post('/login', { email, password }); // 2. normal session login const { data } = await axios.get('/api/user'); // 3. auth:sanctum just worksAlso list your frontend's domain in
SANCTUM_STATEFUL_DOMAINSin.env.
Avoid this, do that
return ['token' => $token->accessToken->token];
That's the stored hash. It won't authenticate anything.
return ['token' => $token->plainTextToken];
Return plainTextToken once; the client must store it safely.
Route::delete('/posts/{post}', [PostController::class, 'destroy']);
Without middleware, anyone on the internet can delete posts.
Route::delete('/posts/{post}', [PostController::class, 'destroy'])
->middleware('auth:sanctum');
Guard every write route, then authorise ownership with a policy.
A Bearer token is as good as a password. Send it only over HTTPS, never put it in a URL query string, and never commit it to Git.
- Add a
/api/loginroute that returns a token, and test it with curl. - Call
/api/useronce without the token (expect 401) and once with it. - Create a token with only
['posts:read']and makedestroyreturn 403 for it. - Add
/api/logoutand confirm the same token now gets 401. - Open the
personal_access_tokenstable and notice the token column is hashed.
Key takeaways
- Sanctum comes with
php artisan install:api; addHasApiTokenstoUser. $user->createToken('name', $abilities)->plainTextTokenis shown once and sent asAuthorization: Bearer โฆ.auth:sanctumprotects routes and returns401when the token is missing or invalid.- Abilities plus
tokenCan()limit what a token may do; policies still govern the user. - For a same-domain SPA, prefer Sanctum's cookie (session) auth over storing tokens in JavaScript.