laravel/passport
Laravel Passport is an OAuth2 server and API authentication package for Laravel, making it simple and enjoyable to issue tokens and secure your APIs.
Minimal install
composer require laravel/passport
php artisan migrate # creates oauth_clients, oauth_access_tokens, oauth_refresh_tokens, oauth_auth_codes, oauth_device_codes
php artisan passport:install # generates encryption keys + personal-access & password-grant clients
Then three edits:
App\Models\User — use Laravel\Passport\HasApiTokens; (and in v13, implement Laravel\Passport\Contracts\OAuthenticatable if you override the token model).config/auth.php — an api guard with 'driver' => 'passport'.App\Providers\AuthServiceProvider::boot() (or AppServiceProvider) — define your scopes:Passport::tokensCan([
'places-read' => 'Read places',
'places-write' => 'Create and update places',
]);
Passport::defaultScopes(['places-read']);
Where to look first
config/passport.php — key path, guard, connection, token lifetimes, device-code toggle.UPGRADE.md in the repo — mandatory reading if you're coming from v12; v13 changed client IDs, secret hashing, and the oauth_clients schema.Laravel\Passport\Passport — the static facade-ish config surface (tokensExpireIn, tokensCan, useClientModel, actingAs, …).First use case: personal access tokens for a first-party mobile app / CLI
// Issue
$token = $request->user()->createToken('pixel-7', ['places-read'])->accessToken;
// Protect
Route::middleware('auth:api')->get('/user', fn (Request $r) => $r->user());
// Check
if ($request->user()->tokenCan('places-write')) { /* ... */ }
This is the 20% of Passport that covers ~80% of day-to-day usage. Only reach for authorization-code/PKCE or client-credentials once you actually have third-party consumers or machine-to-machine traffic.
Pick the grant before writing code
| Consumer | Grant | Setup |
|---|---|---|
| Your own SPA/mobile app | Personal access token, or Authorization Code + PKCE | passport:client --personal / --public |
| First-party SPA using the same Laravel session | Cookie-based (CreateFreshApiToken) |
Needs web middleware + EnsureFrontendRequestsAreStateful |
| Third-party OAuth app acting for a user | Authorization Code (+ PKCE for public clients) | passport:client --public or --confidential |
| Service-to-service, no user | Client Credentials | passport:client --client |
| Smart TV / CLI with no browser | Device Code | passport:client --device |
| Legacy username/password login | Password grant | passport:client --password — avoid for new work; not part of OAuth 2.1 |
Authorization Code flow, end to end
// routes/web.php — Passport ships the authorization + token endpoints
Passport::routes(); // /oauth/authorize, /oauth/token, /oauth/clients, /oauth/scopes, /oauth/tokens
Your consent screen is the one you publish at Passport::authorizationView('auth.oauth-authorize'). In that view, approve with:
<form method="POST" action="{{ route('passport.authorizations.approve') }}">
@csrf
<input type="hidden" name="state" value="{{ $request->state }}">
<input type="hidden" name="client_id" value="{{ $request->client->id }}">
<button>Authorize</button>
</form>
Public clients (SPAs, native apps) must use PKCE — Passport enforces code_challenge/code_challenge_method=S256 for clients created with --public.
Scopes: define once, enforce everywhere
Route::middleware(['auth:api', 'scope:places-read'])->group(function () {
Route::get('/places', [PlaceController::class, 'index']);
});
Route::middleware(['auth:api', 'scope:places-read,places-write'])->group(function () {
// comma = OR in Passport's CheckScopes middleware
});
Enforce scopes on Token too (v13.4.4 added can/cant):
if ($request->user()->token()->can('places-write')) { /* ... */ }
Anything not covered by middleware should be double-checked explicitly — middleware only runs on routes where you attach it.
Client Credentials (machine-to-machine)
// routes/api.php
Route::middleware('client')->get('/stats', ...); // CheckClientCredentials
Route::middleware('client:stats-read')->get('/stats2', ...); // + scope
Read the client from the request with $request->user() being null — use Laravel\Passport\Client::find(Passport::client()->id) or the authenticated client via $request->user('api-client')-style resolution depending on your guard. Do not assume a user is attached (see Gotchas). v13.5+ gives you a dedicated lifetime:
Passport::clientCredentialsTokenLifetime(CarbonInterval::days(1));
Token lifecycle in your app
$user->tokens; // list active tokens for a "manage devices" screen
$token->revoke(); // revoke one
$user->tokens()->delete(); // revoke all
Auth::user()->token()->revoke(); // logout current device
Schedule cleanup — oauth_access_tokens grows fast without it:
$schedule->command('passport:purge')->hourly();
Testing
// Unit-ish: no real HTTP, no middleware
Passport::actingAs($user, ['places-read']);
// Pin the issuing client (v13.4.2+)
Passport::actingAs($user, ['places-read'], 'client-uuid-here');
// Client credentials
Passport::actingAsClient($client, ['stats-read']);
// Full-stack honesty: assert the real middleware chain at least once
$this->withHeader('Authorization', "Bearer {$token}")->getJson('/api/places');
actingAs short-circuits the guard, so scope middleware still runs against the scopes you pass — but token expiry, revocation, and DB lookups do not. Keep a handful of end-to-end tests.
Extending it
League\OAuth2\Server\Grant\GrantTypeInterface and register with Passport::grant().findForPassport($username, $client) (v13.3+ passes the OAuth client) and validateForPassportPasswordGrant($password) on the user model.Passport::useTokenModel(), Passport::useClientModel(), Passport::usePersonalAccessClientModel().Laravel\Passport\Events\AccessTokenCreated, RefreshTokenCreated, TokenRevoked for audit logging.v13 is a breaking upgrade, and the docs are the only map. The big ones:
php artisan passport:hash is not optional — plaintext secrets left in oauth_clients stop working (see v13.7.5 note and v13.0.0 "Always hash client secret").oauth_clients table changed shape (grant_types, nullable redirect, unique IDs). If you keep integer keys, the changelog explicitly flags that the grant_types migration is required (v13.7.6).OAuthenticatable on your User model is part of the upgrade path — skipping it bites in odd places.Client-credentials tokens must never resolve to a user. v13.7.1/13.7.2 fixed a user-impersonation bug where a client_credentials token resolved as a user when the client ID happened to equal a user ID. As a result, user lookup is now null for these tokens — and the fix deliberately invalidates existing client-credentials tokens where the IDs collided. If your code did $request->user()->id inside a client route, it will break; treat that as correct.
"Personal access client not found" — you migrated but never ran passport:client --personal (or passport:install). PAT requests are disabled by default in v13; the client must exist explicitly.
"invalid_client" / 401 on /oauth/token — usually a non-confidential client trying to authenticate with a secret, a hashed-vs-plaintext secret mismatch after upgrading, or a missing oauth-private.key. Check storage/ (or Passport::loadKeysFrom()) and file permissions; key rotation (passport:keys --force) invalidates every issued token.
auth:api returning null user with a 200 route — that's the token guard silently failing on a revoked/expired token, or a client-credentials token on a user-scoped route. Add an explicit abort_unless($request->user(), 401) if you can't rely on auth middleware, and note that v13.4.1 fixed a 500 in exactly this path.
Scope semantics are wider than they look. A token with * (or no scopes, depending on how it was issued) satisfies tokenCan() for everything. Don't use tokenCan() as your only security boundary — pair it with scope: middleware so it's declarative and testable.
Cookie/SPA flow needs all three pieces. CreateFreshApiToken in the web group, EnsureFrontendRequestsAreStateful, and Passport::cookie() with your domain. If CSRF or 401s appear intermittently, check config/session.php domain and the stateful list. v13.7.3 fixed a session JSON serialization bug — pin ≥13.7.3 if you're on the cookie flow.
Prompt and consent quirks. Passport skips the consent screen when no scopes are requested (v13.0.4), and prompt is now space-delimited (v13.7.4), so prompt=login consent works. prompt=none on a user without a session returns login_required as JSON, not a redirect — handle it in your client.
Pruning. passport:purge clears revoked/expired tokens and expired auth codes and device codes (v13.2.0 added device codes). Without it, oauth_access_tokens becomes your biggest table within months. Also: refresh tokens are single-use by default; a client that reuses one gets invalid_grant, which looks like a bug to front-end devs — document it.
Debugging moves that pay off:
php artisan route:list --path=oauth # confirm endpoints exist
php artisan tinker --execute="dump(Laravel\Passport\Client::all()->toArray());"
tail -f storage/logs/laravel.log # OAuthServerException renders as JSON with a hint field
The hint key in OAuthServerException JSON is the fastest path to the cause (Requested grant type is invalid, Client authentication failed, The provided authorization grant is invalid, expired, or revoked).
Extension points worth knowing before you fork anything: Passport::registerMiddleware() / route middleware attributes on Passport::routes() (added across 13.5–13.7), Passport::authorizationView(), Passport::useClientModel(), Passport::setKeyPath(), and the custom OAuthServerException rendering closure. Most "I need to modify Passport's behaviour" tickets are covered by one of these.
Finally: consider whether you need Passport at all. If the only consumer is your own SPA/mobile app and you don't need refresh tokens, scopes, or third-party grants, Sanctum is lighter and has far fewer moving parts. Passport earns its complexity the moment a client you don't control needs OAuth2 semantics.
How can I help you explore Laravel packages today?