Toggle sidebar
Why I Built Passportless Instead of Using Passport or Sanctum

Why I Built Passportless Instead of Using Passport or Sanctum

Laravel already gives us two strong answers for API authentication: Passport and Sanctum. For a long time I treated that as the whole menu. If I needed tokens, I picked one of them.

Then I kept hitting the same project shape:

- My Laravel app issues tokens for my clients (mobile app, CLI, internal admin UI, service-to-service).

- I do not need third-party apps to log in with OAuth2.

- I still want short-lived access tokens, refresh rotation, device-level logout, and ability checks.

- I want that to feel like normal Laravel guards and middleware.

That gap is why Passportless for Laravel exists. This post is less a line-by-line tutorial and more the product reason: what Passport and Sanctum already cover well, what they leave open for first-party APIs, and why I chose a small dedicated package instead of forcing either tool into a shape it was not designed for.

## The real decision is not "which package looks cooler"

It is this:

You need...

Better default

Third-party apps, consent screens, OAuth clients, authorization-code + PKCE, formal grant flows

Laravel Passport

First-party SPA with cookie sessions, or simple long-lived personal API tokens

Laravel Sanctum

First-party API tokens with short-lived access tokens, refresh rotation, reuse detection, token sessions, multi-guard owners, without OAuth2

Passportless (or roll your own)

Laravel's own docs draw a similar line. Passport is the OAuth2 server when you absolutely need OAuth2. Sanctum is the lightweight option for SPAs, mobile apps, and simple token APIs. Neither claims to be "rotating first-party token auth with device sessions and no OAuth surface."

So the question is not "Passport bad, Sanctum bad." The question is: does my problem match their design center?

What I actually needed

In the apps I build most often, authentication looks like this:

  1. A user (or staff member, or service account) authenticates against my backend.

  2. The backend issues an access token (and usually a refresh token).

  3. Clients call APIs with that credential.

  4. I can revoke one device without logging every device out.

  5. Access tokens expire quickly; refresh rotates; reused refresh tokens look like theft.

  6. Token abilities limit what that token may do orders:read, not "full account forever").

  7. Sometimes one app has separate authenticatable models User vs Staff) that must never share identity through the same token path.

I own both sides of the trust boundary. There is no external client registry. There is no authorization-code redirect dance. There is no third-party delegated access.

That is first-party token authentication. Passportless targets that lane only.

Why not Laravel Passport

Passport is excellent at what it is: a full OAuth2 authorization server on top of League OAuth2 Server.

You get clients, secrets, redirect URIs, grant types, scopes, authorization endpoints, and the operational weight that comes with them. That is the correct stack when third parties need delegated access to user data through standard OAuth2 flows.

For my first-party APIs, that stack was the wrong cost:

  • OAuth2 concepts I did not need. Client registration, authorization-code redirects, PKCE, password grant, client credentials, and formal scopes are solutions for delegated third-party authorization. My mobile app and admin panel are not third-party OAuth clients in that sense.

  • Operational complexity for no product gain. Keys, clients, grants, redirects, and scope administration are real maintenance. If no external developer will ever register an OAuth client, I am paying for machinery my users never touch.

  • Wrong mental model for the team. Ability strings on a first-party token are not OAuth scopes. Pretending they are invites the wrong security conversations and the wrong API design.

  • I still only wanted Laravel-native auth. Guards, middleware, models, and config. Not an authorization server bolted onto a private API.

The decision recorded for this package is explicit: keep first-party access tokens, refresh tokens, rotation, device sessions, and token abilities. Do not implement OAuth2. OAuth2 flows, client registration, and delegated authorization sit outside the target use case.

If you need third-party delegated authorization, use Passport. Passportless is not a Passport replacement.

Why not Laravel Sanctum

Sanctum is also excellent at what it is: a featherweight auth system for first-party SPAs and simple API tokens.

It has two main modes:

  1. SPA authentication — cookie-based session auth with CSRF protection. Laravel's recommended path for your own SPA talking to your own API.

  2. API tokens — personal access tokens stored as hashes, with optional abilities. Great for simple "issue a token, send Bearer, revoke later" flows.

For classic first-party SPA session auth, Sanctum remains a strong default. Passportless does not try to replace that.

I reached for something else when the API token side of Sanctum stopped matching the security model I wanted:

  • Long-lived personal tokens by default, not rotating pairs. Sanctum API tokens are simple bearer credentials. Expiration is optional; there is no first-class refresh-token rotation and reuse-detection story. I wanted short-lived access tokens plus long-lived refresh tokens that rotate on every use.

  • No token-family theft model. If a refresh credential is stolen and replayed after rotation, I want the whole family revoked by default. That is a deliberate security feature in Passportless, not something I wanted to re-implement ad hoc in every app.

  • Device sessions as a product feature. "Log out this iPhone" and "log out all devices" need a session grouping model, not only a bag of independent tokens — plus first-class APIs to revoke one session or every session for a guard.

  • Multiple authenticatable models as identity boundaries. One Laravel app with User and Staff should issue tokens that cannot cross guards/providers even when numeric IDs collide. Passportless treats named Laravel guards as auth identity and snapshots guard/provider on the token.

  • Browser transport for the same tokens, not a second auth model. For browser clients I still want HttpOnly access/refresh cookies and a readable CSRF cookie for double-submit flows. Sanctum's SPA mode is session-based. Passportless stays in the token lane: Bearer first, then guard-scoped access cookie; optional SPA login/refresh/logout routes; CSRF and same-origin middleware as opt-in aliases. Host still owns CORS, cookie encryption exclusions, and policy.

Sanctum's API tokens are intentionally simple. That simplicity is a feature when you want simple tokens. It becomes a gap when you want modern rotating-token behavior without graduating to full OAuth2.

The gap I kept re-solving by hand

Before the package, the controller version always started innocent:

$plainTextToken = Str::random(40);

auth()->user()->tokens()->create([

    'name' => 'iphone',

    'token' => $plainTextToken,

]);

Then the real requirements arrived, one by one:

  • Stop storing the plain token; store a hash; return id|secret once.

  • Integrate with Laravel guards instead of ad-hoc controller checks.

  • Expire access tokens.

  • Attach abilities and enforce them with middleware.

  • Group tokens into sessions for per-device logout.

  • Add refresh tokens, rotation, lockForUpdate, and reuse detection.

  • Support separate guards/providers for customers and staff.

  • Offer HttpOnly cookie construction for browser clients without putting secrets in localStorage.

  • Wire browser CSRF and same-origin checks without inventing a parallel session stack.

  • Ship logout that revokes a session (or all sessions), not only the credential in hand.

  • Catch misconfigured cookie paths, guards, and CORS before production.

Every app reinvented a slightly different half of that list. Passport was too heavy. Sanctum was too thin for the rotating-token path. Passportless is the reusable middle: first-party token auth with the security behaviors I actually ship.

What Passportless optimizes for

At a product level, the package should feel Laravel-native and stay small:

use l3aro\Passportless\Concerns\HasPassportless;

class User extends Authenticatable
{
    use HasPassportless;
}

$token = $user->createToken('iphone', ['orders:read', 'orders:write']);

$staffToken = $staff->createToken('admin', ['staff:read'], guard: 'passportless-admin');
Route::get('/orders', OrdersController::class)
    ->middleware(['auth:passportless-client', 'abilities:orders:read']);

Core behaviors that justified a package instead of "just use Sanctum tokens":

  • Hashed access tokens; plain values returned only at issuance/refresh.

  • Optional refresh-token rotation with configurable reuse detection (default: revoke family).

  • Token sessions for device-scoped revocation logoutCurrentSession, logoutAllSessions, refresh-token logout for cookie-only clients).

  • Named Laravel guards as identity boundaries for multiple authenticatable models.

  • First-party abilities abilities / ability middleware), explicitly not OAuth scopes.

  • Two client paths, same token model: Bearer for mobile/CLI/server; optional HttpOnly cookies + SPA routes for browsers.

  • Opt-in browser middleware passportless.csrf, passportless.origin) and passportless:doctor for config audits.

  • No OAuth2 server, no forced login routes, no third-party client registry.

Non-goals stay non-goals: Authorization Code, PKCE, client credentials, password grant, third-party client registration, social login, identity-provider features. Those belong to Passport or specialized IdP tooling.

How the package expresses that reason in code

Hash once, compare safely

The database stores only `hash('sha256', $plainTextToken)`. The client receives `id|plain` once. Lookup is by id; verification uses `hash_equals`.

public function createToken(
    Model $tokenable,
    string $name,
    array $abilities = ['*'],
    ?DateTimeInterface $expiresAt = null,
    string|int|null $sessionId = null,
    ?string $guard = null,
): NewAccessToken {
    $resolved = $this->authBindings->resolve($guard);

    $this->assertTokenableMatchesBinding($tokenable, $resolved);

    $plainTextToken = Str::random(40);

    $token = $tokenable->morphMany(PersonalAccessToken::class, 'tokenable')->create([
        'name' => $name,
        'token' => hash('sha256', $plainTextToken),
        'abilities' => $abilities,
        'session_id' => $sessionId,
        'guard' => $resolved->guard,
        'provider' => $resolved->provider,
        'expires_at' => $expiresAt ?? now()->addMinutes((int) config('passportless.access_token.expiration', 15)),
    ]);

    return new NewAccessToken($token, $token->getKey().'|'.$plainTextToken);
}

Guards are the auth boundary

Passportless registers a passportless guard driver. Route protection is normal Laravel:

'guards' => [
    'passportless-client' => [
        'driver' => 'passportless',
        'provider' => 'users',
    ],
    'passportless-admin' => [
        'driver' => 'passportless',
        'provider' => 'staff',
    ],
],
Route::get('/profile', ClientProfileController::class)
    ->middleware('auth:passportless-client');

Route::get('/admin/profile', StaffProfileController::class)
    ->middleware('auth:passportless-admin');

A client token must not authenticate as staff. Guard, provider, and owner model must still match at validation time or the request fails closed.

The authenticator resolves credentials in order: *Authorization: Bearer first**, then the guard-scoped access cookie if no bearer is present. Same token model; two transports.

Abilities are token capability limits

if ($request->user()->tokenCan('orders:read')) {
    // ...
}
Route::get('/orders', OrdersController::class)
    ->middleware(['auth:passportless-client', 'abilities:orders:read']);

Route::post('/orders', OrdersController::class)
    ->middleware(['auth:passportless-client', 'ability:orders:write,orders:admin']);
  • abilities requires every listed ability.

  • ability requires at least one listed ability.

    Use policies and gates for business authorization. Token abilities only answer: "what was this credential allowed to do when it was issued?"

Sessions + refresh rotation + logout

$pair = $user->createTokenPair('iphone', ['orders:read']);

$rotated = app(Passportless::class)->refreshToken(
    $pair->plainTextRefreshToken(),
    ['orders:read'],
);

Refresh can narrow abilities, never silently expand them. Reuse of a rotated refresh token can revoke the whole family. Family operations stay scoped by family_id, guard, and provider.

'refresh_token' => [
    'expiration' => 60 * 24 * 30, // 30 days
    'reuse_detection' => RefreshTokenReuseDetection::REVOKE_FAMILY,
],

A session groups related access and refresh credentials. Revoking a session revokes every active token in that session — not only the credential you pass:

use l3aro\Passportless\Facades\Passportless;

// This device (from access token)
Passportless::logoutCurrentSession($plainTextAccessToken, 'passportless');

// Cookie-only logout when only the refresh cookie remains
Passportless::revokeSessionFromRefreshToken($plainTextRefreshToken, 'passportless');

// Every session for this user on this guard
Passportless::logoutAllSessions($user, 'passportless');

Invalid, expired, revoked, or guard-mismatched credentials no-op safely. Isolation by guard/provider stays enforced.

Cookies for browsers, Bearer for everything else

Mobile, CLI, and server clients use Bearer tokens. Browser clients can use the same token model via HttpOnly cookies.

Option A — package SPA routes (recommended starter). Routes are never auto-loaded; register per guard:

use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Route;
use App\Models\User;

final class AuthenticateUser
{
    public function __invoke(\Illuminate\Http\Request $request): ?User
    {
        $user = User::query()->where('email', $request->input('email'))->first();

        if ($user === null || ! Hash::check((string) $request->input('password'), $user->password)) {
            return null;
        }

        return $user;
    }
}

Route::passportlessSpaAuth(
    prefix: 'api/auth',
    guard: 'passportless',
    authenticate: AuthenticateUser::class,
    abilities: ['demo:read'],
    loginMiddleware: ['throttle:login'],
    refreshMiddleware: ['throttle:refresh'],
);

Method

Path

Role

POST

{prefix}/login

Issue pair + set cookies

POST

{prefix}/refresh

Rotate from refresh cookie

POST

{prefix}/logout

Revoke session + forget cookies

Login JSON stays non-secret token_type, expirations, optional csrf_token, session) — never plain access/refresh tokens. With csrf: true (default), login gets passportless.origin; refresh/logout get CSRF middleware. Authenticator handlers must be cacheable (invokable class or Class@method, no closures). Host still owns CORS, EncryptCookies exclusions for the JS-readable CSRF cookie, and throttles.

Option B — manual cookie construction when you want full control of routes:

use Illuminate\Support\Str;
use l3aro\Passportless\PassportlessCookieManager;

Route::post('/auth/login', function (PassportlessCookieManager $cookies) {
    $pair = auth()->user()->createTokenPair('browser', guard: 'passportless-client');

    $csrf = Str::random(40);

    return response()->json(['csrf_token' => $csrf])
        ->withCookie($cookies->createAccessCookie($pair->plainTextAccessToken()))
        ->withCookie($cookies->createRefreshCookie($pair->plainTextRefreshToken()))
        ->withCookie($cookies->createCsrfCookie($csrf));
});

Access and refresh cookies stay HttpOnly. The CSRF cookie may be JavaScript-readable for double-submit flows.

Opt-in middleware for host-owned browser routes:

// Double-submit CSRF: cookie vs X-CSRF-TOKEN header (skips GET/HEAD/OPTIONS)
Route::middleware(['passportless.csrf', 'auth:passportless'])
    ->post('/profile', ...);

// Reject a present Origin that does not match the request origin
Route::post('/auth/login', AuthenticateUserController::class)
    ->middleware('passportless.origin');

Cookie paths matter: access and CSRF default to /; refresh defaults to a narrow path (e.g. /api/auth) that must cover both refresh and logout. Misaligned paths are a common production footgun — which is why the doctor command exists.

Ops and tests stay first-party too

php artisan passportless:doctor

php artisan passportless:prune-stale --hours=24

passportless:doctor audits guards, cookie profiles, SPA refresh/logout path coverage, credentialed CORS, and migration columns. Reports only; exits nonzero on problems.

For host-app tests, InteractsWithPassportless provides actingAsPassportless, withPassportlessCookieSession, and cookie queue assertions — same identity rules as production (model must match the guard provider).

Install shape (kept intentionally small)

composer require l3aro/passportless-for-laravel

php artisan vendor:publish --tag="passportless-migrations"

php artisan migrate

Optional config:

php artisan vendor:publish --tag="passportless-config"

After wiring guards, cookies, SPA routes, or CORS:

php artisan passportless:doctor

Cleanup:

php artisan passportless:prune-stale --hours=24

Try it: runnable demo

If you want the cookie SPA path, dual guards, and refresh rotation in one place instead of wiring from scratch, there is a full demo app:

l3aro/passportless-demo — Laravel 13 API + Vue 3 SPA + Docker (nginx gateway, SQLite).

It is built on Passportless ^1.2.0 and exercises the product surface this post argues for:

  • HttpOnly access/refresh cookies (no tokens in localStorage)

  • Route::passportlessSpaAuth login / refresh / logout per guard

  • Two identity realms that must not cross: User + passportless vs Staff + passportless-admin

  • Package CSRF aliases, passportless:doctor, and scheduled stale-token prune

Clone, bring the stack up with Docker Compose (Traefik or a direct host port), migrate + seed, then log in as user or staff. Seed accounts and both access options are documented in the demo README.

That repo is the integration picture; this package is the reusable auth core.

When I still choose Passport or Sanctum

Choose Passport when:

  • External applications need delegated access.

  • You need OAuth2 grants, clients, redirects, or formal scopes.

  • Compliance or partner integration expects an OAuth2 authorization server.

Choose Sanctum when:

  • You are building a classic first-party SPA and want Laravel's session + CSRF SPA authentication.

  • You only need simple personal access tokens without refresh rotation and device-session product features.

  • You want the smallest first-party setup and Sanctum's model already matches.

Choose Passportless when:

  • Your Laravel app issues and validates its own API tokens for clients you own.

  • You want short-lived access tokens + rotating refresh tokens + reuse detection.

  • You need multi-device sessions and per-session (or all-session) revocation.

  • You have separate authenticatable models that must stay isolated by guard/provider.

  • You want optional browser cookie transport for those same tokens — SPA routes, CSRF, same-origin — without adopting OAuth2 or Laravel session SPA auth.

  • You refuse OAuth2 complexity because you do not have an OAuth2 product problem.

Closing

I did not build Passportless because Passport and Sanctum are weak. I built it because they are strong at different jobs, and my job was the lane between them:

  • more security structure than "store a random string / simple PAT"

  • less protocol surface than a full OAuth2 server

  • still Laravel-shaped: guards, middleware, traits, publishable config and migrations

The package name is the thesis: first-party Laravel token authentication without Passport and without OAuth2 complexity — while keeping the behaviors that matter for real multi-device APIs: hashed tokens, rotation, reuse detection, session revocation, named guards, and browser secrets kept out of JavaScript.

Package: l3aro/passportless-for-laravel

Demo app: l3aro/passportless-demo