A Sanctum 2,988 personal access token is a row in personal_access_tokens. It has a name, a list of abilities (Sanctum's word for OAuth scopes) and an optional expiry. With HasApiTokens on User, the bookshop exchanges a password for a token and guards the order routes:
Route::post('/tokens', function (Request $request) {
// ... validate email, password and device; Hash::check() the password
$token = $user->createToken($data['device'], ['orders:read'], now()->addDays(30));
return response()->json(['token' => $token->plainTextToken], 201);
});
Route::middleware('auth:sanctum')->group(function () {
Route::get('/orders', fn (Request $request) => $request->user()->orders);
Route::post('/orders/{order}/ship', function (Order $order) {
// ... update the order, dispatch OrderShipped (Section 5.20.8)
})->middleware('abilities:orders:ship');
});abilities (all listed) and ability (any one) are aliases for Sanctum's CheckAbilities and CheckForAnyAbility, registered in withMiddleware(). Ben's scanner token came from Tinker: createToken('warehouse-scanner', ['orders:ship', 'products:write']).
$ curl -s -X POST $B/api/v1/tokens -H 'Accept: application/json' \
-d email=ann@example.com -d password=secret-pass -d device=ann-phone
{"token":"1|n1o0AS3ewfinsNtsXJqelTQYYYTN7YChgVJM5g9C334ae36c"}
$ curl -si -H 'Accept: application/json' $B/api/v1/orders | grep -E '^HTTP|message'
HTTP/1.1 401 Unauthorized
{"message":"Unauthenticated."}
$ curl -si -X POST -H 'Accept: application/json' -H "Authorization: Bearer $ANN" \
$B/api/v1/orders/1/ship | grep -E '^HTTP|message'
HTTP/1.1 403 Forbidden
"message": "Invalid ability provided."No token means 401, a token without the ability means 403, and a wrong password gets the 422 of Validation. The secret after 1| is 40 random characters plus their CRC32 (334ae36c, checked with hash('crc32b')). The table keeps only its SHA-256 (the token column began 84892f35861b, as hash('sha256') confirmed), so the token is shown once. Tokens never expire unless you pass an expiry or set expiration; schedule sanctum:prune-expired. Abilities do not check ownership, so pair tokenCan() with a policy (Authorization).