In one sentence

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.

Why it matters

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 big picture

The token flow

๐Ÿ”‘Log inPOST /api/login
๐ŸŽŸ๏ธToken issuedplainTextToken
๐Ÿ“จEvery requestAuthorization: Bearer โ€ฆ
๐Ÿ›ก๏ธauth:sanctumfinds the user
The client stores the token once and sends it as a Bearer header on every protected request.
ModeBest forHow it identifies the user
API tokensMobile apps, CLI tools, other serversAuthorization: Bearer <token> header
SPA cookie authA JS frontend on the same top-level domainNormal Laravel session cookie + CSRF
Step by step

Protect the blog API

  1. Install Sanctum and prepare the User model

    If you ran php artisan install:api in the last lesson, Sanctum is already installed and its personal_access_tokens table migrated. Add the HasApiTokens trait to User.

    
              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;
                  // ...
              }
            
  2. Issue a token at login

    Check the email and password, then call createToken(). The plainTextToken is 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" }
            
  3. Guard routes with auth:sanctum

    The auth:sanctum middleware rejects any request without a valid token with 401 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." }
            
  4. 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.

  5. Log out by revoking tokens

    A token works until you delete it (or it expires, if you set expiration in config/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');
            
  6. 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 works
            

    Also list your frontend's domain in SANCTUM_STATEFUL_DOMAINS in .env.

Common mistakes

Avoid this, do that

โŒ Avoid

          return ['token' => $token->accessToken->token];
        

That's the stored hash. It won't authenticate anything.

โœ… Do

          return ['token' => $token->plainTextToken];
        

Return plainTextToken once; the client must store it safely.

โŒ Avoid

          Route::delete('/posts/{post}', [PostController::class, 'destroy']);
        

Without middleware, anyone on the internet can delete posts.

โœ… Do

          Route::delete('/posts/{post}', [PostController::class, 'destroy'])
              ->middleware('auth:sanctum');
        

Guard every write route, then authorise ownership with a policy.

๐Ÿ’ก Pro tip: always use HTTPS

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.

๐Ÿ‹๏ธ Try it yourself
  1. Add a /api/login route that returns a token, and test it with curl.
  2. Call /api/user once without the token (expect 401) and once with it.
  3. Create a token with only ['posts:read'] and make destroy return 403 for it.
  4. Add /api/logout and confirm the same token now gets 401.
  5. Open the personal_access_tokens table and notice the token column is hashed.
Recap

Key takeaways

Check yourself

Mini quiz