# Sanctum or Passport for a Vue app

> Sanctum is the cookie and the API token. Passport is the OAuth server for a third party that brings its own client id.

- URL: https://laravel-vuejs.space/laravel-sanctum-vs-passport
- Author: Anass Ez-zouaine (https://laravel-vuejs.space/author/anass-ez-zouaine)
- Published: 2026-10-10
- Topics: Laravel, VueJs, Sanctum, Passport

“Sanctum or Passport?” has a one-line answer, and most Vue apps never need the second half of it. Here is Laravel’s own rule, then the full Passport setup with an Inertia consent screen for the day a third party really does need OAuth.

> **TL;DR**
>
> -   SPA, mobile app or API tokens: **Sanctum**. A third party that needs OAuth 2: **Passport**.
> -   `php artisan install:api --passport`; swap Sanctum’s `HasApiTokens` for Passport’s, never both.
> -   Passport 13 has no Blade consent page: register an Inertia one with `Passport::authorizationView`.
> -   Client secrets stay on servers. Public clients use PKCE, never the implicit grant.
>
> **What you'll learn**
>
> -   When Sanctum is enough and when you need Passport
> -   How to install Passport next to the Vue starter kit
> -   How to build the OAuth consent screen as an Inertia page
>
> **Who it's for:** Laravel + Vue developers choosing API authentication
>
> **Applies to:** Laravel 13 · Passport 13 · Sanctum · Inertia 3 · Vue 3

Laravel’s own split is the one to use. If the app must speak OAuth 2, use Passport. If you are authenticating a single-page app, a mobile app, or issuing API tokens, use Sanctum. Sanctum does not support OAuth 2. The [Passport docs](https://laravel.com/docs/13.x/passport) state that directly.

| You are authenticating | Use |
| --- | --- |
| The Vue starter kit’s own pages | The session (no package choice) |
| Your Vue SPA on another origin | Sanctum (session cookie) |
| Your phone app or a script | Sanctum (API tokens) |
| Another product that redirects users to you for a code | Passport (OAuth 2) |

The Vue starter kit’s login is a session. Your own Vue SPA on another origin is also a session cookie, which is [Sanctum for a Vue SPA](https://laravel-vuejs.space/laravel-sanctum-vue-spa). A bearer token in that article is for a phone or a script you own. Passport is the next step only when some other product redirects a user to you, gets a code, and exchanges it for a token. The stack choice is [Laravel and Vue in 2026](https://laravel-vuejs.space/laravel-vue-in-2026).

> **Gotcha: two traits named HasApiTokens**
>
> Do not put Sanctum’s `HasApiTokens` and Passport’s `HasApiTokens` on `User` together. `php artisan install:api --passport` replaces the API token machinery. The `web` guard stays `session`, so the kit’s login page keeps working.

## Install Passport

```bash
php artisan install:api --passport
```

That publishes and runs the OAuth migrations and creates the encryption keys. Then `User` implements Passport’s contract and uses Passport’s trait. Keep `HasFactory` and `Notifiable`. Remove Sanctum’s `HasApiTokens` import if the model already has it. The two traits share a name and must not both be applied.

`app/Models/User.php`
```php
use Laravel\Passport\Contracts\OAuthenticatable;
use Laravel\Passport\HasApiTokens;

class User extends Authenticatable implements OAuthenticatable
{
    use HasApiTokens, HasFactory, Notifiable;
}
```

`config/auth.php` must send API requests through Passport:

`config/auth.php`
```php
'guards' => [
    'web' => [
        'driver' => 'session',
        'provider' => 'users',
    ],

    'api' => [
        'driver' => 'passport',
        'provider' => 'users',
    ],
],
```

> **Heads up: keys belong on the server**
>
> On the first deploy, generate keys on the server with `php artisan passport:keys`. Do not commit those files. You can point Passport at a directory with `Passport::loadKeysFrom` in `AppServiceProvider`. To load the keys from the environment instead, publish the config and set `PASSPORT_PRIVATE_KEY` and `PASSPORT_PUBLIC_KEY`:

```bash
php artisan vendor:publish --tag=passport-config
```

## The consent screen

Passport 13 does not ship a Blade consent page. Register one in `AppServiceProvider::boot`. The docs pass the whole request into Inertia. The form only needs three scalars, plus the CSRF token, so pass those:

`app/Providers/AppServiceProvider.php`
```php
use Inertia\Inertia;
use Laravel\Passport\Passport;

Passport::authorizationView(function (array $parameters) {
    return Inertia::render('auth/OAuth/Authorize', [
        'state' => $parameters['request']->string('state')->toString(),
        'clientId' => (string) $parameters['client']->getKey(),
        'clientName' => $parameters['client']->name,
        'authToken' => $parameters['authToken'],
        'scopes' => collect($parameters['scopes'])->map(fn ($scope) => [
            'id' => $scope->id,
            'description' => $scope->description,
        ])->values(),
        'csrf' => csrf_token(),
    ]);
});
```

`/oauth/authorize` is already registered. Approve is a POST to the named route `passport.authorizations.approve`. Deny is a DELETE to `passport.authorizations.deny`. Both expect `state`, `client_id`, and `auth_token`. A browser form cannot send DELETE, so the deny form posts with `_method` set to `DELETE`.

`resources/js/pages/auth/OAuth/Authorize.vue`
```vue
<script setup lang="ts">
defineProps<{
  state: string;
  clientId: string;
  clientName: string;
  authToken: string;
  scopes: { id: string; description: string }[];
  csrf: string;
}>();
</script>

<template>
  <h1>Allow {{ clientName }}?</h1>
  <ul>
    <li v-for="scope in scopes" :key="scope.id">{{ scope.description }}</li>
  </ul>

  <form method="post" action="/oauth/authorize">
    <input type="hidden" name="_token" :value="csrf" />
    <input type="hidden" name="state" :value="state" />
    <input type="hidden" name="client_id" :value="clientId" />
    <input type="hidden" name="auth_token" :value="authToken" />
    <button type="submit">Allow</button>
  </form>

  <form method="post" action="/oauth/authorize">
    <input type="hidden" name="_method" value="DELETE" />
    <input type="hidden" name="_token" :value="csrf" />
    <input type="hidden" name="state" :value="state" />
    <input type="hidden" name="client_id" :value="clientId" />
    <input type="hidden" name="auth_token" :value="authToken" />
    <button type="submit">Deny</button>
  </form>
</template>
```

Save it as `Authorize.vue` inside `pages/auth/OAuth`, next to the kit’s other auth pages. Do not read `document` while the script is setting up. The page can be server-rendered, and the form is a normal POST so Passport can redirect back to the other app.

> **Tip: skipping the prompt for your own client**
>
> A first-party client can skip this prompt. Extend Passport’s `Client` and return `$this->firstParty()` from `skipsAuthorization`. The [docs](https://laravel.com/docs/13.x/passport) show that override. A third-party client should keep the prompt.

## The other app asks for a token

Create the client with `php artisan passport:client`. When it asks for the redirect URI, a comma-separated list is accepted. For a client owned by one of your users, the docs call `createAuthorizationCodeGrantClient` on `ClientRepository`. Show `$client->plainSecret` once. `$user->oauthApps()` lists that user’s clients.

The consuming app, not your Vue bundle, redirects the browser to `/oauth/authorize` with `client_id`, `redirect_uri`, `response_type=code`, `scope`, and a `state` you stored in that app’s session (`Str::random(40)`). `prompt` may be `none`, `consent`, or `login`. After the user allows access, the callback checks `state`, then exchanges the code from the server:

`the consuming app's callback`
```php
$response = Http::asForm()->post('https://passport-app.test/oauth/token', [
    'grant_type' => 'authorization_code',
    'client_id' => 'your-client-id',
    'client_secret' => 'your-client-secret',
    'redirect_uri' => 'https://third-party-app.com/callback',
    'code' => $request->code,
]);
```

> **Gotcha: the client secret never reaches Vue**
>
> The JSON has `access_token`, `refresh_token`, and `expires_in`. The client secret stays on that server. It never goes in a Vue file, a Vite env var, or `localStorage`.

A public client that cannot keep a secret uses `php artisan passport:client --public` and the PKCE grant in the same docs. Do not use the implicit grant for a new SPA. The grant list, scopes, and refresh flow stay in the Passport docs rather than copied here.

> **Run the whole grant locally**
>
> 1.  `php artisan passport:client` and give it a local callback URL.
> 2.  Open `/oauth/authorize?client_id=…&redirect_uri=…&response_type=code&scope=&state=…` while logged in.
> 3.  Click **Allow**, copy `code` from the callback URL, and exchange it with the `Http::asForm()` call above.

Protect your API with `auth:api`. Callers send `Authorization: Bearer` and the access token. That header is for the third party. Your own Inertia pages keep using the session.

## Recap

- Sanctum covers SPAs, mobile apps and personal tokens; Passport is for OAuth 2 clients.
- One `HasApiTokens` trait on `User`: Passport’s, once you install it.
- The consent screen is an Inertia page posting to Passport’s routes.
- Secrets on servers; PKCE for public clients; `auth:api` for third-party calls.

## FAQ

**Q: Should I use Sanctum or Passport for a Vue SPA?**

Sanctum. A first-party SPA uses the session cookie; Passport is only needed when a third party must authenticate with OAuth 2.

**Q: Can I use Sanctum and Passport together?**

Not on the same `User` model traits. Passport’s `HasApiTokens` replaces Sanctum’s; the web session guard keeps working.

**Q: Does Passport 13 include a consent screen?**

No. Register your own view with `Passport::authorizationView`, for example an Inertia Vue page.

**Q: Which OAuth grant should a new SPA use?**

Authorization code with PKCE, via a public client (`passport:client --public`). Do not use the implicit grant.

## Next steps

- [Sanctum for a Vue SPA](https://laravel-vuejs.space/laravel-sanctum-vue-spa)
- [Laravel Fortify with Vue](https://laravel-vuejs.space/laravel-fortify-vue)
- [Breeze, Jetstream, and the Vue starter kit](https://laravel-vuejs.space/laravel-breeze-jetstream-vue)

## Sources

- [Laravel 13: Passport](https://laravel.com/docs/13.x/passport)
- [Laravel 13: Sanctum](https://laravel.com/docs/13.x/sanctum)
- [OAuth 2.0 for Browser-Based Apps (IETF draft)](https://datatracker.ietf.org/doc/draft-ietf-oauth-browser-based-apps/)
