[{"data":1,"prerenderedAt":6},["ShallowReactive",2],{"post-content-laravel-sanctum-tokencan-always-returns-true":3},{"content":4,"lastModified":5},"\u003Cp>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 \u003Ccode>tokenCan('orders:write')\u003C\u002Fcode>, 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.\u003C\u002Fp>\n\n\u003Cp>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.\u003C\u002Fp>\n\n\u003Cimg src=\"https:\u002F\u002Fimages.pexels.com\u002Fphotos\u002F29283877\u002Fpexels-photo-29283877.jpeg?auto=compress&amp;cs=tinysrgb&amp;w=1200\" alt=\"A brass hotel door handle with a card reader lock mounted above it\" loading=\"lazy\" \u002F>\n\n\u003Ch2>The guard checks your session before it reads the header\u003C\u002Fh2>\n\n\u003Cp>Using \u003Ccode>auth:sanctum\u003C\u002Fcode> on a route does not mean token authentication. The guard first loops over the guards listed in \u003Ccode>config('sanctum.guard')\u003C\u002Fcode>, 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:\u003C\u002Fp>\n\n\u003Cpre>\u003Ccode class=\"language-php\">foreach (Arr::wrap(config('sanctum.guard', 'web')) as $guard) {\n    if ($user = $this-&gt;auth-&gt;guard($guard)-&gt;user()) {\n        return $this-&gt;supportsTokens($user)\n            ? $user-&gt;withAccessToken(new TransientToken)\n            : $user;\n    }\n}\u003C\u002Fcode>\u003C\u002Fpre>\n\n\u003Cp>Only when every session guard comes up empty does it parse the \u003Ccode>Authorization\u003C\u002Fcode> 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.\u003C\u002Fp>\n\n\u003Ch2>TransientToken says yes to everything\u003C\u002Fh2>\n\n\u003Cp>Here is the entire class:\u003C\u002Fp>\n\n\u003Cpre>\u003Ccode class=\"language-php\">class TransientToken implements HasAbilities\n{\n    public function can($ability)\n    {\n        return true;\n    }\n\n    public function cant($ability)\n    {\n        return false;\n    }\n}\u003C\u002Fcode>\u003C\u002Fpre>\n\n\u003Cp>can() returns true. Unconditionally, for every ability, for every session request. This is deliberate: it lets policies and controllers call \u003Ccode>tokenCan()\u003C\u002Fcode> 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.\u003C\u002Fp>\n\n\u003Cp>Pointless until you assume the result means what it says. \u003Ccode>tokenCan('orders:write')\u003C\u002Fcode> returning true reads like the token has the ability. It can also mean there is no token.\u003C\u002Fp>\n\n\u003Ch2>Telling the two clients apart\u003C\u002Fh2>\n\n\u003Cp>The fix I landed on: anything ability-sensitive checks what kind of credential arrived first.\u003C\u002Fp>\n\n\u003Cpre>\u003Ccode class=\"language-php\">$token = $request-&gt;user()-&gt;currentAccessToken();\n\nif ($token instanceof PersonalAccessToken &amp;&amp; ! $token-&gt;can('orders:write')) {\n    abort(403);\n}\u003C\u002Fcode>\u003C\u002Fpre>\n\n\u003Cp>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 \u003Ccode>abilities:\u003C\u002Fcode> and \u003Ccode>ability:\u003C\u002Fcode> aliases behave the same way, they just wrap \u003Ccode>tokenCan()\u003C\u002Fcode> internally, so a session request sails through CheckAbilities too. On Laravel 11 and up you register the aliases yourself:\u003C\u002Fp>\n\n\u003Cpre>\u003Ccode class=\"language-php\">-&gt;withMiddleware(function (Middleware $middleware): void {\n    $middleware-&gt;alias([\n        'abilities' =&gt; CheckAbilities::class,\n        'ability' =&gt; CheckForAnyAbility::class,\n    ]);\n})\u003C\u002Fcode>\u003C\u002Fpre>\n\n\u003Cpre>\u003Ccode class=\"language-php\">Route::post('\u002Forders', [OrderController::class, 'store'])\n    -&gt;middleware(['auth:sanctum', 'abilities:orders:write']);\u003C\u002Fcode>\u003C\u002Fpre>\n\n\u003Cp>One more trap while you are in here: abilities are exact strings compared with array lookups, and a typo fails silently. \u003Ccode>orders:read\u003C\u002Fcode> versus \u003Ccode>order:read\u003C\u002Fcode> returns false with no error anywhere. The list of abilities my clients use now lives in one snippet in \u003Ca href=\"\u002Fsnippetark\u002F\">Snippet Ark\u003C\u002Fa>, because the mobile developer and I kept spelling them differently and lost an afternoon to it.\u003C\u002Fp>\n\n\u003Ch2>Expiry, and the cleanup people skip\u003C\u002Fh2>\n\n\u003Cp>Abilities are half the token story. The other half is expiry, and the config option is easy to get wrong: \u003Ccode>expiration\u003C\u002Fcode> in \u003Ccode>config\u002Fsanctum.php\u003C\u002Fcode> is in minutes, not seconds or days. Sixty means one hour. For per-token control, pass a date as the third argument to \u003Ccode>createToken()\u003C\u002Fcode>:\u003C\u002Fp>\n\n\u003Cpre>\u003Ccode class=\"language-php\">\u002F\u002F config\u002Fsanctum.php\n'expiration' =&gt; 60 * 24 * 30, \u002F\u002F thirty days, in minutes\n\n\u002F\u002F or per token\n$token = $user-&gt;createToken(\n    'deploy-bot',\n    ['orders:read'],\n    now()-&gt;addDays(7)\n)-&gt;plainTextToken;\u003C\u002Fcode>\u003C\u002Fpre>\n\n\u003Cp>Expired tokens stop working, but their rows keep sitting in \u003Ccode>personal_access_tokens\u003C\u002Fcode> forever. Expired is not deleted. If you turn expiry on, schedule the pruning command:\u003C\u002Fp>\n\n\u003Cpre>\u003Ccode class=\"language-php\">Schedule::command('sanctum:prune-expired --hours=24')-&gt;daily();\u003C\u002Fcode>\u003C\u002Fpre>\n\n\u003Cp>One habit worth stealing while you are at it: revoke every token on password reset with \u003Ccode>$user-&gt;tokens()-&gt;delete()\u003C\u002Fcode>. A stolen token becomes worthless the second the password changes.\u003C\u002Fp>\n\n\u003Cp>The mental model that finally made this click for me: \u003Ccode>auth:sanctum\u003C\u002Fcode> 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.\u003C\u002Fp>\n","2026-10-23",1793106023372]