•5 min read

Laravel Sanctum tokenCan() Always Returns True: Here Is Why

I spent most of a Tuesday last month convinced I had broken my own API. I had just added token abilities to a Laravel side project: mobile clients get bearer tokens, the routes check tokenCan('orders:write'), and my curl tests all passed. Then I opened the web dashboard, which logs in with a session cookie, and it strolled past every ability check I had written. No token, no abilities, full access.

My first thought was that the middleware was not running. It was running. The check was returning true for a request that had never seen a bearer token, and the reason sits in about twenty lines of Sanctum source code that the docs mention exactly once.

A brass hotel door handle with a card reader lock mounted above it

The guard checks your session before it reads the header

Using auth:sanctum on a route does not mean token authentication. The guard first loops over the guards listed in config('sanctum.guard'), which defaults to just the web guard, and asks them for a user. If your session cookie authenticates you, Sanctum stops right there and hands back a user wearing a TransientToken:

foreach (Arr::wrap(config('sanctum.guard', 'web')) as $guard) {
    if ($user = $this->auth->guard($guard)->user()) {
        return $this->supportsTokens($user)
            ? $user->withAccessToken(new TransientToken)
            : $user;
    }
}

Only when every session guard comes up empty does it parse the Authorization header and look up the bearer token. The same route can serve both kinds of client, which is the whole point of the design, and it is also how my dashboard walked past the abilities. It was never a token client at all.

TransientToken says yes to everything

Here is the entire class:

class TransientToken implements HasAbilities
{
    public function can($ability)
    {
        return true;
    }

    public function cant($ability)
    {
        return false;
    }
}

can() returns true. Unconditionally, for every ability, for every session request. This is deliberate: it lets policies and controllers call tokenCan() without branching on the authentication mode, because for your own frontend the user is the credential. The session already proved who they are, so an ability check against them would be pointless anyway.

Pointless until you assume the result means what it says. tokenCan('orders:write') returning true reads like the token has the ability. It can also mean there is no token.

Telling the two clients apart

The fix I landed on: anything ability-sensitive checks what kind of credential arrived first.

$token = $request->user()->currentAccessToken();

if ($token instanceof PersonalAccessToken && ! $token->can('orders:write')) {
    abort(403);
}

Session requests fall through to a normal policy check, which is where first-party authorization belongs anyway. Bearer tokens get judged strictly by their abilities. If you prefer route middleware, the abilities: and ability: aliases behave the same way, they just wrap tokenCan() internally, so a session request sails through CheckAbilities too. On Laravel 11 and up you register the aliases yourself:

->withMiddleware(function (Middleware $middleware): void {
    $middleware->alias([
        'abilities' => CheckAbilities::class,
        'ability' => CheckForAnyAbility::class,
    ]);
})
Route::post('/orders', [OrderController::class, 'store'])
    ->middleware(['auth:sanctum', 'abilities:orders:write']);

One more trap while you are in here: abilities are exact strings compared with array lookups, and a typo fails silently. orders:read versus order:read returns false with no error anywhere. The list of abilities my clients use now lives in one snippet in Snippet Ark, because the mobile developer and I kept spelling them differently and lost an afternoon to it.

Expiry, and the cleanup people skip

Abilities are half the token story. The other half is expiry, and the config option is easy to get wrong: expiration in config/sanctum.php is in minutes, not seconds or days. Sixty means one hour. For per-token control, pass a date as the third argument to createToken():

// config/sanctum.php
'expiration' => 60 * 24 * 30, // thirty days, in minutes

// or per token
$token = $user->createToken(
    'deploy-bot',
    ['orders:read'],
    now()->addDays(7)
)->plainTextToken;

Expired tokens stop working, but their rows keep sitting in personal_access_tokens forever. Expired is not deleted. If you turn expiry on, schedule the pruning command:

Schedule::command('sanctum:prune-expired --hours=24')->daily();

One habit worth stealing while you are at it: revoke every token on password reset with $user->tokens()->delete(). A stolen token becomes worthless the second the password changes.

The mental model that finally made this click for me: auth:sanctum answers who you are, token abilities answer which credential you arrived with, and policies answer whether this user may do this thing. The first two overlap completely for session clients and not at all for token clients, and the twenty-line TransientToken class is exactly where they blur. Now my dashboard goes through policies, the mobile client goes through abilities, and I can stop wondering why the lock opened for everyone.