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.

For your AI agent

Copies a prompt. Paste it into the AI agent you already have open.

Read https://laravel-vuejs.space/laravel-sanctum-vs-passport.md
Sanctum is the cookie and the API token. Passport is the OAuth server for a third party that brings its own client id.
Do not put Sanctum's HasApiTokens and Passport's HasApiTokens on User together.
Do not read document while the script is setting up.
Do not use the implicit grant for a new SPA.
Show the diff. Do not commit.

Next: When to use Pinia with Laravel and Inertia

On this page · 7 sections
  1. Install Passport
  2. The consent screen
  3. The other app asks for a token
  4. Recap
  5. FAQ
  6. Next steps
  7. Sources

“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 state that directly.

You are authenticatingUse
The Vue starter kit’s own pagesThe session (no package choice)
Your Vue SPA on another originSanctum (session cookie)
Your phone app or a scriptSanctum (API tokens)
Another product that redirects users to you for a codePassport (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. 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.

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.phpphp
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.phpphp
'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

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.phpphp
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.vuevue
<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 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 callbackphp
$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#

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.

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.

Does Passport 13 include a consent screen?

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

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#

Sources#

Found this useful?

Ask questions, share what you built.

Join thousands of Laravel and Vue developers in our Facebook group, or get new posts in your Telegram feed. Sharing this article helps too.

Keep reading

Newsletter

Get the best of Laravel & Vue, once a month.

One short email with new guides, release notes worth reading and hand-picked community links. No spam, unsubscribe any time.