Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

Building Web Apps with Laravel  Module 9 – APIs

API auth with Sanctum and Passport

Issue Sanctum tokens with abilities, understand how the hashed token column protects a leaked database, and know when to reach for Passport's OAuth2 instead.

  • Intermediate
  • 15 minutes
  • Examples run with PHP 8.5.11, Composer 2.10.3, Laravel 13.34.0 and Python 3.14.8
  • By MySmartCoPilot
Free core, Pro depth: 6 of 15 minutes free
Free: Three callers, three mechanisms, and when to pick which · install:api adds Sanctum and the token table · A token, two abilities, one guarded route · The database holds a hash, not the token. With a pass: 2 in-depth parts and 3 interview answers, about 9 minutes.

What you will learn

  • Install Sanctum with `php artisan install:api` and issue a personal access token with abilities
  • Protect an endpoint with `auth:sanctum` and the `ability` middleware, and tell its 401 from its 403
  • Explain why Sanctum stores a SHA-256 hash of the token, not the plain text
  • Choose between Sanctum tokens, Sanctum SPA cookie mode and Passport OAuth2 for a given client

Before you start

What you’ll learn · Before you start

What you will learn

  • Install Sanctum with `php artisan install:api` and issue a personal access token with abilities
  • Protect an endpoint with `auth:sanctum` and the `ability` middleware, and tell its 401 from its 403
  • Explain why Sanctum stores a SHA-256 hash of the token, not the plain text
  • Choose between Sanctum tokens, Sanctum SPA cookie mode and Passport OAuth2 for a given client

Before you start

On this page

Pathshala’s /api/v1/batches endpoint answers anyone who asks. Three callers are coming: a mobile app that signs a teacher in and keeps a token, a React admin on Pathshala’s own domain that uses the browser session, and a travel partner’s app that wants to read attendance for the parents who allow it. Laravel ships two first-party packages for them. API auth with Sanctum covers Pathshala’s own clients, with tokens or session cookies, and Passport, a full OAuth2 server, covers software another company writes on behalf of a Pathshala user.

This lesson installs Sanctum, issues tokens with short ability lists, tests the two middlewares that check them and reads the row Sanctum stores: a hash, so a leaked database hands nobody a working token.

Three callers, three mechanisms, and when to pick which

A mobile app that signs its user in and then sends a bearer token: A random string a client sends in the Authorization header, as Authorization: Bearer <token>. The server identifies the request from the token alone. with every request uses Sanctum’s personal access token: A Sanctum API token issued to one user for one named client, with a list of abilities. The client sends it as a bearer token, and deleting its row revokes it.. A React front end on the API’s top-level domain uses the browser session instead, after one request for a CSRF cookie: Sanctum’s SPA cookie mode: Sanctum's mode for a first-party single-page app on the same top-level domain as the API. The browser uses the session cookie and returns the XSRF-TOKEN value in an X-XSRF-TOKEN header, so no bearer token is needed.. A partner’s app that acts for a parent needs the parent’s agreement on a consent screen, which OAuth2: An authorization framework (RFC 6749) that lets a user allow one application to reach their data on another service. The user signs in and agrees at the authorization server, which gives the application an access token limited to the scopes agreed. provides through Passport, Laravel’s OAuth2 server.

Interactive · answer and compare Sanctum tokens, Sanctum cookie mode, Passport OAuth2 Answer the two questions. The table marks each choice and its reasons.
Who is the caller?
Does your front end share the top-level domain with the API?

Answer the questions above: the table then marks what fits your case and says why the rest does not.

Three mechanisms against four everyday concerns, with the lesson's own numbers
Mechanism Your own apps Other companies' apps for a user Setup and server surface What a leaked database shows
Sanctum token How a client calls it: Authorization: Bearer id|… Good Mobile apps and server scripts: one Bearer header, abilities checked by middleware. Poor No consent flow; a third party has no way to ask the user. Good One Composer package, one migration, one trait on the User. Good personal_access_tokens.token holds a 64-hex sha256, not the plain text.
Sanctum SPA cookie How a client calls it: the browser's XSRF-TOKEN plus laravel-session Good A React or Vue front end on the same company's top-level domain. Poor Browsers refuse the cookie across different top-level domains. Mixed statefulApi(), the stateful domains list and a GET /sanctum/csrf-cookie first. Good Sessions, not tokens: no bearer value sits in the token table.
Passport OAuth2 How a client calls it: authorization code with PKCE, then a Bearer JWT Mixed Works, but heavier than Sanctum for an app your team owns. Good authorization code with PKCE, client credentials and device grants. Poor A full OAuth2 server: endpoints, clients, scopes and the RFC 6819 threat model. Good Client secrets are stored hashed; the token table keeps ids, not the signed JWTs.
How the answers decide
  • “Who is the caller?” A third-party app acting on a user: rules out Sanctum token and Sanctum SPA cookie. A third-party app needs the user to agree on a consent screen; Passport is the only option that offers one.
  • “Who is the caller?” Our own mobile, SPA or server scripts and “Does your front end share the top-level domain with the API?” Yes, same top-level domain: adds a caveat to Sanctum SPA cookie. A first-party SPA on the same top-level domain fits cookie mode and skips the Bearer plumbing.
  • “Who is the caller?” Our own mobile, SPA or server scripts and “Does your front end share the top-level domain with the API?” No, or no front end: rules out Sanctum SPA cookie and Passport OAuth2. A mobile app, a CLI or a server script is the exact fit for a Sanctum token with abilities.

Cookie mode needs $middleware->statefulApi() in bootstrap/app.php and the front end’s domain in Sanctum’s list of stateful domain: A domain in the stateful list of config/sanctum.php. Sanctum treats a request from it as a browser session, with cookies and CSRF checks, instead of a token request; SPA cookie mode depends on this list.:

SPA cookie mode: the CSRF cookie, then a POST without it Python · spa_cookie_session.py
from session import run

# SPA cookie mode runs through php artisan serve so curl can show the real Set-Cookie headers of /sanctum/csrf-cookie
# and the 419 that a POST without an X-XSRF-TOKEN receives. The server runs in the background and is stopped before
# the session ends. APP_DEBUG=false keeps the 419 body short (one JSON key).
run(
    """
export APP_DEBUG=false
composer require laravel/sanctum --no-interaction --quiet
php artisan vendor:publish --tag=sanctum-migrations --no-interaction
php artisan migrate --no-interaction
php artisan serve --host=127.0.0.1 --port=8321 --no-reload > server.log 2>&1 & echo $! > server.pid
until curl -s -o /dev/null http://127.0.0.1:8321/up; do sleep 0.3; done
# One: the SPA fetches /sanctum/csrf-cookie. Laravel answers with two Set-Cookie headers: XSRF-TOKEN (read by
# JavaScript) and laravel-session (the session identifier, which a script cannot read).
curl -s -o /dev/null -D - -c cookies.txt http://127.0.0.1:8321/sanctum/csrf-cookie
# Two: a POST that forgets to echo X-XSRF-TOKEN back. The stateful api middleware answers 419, with the headers
# shown here and a one-key JSON body.
curl -s -o /dev/null -D - -b cookies.txt -X POST http://127.0.0.1:8321/api/v1/csrf-probe -H 'Accept: application/json' -H 'Origin: http://127.0.0.1' -H 'Referer: http://127.0.0.1/'
# Three: stop the server; the next command prints what it logged.
kill "$(cat server.pid)" 2>/dev/null; sleep 0.3
cat server.log
""",
    app=True,
    overlay=("sanctum-probe",),
)

Output

$ export APP_DEBUG=false
$ composer require laravel/sanctum --no-interaction --quiet
$ php artisan vendor:publish --tag=sanctum-migrations --no-interaction
   INFO  Publishing [sanctum-migrations] assets.

  Copying directory [vendor/laravel/sanctum/database/migrations] to [database/migrations]  DONE
$ php artisan migrate --no-interaction
   INFO  Running migrations.

  2026_01_01_000000_create_batches_table ................................ DONE
  YYYY_MM_DD_HHMMSS_create_personal_access_tokens_table ................. DONE
$ php artisan serve --host=127.0.0.1 --port=8321 --no-reload > server.log 2>&1 & echo $! > server.pid
$ until curl -s -o /dev/null http://127.0.0.1:8321/up; do sleep 0.3; done
# One: the SPA fetches /sanctum/csrf-cookie. Laravel answers with two Set-Cookie headers: XSRF-TOKEN (read by
# JavaScript) and laravel-session (the session identifier, which a script cannot read).
$ curl -s -o /dev/null -D - -c cookies.txt http://127.0.0.1:8321/sanctum/csrf-cookie
HTTP/1.1 204 No Content
Host: 127.0.0.1:8321
Connection: close
X-Powered-By: PHP/8.5.11
Cache-Control: no-cache, private
Date: …
Access-Control-Allow-Origin: *
Set-Cookie: XSRF-TOKEN=…; expires=…; Max-Age=…; path=/; samesite=lax
Set-Cookie: laravel-session=…; expires=…; Max-Age=…; path=/; httponly; samesite=lax
# Two: a POST that forgets to echo X-XSRF-TOKEN back. The stateful api middleware answers 419, with the headers
# shown here and a one-key JSON body.
$ curl -s -o /dev/null -D - -b cookies.txt -X POST http://127.0.0.1:8321/api/v1/csrf-probe -H 'Accept: application/json' -H 'Origin: http://127.0.0.1' -H 'Referer: http://127.0.0.1/'
HTTP/1.1 419 unknown status
Host: 127.0.0.1:8321
Connection: close
X-Powered-By: PHP/8.5.11
Cache-Control: no-cache, private
Date: …
Content-Type: application/json
Access-Control-Allow-Origin: *
Set-Cookie: laravel-session=…; expires=…; Max-Age=…; path=/; httponly; samesite=lax
# Three: stop the server; the next command prints what it logged.
$ kill "$(cat server.pid)" 2>/dev/null; sleep 0.3
$ cat server.log
   INFO  Server running on [http://127.0.0.1:8321].

  Press Ctrl+C to stop the server

  … /up ............................................... ~ …
  … /sanctum/csrf-cookie .............................. ~ …
  … /api/v1/csrf-probe ................................ ~ …

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 spa_cookie_session.py

/sanctum/csrf-cookie sets XSRF-TOKEN, which the front end’s script reads, and laravel-session, which no script can read; a POST that does not send the token back in X-XSRF-TOKEN gets a 419. Passport has a grant for each kind of outside caller. The authorization code grant with PKCE lets a partner’s app act for a user who agrees. The client credentials grant lets a service act for itself, with no user. The device grant signs in a TV or kiosk through the user’s phone.

Checkpoint

Check: Sanctum tokens, cookies and Passport

  1. One question Pathshala wants to let a travel-safety company read a child's attendance, with the parent's agreement. Which mechanism fits?

    Choose one answer.

    Show the answer: One question

    Answer: Passport, with the authorization code grant and PKCE

    "A third party on behalf of a user" is the case OAuth2 exists for. Passport is Laravel's OAuth2 server: the parent agrees on the authorization screen, and the partner's app receives a token tied to that parent.

install:api adds Sanctum and the token table

php artisan install:api prepares a Laravel 13 project for an API in one command. It requires laravel/sanctum with Composer, publishes Sanctum’s configuration file and its personal_access_tokens migration, copies a routes file to routes/api.php, adds that file to withRouting() in bootstrap/app.php and runs the migrations:

install:api on a fresh Laravel 13 project Python · install_sanctum_session.py
from session import run

# install:api on a fresh Laravel 13 project: Composer adds laravel/sanctum, Sanctum's config and token migration are
# published, routes/api.php is written and registered in bootstrap/app.php, and the migrations run (--no-interaction
# takes the prompt's default answer, yes, so the later migrate finds nothing to do).
run(
    """
php artisan install:api --no-interaction
cat routes/api.php
sed -n '1,30p' bootstrap/app.php
ls config
ls database/migrations
php artisan migrate --no-interaction
""",
    app=True,
)

Output

$ php artisan install:api --no-interaction
./composer.json has been updated
Running composer update laravel/sanctum --with-all-dependencies
Loading composer repositories with package information
Updating dependencies
Lock file operations: 1 install, 4 updates, 0 removals
  - Upgrading guzzlehttp/guzzle (8.2.0 => 8.3.0)
  - Upgrading guzzlehttp/promises (3.0.2 => 3.1.0)
  - Upgrading guzzlehttp/psr7 (3.1.0 => 3.2.0)
  - Locking laravel/sanctum (v4.3.3)
  - Upgrading league/commonmark (2.10.3 => 2.10.4)
Writing lock file
Installing dependencies from lock file (including require-dev)
Package operations: 1 install, 4 updates, 0 removals
  - Upgrading guzzlehttp/psr7 (3.1.0 => 3.2.0): Extracting archive
  - Upgrading guzzlehttp/promises (3.0.2 => 3.1.0): Extracting archive
  - Upgrading guzzlehttp/guzzle (8.2.0 => 8.3.0): Extracting archive
  … and 1 more lines like these
  - Installing laravel/sanctum (v4.3.3): Extracting archive
Generating optimized autoload files
> Illuminate\Foundation\ComposerScripts::postAutoloadDump
> @php artisan package:discover --ansi

   INFO  Discovering packages.

  laravel/pail .......................................................... DONE
  laravel/pao ........................................................... DONE
  laravel/sanctum ....................................................... DONE
  laravel/tinker ........................................................ DONE
  nesbot/carbon ......................................................... DONE
  nunomaduro/collision .................................................. DONE
  nunomaduro/termwind ................................................... DONE

79 packages you are using are looking for funding.
Use the `composer fund` command to find out more!
> @php artisan vendor:publish --tag=laravel-assets --ansi --force

   INFO  No publishable resources for tag [laravel-assets].

No security vulnerability advisories found.

   INFO  Published API routes file.

   INFO  Running migrations.

  YYYY_MM_DD_HHMMSS_create_personal_access_tokens_table ................. DONE


   INFO  API scaffolding installed. Please add the [Laravel\Sanctum\HasApiTokens] trait to your User model.
$ cat routes/api.php
<?php

use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;

Route::get('/user', function (Request $request) {
    return $request->user();
})->middleware('auth:sanctum');
$ sed -n '1,30p' bootstrap/app.php
<?php

use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;
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): void {
        //
    })
    ->withExceptions(function (Exceptions $exceptions): void {
        $exceptions->shouldRenderJsonWhen(
            fn (Request $request) => $request->is('api/*') || $request->expectsJson(),
        );
    })->create();
$ ls config
app.php
auth.php
cache.php
database.php
filesystems.php
logging.php
mail.php
queue.php
sanctum.php
services.php
session.php
$ ls database/migrations
0001_01_01_000000_create_users_table.php
0001_01_01_000001_create_cache_table.php
0001_01_01_000002_create_jobs_table.php
YYYY_MM_DD_HHMMSS_create_personal_access_tokens_table.php
$ php artisan migrate --no-interaction
   INFO  Nothing to migrate.

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 install_sanctum_session.py

The run installs Sanctum 4.3.3, prints “Published API routes file.” and migrates the token table, and config now holds sanctum.php. With --no-interaction the command answers its own migration question with the default, yes. The last line is a task for you: add the Laravel\Sanctum\HasApiTokens trait to your User model, which is where createToken(), tokens() and currentAccessToken() come from.

Key idea

install:api is the whole API set-up in one command; adding HasApiTokens to User stays your edit.

Checkpoint

Check: install:api installs Sanctum

  1. One question Which of these does php artisan install:api --no-interaction do, from the recorded run?

    Choose every answer that is right.

    Show the answer: One question

    Answer:

    • Publishes config/sanctum.php
    • Runs composer require laravel/sanctum
    • Publishes Sanctum's personal_access_tokens migration and runs it
    • Copies the API routes file into routes/api.php and registers it in bootstrap/app.php

    install:api is Composer, the config and migration published, the routes file written and registered, and the migrations run. The trait on the User model is the one thing it tells you to do yourself.

A token, two abilities, one guarded route

createToken($name, $abilities, $expiresAt) makes a token; the last argument is optional. A token ability: A string on a Sanctum token that names an action the token may take, such as fees:read. The ability and abilities middleware refuse a request whose token lacks what the route requires. is a plain string such as fees:read. The probe’s bootstrap/app.php aliases Sanctum’s two middlewares: ability accepts a token with any one of the listed abilities, and abilities needs all of them. The probe issues two tokens to one user and revokes one:

Two tokens, two abilities, one revoke Python · sanctum_tokens_session.py
from session import run

# One user, two tokens with different abilities: one for the mobile app (fees:read) and one for the back-office
# (fees:write). The script prints each token's row and then revokes the mobile one, so the row count of
# personal_access_tokens falls by one.
run(
    """
composer require laravel/sanctum --no-interaction --quiet
php artisan vendor:publish --tag=sanctum-migrations --no-interaction
php artisan migrate --no-interaction
php issue.php priya@pathshala.example mobile fees:read --out tok-mobile.txt
php issue.php priya@pathshala.example back-office fees:write --out tok-back.txt
php list.php priya@pathshala.example
php revoke.php priya@pathshala.example mobile
""",
    app=True,
    overlay=("sanctum-probe",),
)

Output

$ composer require laravel/sanctum --no-interaction --quiet
$ php artisan vendor:publish --tag=sanctum-migrations --no-interaction
   INFO  Publishing [sanctum-migrations] assets.

  Copying directory [vendor/laravel/sanctum/database/migrations] to [database/migrations]  DONE
$ php artisan migrate --no-interaction
   INFO  Running migrations.

  2026_01_01_000000_create_batches_table ................................ DONE
  YYYY_MM_DD_HHMMSS_create_personal_access_tokens_table ................. DONE
$ php issue.php priya@pathshala.example mobile fees:read --out tok-mobile.txt
{
    "user_id": 1,
    "token_id": 1,
    "name": "mobile",
    "abilities": [
        "fees:read"
    ],
    "plain_text_prefix": "1|\u2026",
    "plain_text_length": 50
}
$ php issue.php priya@pathshala.example back-office fees:write --out tok-back.txt
{
    "user_id": 1,
    "token_id": 2,
    "name": "back-office",
    "abilities": [
        "fees:write"
    ],
    "plain_text_prefix": "2|\u2026",
    "plain_text_length": 50
}
$ php list.php priya@pathshala.example
{"id":1,"name":"mobile","abilities":["fees:read"],"last_used_at":null}
{"id":2,"name":"back-office","abilities":["fees:write"],"last_used_at":null}
$ php revoke.php priya@pathshala.example mobile
deleted 1 token(s)
personal_access_tokens remaining = 1

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 sanctum_tokens_session.py

API auth with Sanctum pairs auth:sanctum with an ability on each route:

routes/api.php of the probe PHP · sanctum-probe/routes/api.php
<?php

// routes/api.php for the Sanctum probe:
//   * /api/v1/batches: a read route that needs the ability fees:read and a write route that needs fees:write.
//   * /api/v1/ping and /api/v1/me: any valid token (or a signed-in SPA session) is enough.
//   * /api/v1/sync: also rate-limited by the api limiter of AppServiceProvider, one bucket per token.
//   * /api/v1/csrf-probe: a POST that expects an XSRF-TOKEN cookie, used by the SPA session to show the 419.
use App\Http\Controllers\Api\V1\BatchController;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;

Route::prefix('v1')->group(function () {
    Route::middleware(['auth:sanctum', 'ability:fees:read'])->get('/batches', [BatchController::class, 'index']);
    Route::middleware(['auth:sanctum', 'ability:fees:write'])->post('/batches', [BatchController::class, 'store']);
    Route::middleware('auth:sanctum')->get('/ping', fn () => response()->json(['ok' => true]));
    Route::middleware(['auth:sanctum', 'throttle:api'])->get('/sync', fn () => response()->json(['ok' => true]));
    Route::middleware('auth:sanctum')->get('/me', fn (Request $request) => response()->json([
        'id' => $request->user()->id,
        'email' => $request->user()->email,
    ]));
    // A SPA write that reads the session; without a valid XSRF-TOKEN this answers 419.
    Route::middleware(['web'])->post('/csrf-probe', fn () => response()->json(['ok' => true]));
});

Four requests follow, three with a token and one without:

auth:sanctum, then the ability check, with and without the right token Python · ability_guard_session.py
from session import run

# Two tokens, four probes. The lesson talks about three states, so each case has one probe:
#   * no token: 401 at every /api/v1/* route the auth:sanctum guard protects.
#   * mobile token (fees:read): 200 on GET /batches, 403 on POST /batches (ability:fees:write refuses).
#   * back-office token (fees:write): 403 on GET /batches (the mirror).
# ask.php reads the token from a file so the recorded output does not depend on the random bytes Sanctum generates.
run(
    """
composer require laravel/sanctum --no-interaction --quiet
php artisan vendor:publish --tag=sanctum-migrations --no-interaction
php artisan migrate --no-interaction
php issue.php priya@pathshala.example mobile fees:read --out tok-mobile.txt
php issue.php priya@pathshala.example back-office fees:write --out tok-back.txt
# Without any token.
php ask.php GET /api/v1/batches
# Mobile token on the read route (200) and on the write route (403).
php ask.php GET /api/v1/batches --bearer-file tok-mobile.txt
php ask.php POST /api/v1/batches --bearer-file tok-mobile.txt
# Back-office token on the read route (the mirror image).
php ask.php GET /api/v1/batches --bearer-file tok-back.txt
""",
    app=True,
    overlay=("sanctum-probe",),
)

Output

$ composer require laravel/sanctum --no-interaction --quiet
$ php artisan vendor:publish --tag=sanctum-migrations --no-interaction
   INFO  Publishing [sanctum-migrations] assets.

  Copying directory [vendor/laravel/sanctum/database/migrations] to [database/migrations]  DONE
$ php artisan migrate --no-interaction
   INFO  Running migrations.

  2026_01_01_000000_create_batches_table ................................ DONE
  YYYY_MM_DD_HHMMSS_create_personal_access_tokens_table ................. DONE
$ php issue.php priya@pathshala.example mobile fees:read --out tok-mobile.txt
{
    "user_id": 1,
    "token_id": 1,
    "name": "mobile",
    "abilities": [
        "fees:read"
    ],
    "plain_text_prefix": "1|\u2026",
    "plain_text_length": 50
}
$ php issue.php priya@pathshala.example back-office fees:write --out tok-back.txt
{
    "user_id": 1,
    "token_id": 2,
    "name": "back-office",
    "abilities": [
        "fees:write"
    ],
    "plain_text_prefix": "2|\u2026",
    "plain_text_length": 50
}
# Without any token.
$ php ask.php GET /api/v1/batches
GET /api/v1/batches  →  401
{
    "message": "Unauthenticated."
}
---
# Mobile token on the read route (200) and on the write route (403).
$ php ask.php GET /api/v1/batches --bearer-file tok-mobile.txt
GET /api/v1/batches  →  200
{
    "data": []
}
---
$ php ask.php POST /api/v1/batches --bearer-file tok-mobile.txt
POST /api/v1/batches  →  403
{
    "message": "Invalid ability provided."
}
---
# Back-office token on the read route (the mirror image).
$ php ask.php GET /api/v1/batches --bearer-file tok-back.txt
GET /api/v1/batches  →  403
{
    "message": "Invalid ability provided."
}
---

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 ability_guard_session.py

Without a token, auth:sanctum answers 401 with “Unauthenticated.”. The mobile token gets a 200 on the read route and a 403, “Invalid ability provided.”, on the write route, because ability:fees:write refuses a token that does not list fees:write. The back-office token is the mirror image. A 401 tells the client to sign in again; a 403 tells it that signing in again will not help, which is how RFC 6750 separates an invalid token from one with too little scope.

Interactive · stage by stage A request with a bearer token: guard, hash lookup, ability, controller Follow the request from the Bearer header to the controller or to a 401 or 403.
What Sanctum does with one Bearer request, step 7 of 7. Showing Request arrives, Reads id and tail, Row loaded by id, Hash compared (constant-time match), Expiry checked, Ability checked (fees:read is listed), Controller runs, 401 refused, 403 refused. In focus: Controller runs.no headerhash differsexpired, revokedability missingRequest arrivesBearer id|tailReads id and tailsplit on |Row loaded by idtoken tableHash comparedconstant-time matchExpiry checkedexpired: refusedAbility checkedfees:read is listedController runsthe response401 refusedauth:sanctum403 refusedability refused

The drawing shows the whole scene; its 7 steps are listed below.

Every step as text
  1. The request arrives. A client sends GET /api/v1/batches with Authorization Bearer id|<48-char tail>. The api middleware group runs, then the route's own auth:sanctum middleware.
  2. Sanctum reads the id. The guard splits the Bearer value on the first | to get the id and the tail; a missing header is a 401 before any database read.
  3. One row is loaded. Sanctum reads personal_access_tokens WHERE id = <that id>. The query returns at most one row, which keeps the step a constant cost whatever the table size.
  4. Hashes are compared. The guard computes hash('sha256', $tail) and compares with row.token in constant time. A wrong tail answers 401 and never reaches the controller.
  5. Expiry is checked. The guard refuses a token that has expired, and for a token it accepts it writes last_used_at.
  6. Abilities decide 200 or 403. The ability middleware calls tokenCan() for the route's ability. A read token on a write route lacks fees:write, so the answer is 403 with "Invalid ability provided.".
  7. The controller runs. $request->user() is the owning user and $request->user()->currentAccessToken() is the row. The route runs normally and returns its JSON.

Checkpoint

Check: Guards and abilities

  1. Question 1 of 2 A request to GET /api/v1/batches arrives with Authorization: Bearer <token>, where the token has the abilities ['fees:write']. The route is guarded by auth:sanctum and ability:fees:read. What does the recorded run show?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: 403 Invalid ability provided.

    The two middlewares answer two different questions. auth:sanctum answers "who are you?" and the ability middleware answers "may this token do this?".

  2. Question 2 of 2 Which middleware alias in the probe's bootstrap/app.php points at Laravel\Sanctum\Http\Middleware\CheckForAnyAbility?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: ability

    ability (any) and abilities (all) are Sanctum's two middlewares. Which you pick depends on whether a token with any one of the listed abilities qualifies, or only a token that carries every one.

The database holds a hash, not the token

In a leaked database, personal_access_tokens is a poor prize: the token column holds the hashed token: The SHA-256 digest of a Sanctum token's secret part, stored as 64 hex characters in the personal_access_tokens table. Sanctum hashes the token each request presents and compares the two; the plain text is never stored., a 64-character SHA-256 digest, and the plain text is shown once, when the token is made. The probe recomputes the hash in PHP:

The row Sanctum writes, and hash('sha256', $tail) beside it Python · token_hash_session.py
from session import run

# Shows that Sanctum stores a SHA-256 hash of the token in the `token` column of personal_access_tokens, not the
# plain-text value. The script issues one token, reads the row and recomputes hash('sha256', $plain_tail) in PHP; the
# two match. The plain-text value is only shown by shape and length so the record stays reproducible.
run(
    """
composer require laravel/sanctum --no-interaction --quiet
php artisan vendor:publish --tag=sanctum-migrations --no-interaction
php artisan migrate --no-interaction
php prove-hash.php
""",
    app=True,
    overlay=("sanctum-probe",),
)

Output

$ composer require laravel/sanctum --no-interaction --quiet
$ php artisan vendor:publish --tag=sanctum-migrations --no-interaction
   INFO  Publishing [sanctum-migrations] assets.

  Copying directory [vendor/laravel/sanctum/database/migrations] to [database/migrations]  DONE
$ php artisan migrate --no-interaction
   INFO  Running migrations.

  2026_01_01_000000_create_batches_table ................................ DONE
  YYYY_MM_DD_HHMMSS_create_personal_access_tokens_table ................. DONE
$ php prove-hash.php
plain_text_token (shown once) = 1|…(length 50)
plain_text_tail length         = 48
tail = 40 random + crc32b(40)  : true
row.token length               = 64
row.name                       = mobile
row.abilities                  = ["fees:read"]
hash("sha256", tail) == row.token : true
row.token is hex only          : true

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 token_hash_session.py

The probe’s plain-text token has 50 characters: the row id 1, a | and a 48-character tail made of 40 random characters and an 8-character checksum of them. The token column holds 64 hex characters, and hash('sha256', $tail) equals it. Someone who copies the table learns every token’s id, name and abilities, but not one value that would pass auth:sanctum. A fleet of tokens also needs an expiry, a scheduled prune of expired rows and a rate limit counted per token.

Interactive · message by message Token lifecycle: issue, store the hash, present, check, revoke, prune Step through a token from the moment it is issued to the moment the schedule prunes it.
One token, six steps from createToken to prune, step 6 of 6. Messages so far: Admin tool to Sanctum: createToken('mobile', …); Sanctum to Admin tool: plainTextToken (shown once); Admin tool to Mobile app: plain-text token; Mobile app to Sanctum: GET /api/v1/batches + Bearer; Sanctum to Mobile app: 200 or 403.Admin toolissuesSanctumrow deletedMobile appin keychaincreateToken('mobile', …)plainTextToken (shown once)plain-text tokenGET /api/v1/batches + Bearer200 or 403

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. Issue the token. The admin calls $user->createToken('mobile', ['fees:read']). Sanctum makes a 48-character tail, 40 random characters and a checksum, and returns id|tail.
  2. Store the hash, show it once. Sanctum writes a row with token = hash('sha256', $tail), 64 hex characters. The plain text is only ever in the return value.
  3. Client keeps the plain text. The mobile app keeps the string in the platform's secure storage. The server does not keep it; a client that loses it has to be issued a new one.
  4. The client presents the token. Every API request carries Authorization: Bearer <plain-text>. Sanctum reads the id out of the string, loads the row, and compares hash('sha256', $tailFromHeader) to row.token.
  5. Ability check: 200 or 403. The route runs when tokenCan('fees:read') is true. A write route with ability:fees:write would answer 403 for this same token.
  6. Revoke, then prune on schedule. On "log out of this device" the row is deleted. A scheduled sanctum:prune-expired later deletes expired rows.

Checkpoint

Check: The hashed-token column

  1. Question 1 of 2 In the recorded hash probe, how many hexadecimal characters are in the token column of a Sanctum personal_access_tokens row?

    Type a number.

    A hint

    SHA-256 produces 32 bytes, printed as lower-case hex.

    Show the answer: Question 1 of 2

    Answer: 64 characters

    SHA-256 gives 32 bytes, which print as 64 hexadecimal characters. The recorded run confirms it: row.token length = 64 and row.token is hex only : true.

  2. Question 2 of 2 A backup of the production database leaks, and the attacker reads personal_access_tokens. Which of these can they do with the rows alone?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Read the ids, the names and the abilities of every token

    This is why Sanctum stores a hash. A reader of the table learns which tokens exist and what they may do, but not a bearer value that works.

Expiry, revocation, pruning and rate limiting a token at scale Pro: opens with a Pro, Premium or Ultimate pass

A token that never expires, a table that only grows and a rate limit shared by a whole office are three ways a working Sanctum set-up goes wrong at scale. This part sets an expiry in two ways, prunes the table, revokes a leaked token and gives every token its own bucket, and one recorded test run checks all of it.

In this part:

  • Two expiry clocks, and the sooner one wins
  • Pruning keeps the table to the rows that still matter
  • “Sign out every other device” is one query
  • One bucket per token, not one per office
  • The failure drill: a token pasted into a ticket

4 runnable examples (PHP, Python) · about 5 minutes

Opening this part with your pass…

This part opens with any MySmartCoPilot pass.

  • Pro pass · 1 week₹79$2.99
  • 30 days₹179$5.99
  • 1 year₹1,699$45

One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.

It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).

Authorization code with PKCE, client credentials, device authorization, and the OAuth2 server you now own Pro: opens with a Pro, Premium or Ultimate pass

Passport is a full OAuth2 server: Sanctum covers the apps Pathshala owns, and Passport the ones it does not. This part walks through the three grants that integrations end up using, then weighs what running the server costs against a plain Sanctum token.

In this part:

  • Authorization code with PKCE, for an app that acts for a user
  • Client credentials, for a service that acts for itself
  • Device authorization, for a TV or a kiosk
  • The cost: an OAuth2 server in your repository
  • The two-sentence check before you reach for Passport

about 6 minutes

Opening this part with your pass…Pro part. It opens with the same pass as the part above.

Practise

Use your Pathshala project:

  1. Run php artisan install:api --no-interaction; read routes/api.php, config/sanctum.php and the change to bootstrap/app.php, and add HasApiTokens to your User model.
  2. Build GET /api/v1/batches guarded by auth:sanctum and ability:fees:read, and POST /api/v1/batches guarded by ability:fees:write. Issue two tokens and probe both routes.
  3. Read personal_access_tokens.token for one token. Compute hash('sha256', $tail) with php -r and confirm the match.
  4. Write a feature test that sends one request without a token and one with each token, and asserts 401, 200 and 403.

Practice that adapts to you

Practise this lesson

8 questions about 6 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.

  1. Question 1 of 8 What does install:api do first on a fresh Laravel 13 project?

    Choose one answer.

    Show the answer: Question 1 of 8

    Answer: It runs composer require laravel/sanctum

    Composer first, then Sanctum's config and migration are published, then routes/api.php is copied and registered, and the migrations run last. The recorded run prints the Composer lines, "Published API routes file." and "Running migrations" in that order.

  2. Question 2 of 8 You run install:api, then call $user->createToken('mobile', ['fees:read']) in tinker, and PHP says the method does not exist. What did you forget?

    Choose one answer.

    Show the answer: Question 2 of 8

    Answer: Adding Laravel\Sanctum\HasApiTokens to the User model

    install:api ends with the reminder "Please add the [Laravel\Sanctum\HasApiTokens] trait to your User model.", and that line is the fix.

  3. Question 3 of 8 A route reads middleware('abilities:fees:read,fees:write'). Which abilities does a token need to pass?

    Choose one answer.

    Show the answer: Question 3 of 8

    Answer: Both fees:read and fees:write

    Sanctum ships two middlewares: ability for "any of these" and abilities for "all of these". The plural check is the strict one.

  4. Question 4 of 8 What abilities does a token get from createToken('mobile'), with no abilities list?

    Choose one answer.

    Show the answer: Question 4 of 8

    Answer: One ability named *

    The probe's issue.php always passes an explicit list, as production code should: a wildcard token has no scope and makes the ability middleware pointless.

  5. Question 5 of 8 What is written to personal_access_tokens.token?

    Choose one answer.

    Show the answer: Question 5 of 8

    Answer: The SHA-256 hex digest of the token's 48-character tail

    SHA-256 of the tail. The probe's prove-hash.php reads the row and recomputes the digest to show that the two match.

  6. Question 6 of 8 Why does Sanctum write last_used_at on every successful request?

    Choose one answer.

    Show the answer: Question 6 of 8

    Answer: So that an operator can see which tokens are still in use before revoking any

    last_used_at answers "which tokens were used this week?" with one query, and a list of tokens unused for months is where safe revocations start.

  7. Question 7 of 8 Pathshala's React admin runs on https://admin.pathshala.example and calls https://api.pathshala.example. Which Sanctum mode fits, and why?
    Show the answer: Question 7 of 8

    Answer: Cookie mode, because both subdomains share the top-level domain pathshala.example

    Cookie mode is for first-party SPAs on the API's top-level domain. The browser keeps the session cookie, the SPA reads XSRF-TOKEN and sends it back in X-XSRF-TOKEN, and statefulApi() lets the api routes read the session.

  8. Question 8 of 8 In the recorded SPA probe, which two cookies does GET /sanctum/csrf-cookie set?
    Show the answer: Question 8 of 8

    Answer:

    • laravel-session
    • XSRF-TOKEN

    Two cookies: one the browser's script reads for the CSRF check, and one only the server reads for the session. The recorded output shows both Set-Cookie lines.

Interview questions

Warm-up: what is the string createToken()->plainTextToken returns, exactly?

The value is "<id>|<tail>": the row id, a pipe and a 48-character tail, 40 random characters plus an 8-character checksum. Sanctum stores only hash('sha256', $tail), so this return value is the one chance to read the token, and a mobile app keeps it in the platform’s secure storage.

Three longer questions come from this lesson:

  1. Fresher to mid

    An API route is guarded by auth:sanctum and ability:fees:read. Three requests arrive: one with no token, one with a valid token whose abilities are ['fees:write'] and one with a valid token whose abilities are ['fees:read']. What status code does each receive, and which middleware answered?

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. Mid to senior

    A copy of Pathshala's production database has leaked. Explain what someone who reads the personal_access_tokens table can and cannot do with it alone, and what you still revoke or rotate.

    Model answer, Pro. First step and follow-ups

  3. Mid to senior

    Three callers need Pathshala's API: a travel-safety company's app that reads a child's attendance with the parent's agreement, Pathshala's own nightly job that posts to /api/v1/reconcile, and Pathshala's React admin on admin.pathshala.example. Choose the authentication for each and give the reason in one sentence.

    Model answer, Pro. First step and follow-ups

Flashcards · 6 cards

Recall it, then check

Say the answer to yourself before you look: recalling it is what makes it stick.

  1. Which mechanism fits each of Pathshala's three API callers?

    Its own mobile app: a Sanctum personal access token sent as a bearer token. Its React front end on its own domain: Sanctum's SPA cookie mode with the browser session. A partner's app acting for a parent: OAuth2 through Passport, with the parent's consent.

  2. What does Sanctum's SPA cookie mode need, and what does /sanctum/csrf-cookie set?

    statefulApi() in bootstrap/app.php and the front end's domain in Sanctum's stateful domains. The endpoint sets XSRF-TOKEN, which the front end reads, and the session cookie, which no script can read; a POST without the token in X-XSRF-TOKEN gets a 419.

  3. Which Passport grant fits which outside caller?

    The authorization code grant with PKCE for a partner's app that acts for a user who agrees; client credentials for a service acting for itself, with no user; the device grant for a TV or kiosk signed in through the user's phone.

  4. How do Sanctum's ability and abilities middleware differ?

    A token ability is a plain string such as fees:read given to createToken. ability accepts a token with any one of the listed abilities; abilities needs all of them.

  5. What do auth:sanctum and the ability middleware answer for a missing token and for a token without the right ability?

    No token: 401 Unauthenticated, which tells the client to sign in again. A token without the ability: 403 Invalid ability provided, because signing in again will not help, the way RFC 6750 separates an invalid token from one with too little scope.

  6. What does Sanctum store in personal_access_tokens, and what does a leaked table give away?

    The plain-text token, shown once, is the row id, a pipe and a 48-character tail; the token column holds the SHA-256 hash of that tail, 64 hex characters. A copy of the table reveals ids, names and abilities, but no value that passes auth:sanctum.

Key takeaways

  • Sanctum covers Pathshala’s own mobile and SPA clients; Passport is the OAuth2 server for another company’s app acting on a user’s behalf.
  • php artisan install:api requires Sanctum, publishes its config and token migration, registers routes/api.php and migrates.
  • createToken() returns the plain text once, and the token column stores the SHA-256 hash of its 48-character tail.
  • auth:sanctum answers 401 for a missing or unknown token, and the ability middleware answers 403 for a token without the ability the route needs.

Terms in this lesson

bearer token
A random string a client sends in the Authorization header, as Authorization: Bearer <token>. The server identifies the request from the token alone.
client credentials grant
The OAuth2 grant (RFC 6749 section 4.4) in which a confidential client authenticates with its own id and secret and receives an access token, with no user involved. It fits a service that acts for itself.
device authorization grant
The OAuth2 grant (RFC 8628) for a device with no browser or keyboard: the device shows a short code, and the user enters it on a phone or computer to allow the device.
hashed token
The SHA-256 digest of a Sanctum token's secret part, stored as 64 hex characters in the personal_access_tokens table. Sanctum hashes the token each request presents and compares the two; the plain text is never stored.
OAuth2
An authorization framework (RFC 6749) that lets a user allow one application to reach their data on another service. The user signs in and agrees at the authorization server, which gives the application an access token limited to the scopes agreed.
OAuth2 authorization code grant
The OAuth2 grant (RFC 6749 section 4.1) in which a client sends the user to the authorization server, receives a short-lived code and exchanges it for an access token. PKCE (RFC 7636) binds the exchange to the client that started it.
personal access token
A Sanctum API token issued to one user for one named client, with a list of abilities. The client sends it as a bearer token, and deleting its row revokes it.
PKCE
Proof Key for Code Exchange (RFC 7636). The client sends a hash of a random verifier with the authorization request and the verifier itself with the token request, and the server refuses the exchange when they do not match.
SPA cookie mode
Sanctum's mode for a first-party single-page app on the same top-level domain as the API. The browser uses the session cookie and returns the XSRF-TOKEN value in an X-XSRF-TOKEN header, so no bearer token is needed.
stateful domain
A domain in the stateful list of config/sanctum.php. Sanctum treats a request from it as a browser session, with cookies and CSRF checks, instead of a token request; SPA cookie mode depends on this list.
token ability
A string on a Sanctum token that names an action the token may take, such as fees:read. The ability and abilities middleware refuse a request whose token lacks what the route requires.
token pruning
Deleting expired API tokens on a schedule, so that the token table keeps only rows that can still authenticate or that an investigation may still need.

References

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.