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.
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.
Answer the questions above: the table then marks what fits your case and says why the rest does not.
| 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.:
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
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:
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
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:
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:
<?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:
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.
The drawing shows the whole scene; its 7 steps are listed below.
Every step as text
- 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.
- 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.
- 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.
- 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.
- Expiry is checked. The guard refuses a token that has expired, and for a token it accepts it writes last_used_at.
- 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.".
- 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
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:
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.
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- Issue the token. The admin calls
$user->createToken('mobile', ['fees:read']). Sanctum makes a 48-character tail, 40 random characters and a checksum, and returnsid|tail. - 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. - 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.
- 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 compareshash('sha256', $tailFromHeader)torow.token. - Ability check: 200 or 403. The route runs when
tokenCan('fees:read')is true. A write route withability:fees:writewould answer 403 for this same token. - Revoke, then prune on schedule. On "log out of this device" the row is deleted. A scheduled
sanctum:prune-expiredlater deletes expired rows.
Checkpoint
Check: The hashed-token column
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.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
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:
- Run
php artisan install:api --no-interaction; readroutes/api.php,config/sanctum.phpand the change tobootstrap/app.php, and addHasApiTokensto yourUsermodel. - Build
GET /api/v1/batchesguarded byauth:sanctumandability:fees:read, andPOST /api/v1/batchesguarded byability:fees:write. Issue two tokens and probe both routes. - Read
personal_access_tokens.tokenfor one token. Computehash('sha256', $tail)withphp -rand confirm the match. - 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.
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:
-
Fresher to mid
An API route is guarded by
auth:sanctumandability: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? -
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.
-
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.
Flashcards · 6 cards
Recall it, then check
Say the answer to yourself before you look: recalling it is what makes it stick.
-
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.
-
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.
-
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.
-
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.
-
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.
-
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:apirequires Sanctum, publishes its config and token migration, registersroutes/api.phpand migrates.createToken()returns the plain text once, and thetokencolumn stores the SHA-256 hash of its 48-character tail.auth:sanctumanswers 401 for a missing or unknown token, and theabilitymiddleware 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
- Laravel 13.x Sanctum (Laravel (laravel.com))
- Laravel 13.x Passport (Laravel (laravel.com))
- Laravel 13.x rate limiting (Laravel (laravel.com))
- RFC 6749, The OAuth 2.0 Authorization Framework (RFC Editor (IETF))
- RFC 6750, OAuth 2.0 Bearer Token Usage (RFC Editor (IETF))
- RFC 7636, Proof Key for Code Exchange by OAuth Public Clients (RFC Editor (IETF))
- RFC 8628, OAuth 2.0 Device Authorization Grant (RFC Editor (IETF))
- RFC 6819, OAuth 2.0 Threat Model and Security Considerations (RFC Editor (IETF))
- RFC 9700, Best Current Practice for OAuth 2.0 Security (RFC Editor (IETF))
- OWASP: Authentication Cheat Sheet (OWASP Foundation)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress