Skip to content
By • Published • Updated

Laravel Sanctum vs Passport for Mobile Apps (2026)

For a mobile app that talks to its own Laravel API, use Sanctum. It issues one revocable token per device from a login endpoint you control, supports abilities and expiry, and has nothing to configure beyond a trait. Passport is a full OAuth2 server, and the only mobile cases that need one are apps you do not own asking for access to your users’ data, or other servers authenticating as themselves.

That is the short answer, and it matches what the Laravel docs say. The rest of this article is the reasoning, the parts of each package that matter once the app is in production, and the code for the places where Sanctum’s simplicity leaves a gap. It is one part of a larger guide to Laravel as a backend for mobile apps, which covers versioning, response shape, pagination, and push alongside authentication.

What Sanctum Actually Is

Sanctum is two separate features in one package, and only one of them applies to a native app.

  • API tokens. Modelled on GitHub personal access tokens. createToken() on the user writes a row to personal_access_tokens with a SHA-256 hash of a random string and returns the plain text once. The client sends it as a Bearer header and Sanctum looks the hash up on every request. Tokens can carry abilities, which work like scopes, and an optional expiry.
  • SPA cookie authentication. No tokens at all. A browser frontend on the same top-level domain logs in through the normal web guard and Sanctum lets the session cookie authenticate API routes, with CSRF protection intact. The statefulApi() middleware decides which origins get this treatment.

A mobile app uses the first feature only. There is no browser, no cookie jar worth trusting, and no CSRF problem, so the cookie half of Sanctum does not apply. The docs are explicit that API tokens are for mobile apps and third parties while first-party SPAs should use the cookie flow; the mistake worth avoiding is doing it the other way round.

What Passport Actually Is

Passport is a complete OAuth2 authorization server built on league/oauth2-server. It registers the /oauth/authorize and /oauth/token endpoints, stores clients and their redirect URIs, issues RSA-signed JWT access tokens together with refresh tokens, and implements the standard grants: authorization code (with or without PKCE), client credentials, device authorization, and the older password and implicit grants, which are off unless you turn them on.

The important word is server. Passport exists so that other applications can obtain access to your API on a user’s behalf, with the user approving a scope list on a consent screen you render. When the only client is your own app, every one of those moving parts is still present and still has to be configured, deployed, and kept secure, but none of them is doing anything you need.

Why Mobile Apps Almost Always Want Sanctum

Your own iOS or Android app is a first-party client. The user types their credentials into a screen you built, so there is no delegation to model and no third party to keep at arm’s length. What the app needs from the token system is narrower than OAuth2 and more specific:

  • One token per installed device, named so the user can recognise it in a sessions list.
  • The ability to revoke one device without logging out the others, and all of them at once on a password reset.
  • A token that survives app restarts for weeks, not minutes, because re-login on a phone costs you users.
  • Optionally, a token that can do less than the user can, so a stolen token from a read-only screen cannot place orders.

Sanctum covers each of these with a line or two. The login endpoint below follows the shape in the docs, with an expiry and abilities added, and device_name supplied by the app from the device model or a user-chosen label.

<?php
// routes/api.php
Route::post('/auth/login', function (Request $request) {
    $request->validate([
        'email' => ['required', 'email'],
        'password' => ['required'],
        'device_name' => ['required', 'string', 'max:100'],
    ]);

    $user = User::where('email', $request->email)->first();

    if (! $user || ! Hash::check($request->password, $user->password)) {
        throw ValidationException::withMessages([
            'email' => ['The provided credentials are incorrect.'],
        ]);
    }

    $expiresAt = now()->addDays(30);

    $token = $user->createToken(
        $request->device_name,
        ['orders:read', 'orders:write', 'profile:read'],
        $expiresAt,
    );

    return response()->json([
        'token' => $token->plainTextToken,
        'expires_at' => $expiresAt->toIso8601String(),
    ]);
})->middleware('throttle:login');

Returning expires_at alongside the token is a small addition that pays for itself later: the app can decide when to rotate without parsing anything, and support can see at a glance why a device was signed out.

Abilities are enforced on routes with the two middleware Sanctum ships, Laravel\Sanctum\Http\Middleware\CheckAbilities and CheckForAnyAbility. They are not aliased by default in Laravel 11 and 12, so register them once in bootstrap/app.php.

<?php
// bootstrap/app.php, inside withMiddleware()
$middleware->alias([
    'abilities' => CheckAbilities::class,  // token must have every listed ability
    'ability' => CheckForAnyAbility::class, // token must have at least one
]);

// routes/api.php
Route::get('/orders', [OrderController::class, 'index'])
    ->middleware(['auth:sanctum', 'abilities:orders:read']);

Route::post('/orders', [OrderController::class, 'store'])
    ->middleware(['auth:sanctum', 'abilities:orders:write']);

// Inside a policy, combine the token check with ownership
return $user->id === $order->user_id && $user->tokenCan('orders:write');

Revocation is a delete. Logout removes the current device’s row; a password reset, email change, or “sign out everywhere” removes them all. Because only hashes are stored, a leaked database does not yield usable tokens, though you should still rotate after an incident.

<?php
// Logout: only this device
Route::post('/auth/logout', function (Request $request) {
    $request->user()->currentAccessToken()->delete();

    return response()->noContent();
})->middleware('auth:sanctum');

// Password reset listener: every device, then the user logs in again
class RevokeTokensOnPasswordReset
{
    public function handle(PasswordReset $event): void
    {
        $event->user->tokens()->delete();
    }
}

// A sessions screen: revoke one named device the user picked
$request->user()->tokens()->where('id', $request->integer('token_id'))->delete();

What You Give Up: Refresh Tokens and Rotation

Sanctum has no refresh tokens. A token is valid until it expires or is deleted, and the default is that it never expires, because expiration in config/sanctum.php is null. That default is fine for a developer’s personal access token and wrong for a phone that can be lost, so the first production decision is to set it.

<?php
// config/sanctum.php: minutes, applies to every token without its own expiry
return [
    'expiration' => 60 * 24 * 30,
];

// routes/console.php: expired rows stay in the table until this runs
Schedule::command('sanctum:prune-expired --hours=24')->daily();

Once tokens expire you need an answer to “what happens on day 31?” Forcing a re-login is acceptable for some products and a churn problem for others. The alternative is rotation: an authenticated endpoint that issues a replacement with the same name and abilities and deletes the token that called it, in one transaction so a crash between the two statements cannot leave the user with zero tokens or two.

<?php
Route::post('/auth/token/rotate', function (Request $request) {
    $user = $request->user();
    $current = $user->currentAccessToken();
    $expiresAt = now()->addDays(30);

    $fresh = DB::transaction(function () use ($user, $current, $expiresAt) {
        $token = $user->createToken($current->name, $current->abilities, $expiresAt);
        $current->delete();

        return $token;
    });

    return response()->json([
        'token' => $fresh->plainTextToken,
        'expires_at' => $expiresAt->toIso8601String(),
    ]);
})->middleware(['auth:sanctum', 'throttle:rotate']);

On the client, call it when expires_at is within a few days, on app foreground, and store the new token before discarding the old one. Two details keep this honest. The endpoint requires a valid token, so an expired token cannot be refreshed; that is the trade-off versus an OAuth2 refresh token, which outlives the access token by design. And the endpoint needs its own rate limiter, because a captured token can otherwise be renewed forever by whoever holds it. If a long-lived credential that survives access token expiry is a hard requirement, that is a genuine reason to look at Passport.

When Passport Is the Right Choice

Three situations call for a real OAuth2 server, and they are about who the client is rather than how large the app is.

  • Apps you do not own. A partner’s app, a customer’s integration, or a public developer platform where someone else’s code asks for access to your users’ accounts. The user has to see what is being requested and approve it, and the client must never see the user’s password. That is the authorization code grant with PKCE.
  • Machine-to-machine. A reporting job, a warehouse system, or another one of your services calling the API as itself rather than as a user. That is the client credentials grant.
  • Real refresh semantics. A short-lived access token plus a longer-lived refresh token, with both lifetimes set centrally and refresh tokens revocable on their own.

Setup starts with php artisan install:api --passport, then passport:keys on each environment to generate the RSA key pair that signs tokens. The user model takes Passport’s HasApiTokens trait and the OAuthenticatable interface, and the api guard in config/auth.php switches to the passport driver. Scopes and lifetimes live in a service provider.

<?php
// app/Providers/AppServiceProvider.php
class AppServiceProvider extends ServiceProvider
{
    public function boot(): void
    {
        Passport::tokensCan([
            'orders:read' => 'Read your order history',
            'orders:write' => 'Place orders on your behalf',
            'profile:read' => 'Read your profile',
        ]);

        Passport::defaultScopes(['profile:read']);

        Passport::tokensExpireIn(CarbonInterval::hours(1));
        Passport::refreshTokensExpireIn(CarbonInterval::days(30));
        Passport::personalAccessTokensExpireIn(CarbonInterval::months(6));

        // Opt-in only, and the docs no longer recommend it. Leave it out
        // for a first-party mobile app; use Sanctum for that instead.
        // Passport::enablePasswordGrant();
    }
}

For a mobile app that belongs to someone else, create a public client, which has no secret, since a secret compiled into an app binary is not a secret. The app opens the authorization URL in a system browser sheet (ASWebAuthenticationSession on iOS, Custom Tabs on Android), receives the code on its redirect URI, and exchanges it together with the code verifier.

<?php
// Create the PKCE client once; Passport prints its client ID
// php artisan passport:client --public

// The mobile client builds the challenge from a random 43-128 char verifier
$codeVerifier = Str::random(128);
$codeChallenge = strtr(rtrim(base64_encode(hash('sha256', $codeVerifier, true)), '='), '+/', '-_');

// Step 1: open in the system browser
$authorizeUrl = 'https://api.example.com/oauth/authorize?'.http_build_query([
    'client_id' => $clientId,
    'redirect_uri' => 'com.partner.app://oauth/callback',
    'response_type' => 'code',
    'scope' => 'orders:read profile:read',
    'state' => $state,
    'code_challenge' => $codeChallenge,
    'code_challenge_method' => 'S256',
]);

// Step 2: exchange the code; no client_secret for a public client
$response = Http::asForm()->post('https://api.example.com/oauth/token', [
    'grant_type' => 'authorization_code',
    'client_id' => $clientId,
    'redirect_uri' => 'com.partner.app://oauth/callback',
    'code_verifier' => $codeVerifier,
    'code' => $code,
]);

// Protect routes by scope with auth:api and the scope middleware
Route::get('/orders', [OrderController::class, 'index'])
    ->middleware(['auth:api', CheckToken::using('orders:read')]);

// Machine-to-machine routes use the client as the resource owner instead
Route::get('/reports/daily', DailyReportController::class)
    ->middleware(EnsureClientIsResourceOwner::using('reports:read'));

Passport does not ship a mobile SDK, and the challenge and exchange above would normally run in Swift or Kotlin; the PHP form is here because it is the same algorithm and lints. The server side is the routes Passport registers plus the consent view you provide through Passport::authorizationView().

Passport Costs You Should Price In

  • Token size. Access tokens are RSA-signed JWTs that encode the client, user, scopes, and expiry, and come out around a kilobyte. A Sanctum token is the row ID, a pipe, and a 40-character random string. Per request that is noise; across a screen that fires a dozen calls on a cellular connection it is measurable, and it is the kind of thing that shows up when you profile a slow list.
  • Key management. passport:keys writes a private and public key to storage/. They must not be in git, every server that validates tokens needs the public key, and rotating the pair invalidates every token signed by the old one. Most teams load them from PASSPORT_PRIVATE_KEY and PASSPORT_PUBLIC_KEY environment variables, or point Passport::loadKeysFrom() at a mounted secrets path.
  • Revocation sets a flag rather than deleting the row. Expiry is encoded inside the signed token, so the expires_at column is informational and cannot be edited to shorten a token. To cut one off you call $token->revoke(), and separately $token->refreshToken?->revoke(), otherwise the client simply refreshes. Revoked and expired rows accumulate until passport:purge runs, so schedule it.
  • More tables, more grants, more surface. Five tables instead of one, a consent screen to design, redirect URI validation to get right, and the discouraged password and implicit grants sitting one method call away from being enabled by a well-meaning teammate.
<?php
// Revoke one connection, access and refresh token together
$token = Passport::token()->find($tokenId);
$token->revoke();
$token->refreshToken?->revoke();

// Password reset: every token the user has issued to any client
$user->tokens()->each(function (Token $token) {
    $token->revoke();
    $token->refreshToken?->revoke();
});

// routes/console.php
Schedule::command('passport:purge')->hourly();

Decision Table

ConcernSanctumPassport
Client you own (iOS, Android, Flutter)Yes, the intended useWorks, but over-built
Third-party apps with user consentNoAuthorization code + PKCE
Machine-to-machineLong-lived token on a service userClient credentials grant
Token formatOpaque id|random, hash storedRSA-signed JWT, about 1 KB
Refresh tokensNone; rotate with a custom endpointBuilt in, separate lifetime
ScopesAbilities per tokenScopes declared with tokensCan()
ExpiryConfig minutes or per-token date; null by defaulttokensExpireIn(); one year by default
RevocationDelete the rowrevoke() token and refresh token
Secrets to manageNone beyond APP_KEYRSA key pair per environment
DatabaseOne tableClients, codes, tokens, refresh tokens, device codes
Housekeepingsanctum:prune-expiredpassport:purge

Migrating Between Them

The constraint that shapes every mobile migration applies here too: installed app builds keep using whatever token they were issued, for as long as users keep them. You cannot swap the guard on a Tuesday. Plan for a window where both guards accept requests.

Passport to Sanctum is the common direction, usually when a team discovers it adopted OAuth2 for one client it owns. Install Sanctum and add a second guard (sanctum is already a driver), publish a new login endpoint that issues Sanctum tokens, and ship an app build that uses it. Existing builds keep calling the Passport routes until their tokens expire; shorten tokensExpireIn() ahead of the switch so that window is weeks rather than a year. Protect shared routes with auth:sanctum,api so either guard can authenticate during the overlap. Once your build adoption analytics show the old builds are gone, remove the Passport guard, its routes, and the tables.

Sanctum to Passport happens when a partner programme arrives. The snag is that both packages ship a trait called HasApiTokens with the same method names, so one User model cannot simply use both. The cleanest path is to move the first-party app onto Passport as well, either through the PKCE flow in a system browser sheet or through Passport’s own createToken() for personal access tokens, which has a similar signature but mints a JWT. The same overlap window applies, with the Sanctum guard kept alive until old builds have gone. If the partner surface is small, it is worth asking first whether a separate, narrowly scoped Passport resource server in front of the same database would cost less than migrating your own app.

Either way, the migration is mostly a guard switch plus patience. The code that changes is the login endpoint, the route middleware, and the token-revocation paths listed above; business logic should not know which package authenticated the request.

Sources

Where This Fits

Pick Sanctum, set an expiry, add the rotation endpoint, and spend the time you saved on the controls that fail security reviews more often than the token library does: ownership checks on every lookup, rate limits on login, and logs that can answer who read what. Those are worked through in the Laravel API security checklist. If you would rather have someone build the auth layer and the API around it, see backend API development.

Frequently Asked Questions

Should a mobile app use Laravel Sanctum or Passport?

Sanctum, when the only client is your own app. It issues a revocable per-device token from a login endpoint you write, supports abilities and expiry, and needs no OAuth2 setup. Passport is the right choice only when third-party clients need delegated access to your users' data, or other servers need machine-to-machine access through the client credentials grant.

Does Laravel Sanctum support refresh tokens?

No. A Sanctum token is valid until it expires or is deleted. To get refresh-like behaviour, set an expiry and add an authenticated rotation endpoint that issues a fresh token with the same name and abilities and deletes the old one in the same transaction. The app calls it when the current token is close to expiring.

Is the Passport password grant still supported?

It still exists but is opt-in and discouraged. Passport only registers it if you call Passport::enablePasswordGrant() in a service provider, and the Laravel docs say they no longer recommend it. For a first-party mobile app that would otherwise use the password grant, Sanctum is the simpler equivalent; for an app you do not own, use the authorization code grant with PKCE.

Can Sanctum and Passport run in the same Laravel application?

They can both be installed, and each has its own guard driver, but both packages ship a trait named HasApiTokens that defines the same methods on the User model, so one model cannot use both without resolving the conflict. In practice a migration is a guard switch: run the old guard until existing app builds have moved over, then remove it.

How large is a Passport access token compared with a Sanctum token?

A Passport access token is an RSA-signed JWT and is usually around a kilobyte once encoded. A Sanctum token is a short string of the form id|random, well under a hundred characters. On a mobile network the difference is small per request and adds up on screens that fire many calls.

Puneetpal Arneja, Senior iOS Developer & Mobile Architect

Written by

Staff Mobile Engineer at Albertsons · 13+ years building mobile apps

I've shipped 18+ mobile applications across banking, healthcare, and consumer products — including work for Citibank, Apple, PNC, and Albertsons. Everything here comes out of production work, not theory.

Need the Auth Layer Built?

I build Laravel APIs for iOS, Android, and Flutter apps, including Sanctum token lifecycles, Passport partner integrations, and the migration between them.

Related Services & Further Reading