Skip to content
Tech

API Authentication with Laravel Sanctum: Tokens, Abilities and SPA Auth

How Sanctum handles both SPA cookie authentication and mobile API tokens, and how to scope tokens with abilities instead of all-or-nothing access.

API Authentication with Laravel Sanctum: Tokens, Abilities and SPA Auth
API Authentication with Laravel Sanctum: Tokens, Abilities and SPA Auth
In this article

    Sanctum solves two different problems that happen to share a package name: authenticating a single-page app that lives on the same top-level domain as your API, and issuing long-lived tokens to mobile apps or third-party integrations that can't use cookies at all. Knowing which mode you're actually using matters, because the setup and the security model differ.

    SPA Authentication: Cookies, Not Tokens

    If your frontend is a Vue or React app served from app.example.com talking to an API at api.example.com, Sanctum's SPA mode uses Laravel's existing session cookie — there's no token to store in localStorage at all, which sidesteps an entire class of XSS-driven token theft.

    'stateful' => explode(',', env(
        'SANCTUM_STATEFUL_DOMAINS',
        'localhost,localhost:3000,app.example.com'
    )),

    The frontend hits a CSRF cookie endpoint once, then authenticates normally — the session cookie handles everything after that.

    await fetch('/sanctum/csrf-cookie', { credentials: 'include' });
    
    await fetch('/login', {
      method: 'POST',
      credentials: 'include',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ email, password }),
    });

    credentials: 'include' is what makes the browser send and accept cookies across the subdomain boundary — without it, the request goes out unauthenticated regardless of what the server does.

    API Tokens for Mobile Apps and Integrations

    A native mobile app or a server-to-server integration has no browser session to rely on, so it authenticates with a bearer token instead.

    public function store(Request $request): JsonResponse
    {
        $request->validate(['email' => 'required|email', 'password' => 'required']);
    
        $user = User::where('email', $request->email)->first();
    
        if (! $user || ! Hash::check($request->password, $user->password)) {
            throw ValidationException::withMessages(['email' => ['Invalid credentials.']]);
        }
    
        $token = $user->createToken('mobile-app', ['orders:read', 'orders:create']);
    
        return response()->json(['token' => $token->plainTextToken]);
    }

    The second argument to createToken() is a list of abilities — scopes baked into that specific token, independent of what the user themselves is allowed to do elsewhere in the app.

    Enforcing Abilities, Not Just Authentication

    Route::middleware(['auth:sanctum', 'ability:orders:create'])
        ->post('/orders', [OrderController::class, 'store']);
    
    Route::middleware(['auth:sanctum', 'ability:orders:read'])
        ->get('/orders', [OrderController::class, 'index']);

    This separation matters: a read-only reporting integration should hold a token that can list orders but physically cannot create one, even if the user who generated that token has full permissions in the main application.

    A user's permissions and a token's abilities are two different questions — "what can this person do" and "what can this specific credential do" — and conflating them is how an integration ends up with more access than it was ever supposed to have.
    Tip: Let users name and revoke their own tokens from an account settings page ($user->tokens). A token with no expiration and no visibility into who holds it is a liability the moment a laptop with a saved API key gets lost.

    Token Expiration and Cleanup

    'expiration' => 60 * 24 * 14, // 14 days, in minutes
    

    Without an expiration, tokens live forever until manually revoked. Setting a reasonable expiration and having the mobile app silently refresh its token limits how much damage a leaked one can do.

    Choosing Between the Two Modes

    • Use SPA cookie authentication when your frontend and API share a top-level domain and are deployed together.
    • Use API tokens when the client is a native app, a CLI tool, or a separate service that can't participate in a shared cookie session.
    • Never mix the two for the same client out of convenience — pick the one that matches how that client is actually deployed.

    Sanctum is intentionally minimal compared to a full OAuth server, and that's usually the right amount of complexity for a first-party frontend and a handful of API consumers you control. The moment you need delegated third-party access — "let this external app act on a user's behalf without seeing their password" — that's the signal to look at Passport or a dedicated OAuth provider instead.

    Written by Raj

    Founder & Editor

    I write HorizonDesk: practical, no-hype articles on technology, money, business and the vehicles we drive. Everything here is something I’ve researched, tested or wanted explained clearly myself.