skip to content

Passport OAuth Server

A full OAuth2 authorization server in the app: signing keys, UUID clients with hashed secrets, scopes, and authorization-code, client-credentials and device grants. Interviewers ask when you need one.

on this pageshow

explore

questions

6

In Laravel Passport 13, how do you declare scopes with Passport::tokensCan and enforce them with CheckToken versus CheckTokenForAnyScope?

level: middleimportance: must knowfreq 46%

answer

  1. declare in AppServiceProvider::boot
  2. undeclared scopes are rejected
  3. CheckToken::using() needs all
  4. CheckTokenForAnyScope needs one
  5. MissingScopeException, renamed from CheckScopes

basics

~10 s

Passport::tokensCan(['shipments:read' => 'View shipments', ...]) declares the scopes clients may request. CheckToken::using(...) requires every listed scope on the access token, CheckTokenForAnyScope::using(...) requires one; a missing scope throws MissingScopeException, rendered as 403.

solid answer

~40 s

Scopes are declared once, usually in `AppServiceProvider::boot()`, with `Passport::tokensCan()`: an array of scope names mapped to descriptions shown on the consent screen. Passport's scope repository accepts only declared scopes, and it strips the `'*'` wildcard except for the password, personal access and client credentials grants. `Passport::defaultScopes()` sets what a token gets when a client asks for none. On routes, Passport 13 ships `CheckToken` (all listed scopes, formerly `CheckScopes`) and `CheckTokenForAnyScope` (at least one, formerly `CheckForAnyScope`), attached with `::using('shipments:read', 'shipments:write')` after `auth:api`, or via the `#[AuthorizeToken]` controller attribute. A missing scope throws `MissingScopeException`, an `AuthorizationException`, so 403. In code, `$request->user()->tokenCan('shipments:write')` checks the current token.

code

php · 12 lines
php
<?php

use App\Http\Controllers\ShipmentController;
use Illuminate\Support\Facades\Route;
use Laravel\Passport\Http\Middleware\CheckToken;
use Laravel\Passport\Http\Middleware\CheckTokenForAnyScope;

Route::middleware(['auth:api', CheckTokenForAnyScope::using('shipments:read', 'shipments:write')])
    ->get('/shipments', [ShipmentController::class, 'index']);

Route::middleware(['auth:api', CheckToken::using('shipments:write', 'pickups:book')])
    ->post('/shipments/{shipment}/pickup', [ShipmentController::class, 'bookPickup']);

go deeper

for a junior

Remember that scopes are declared with Passport::tokensCan and checked with CheckToken for all or CheckTokenForAnyScope for any.

for a middle

Explain how undeclared and wildcard scopes are filtered at issue time, and why a missing scope is 403 while a bad token is 401.

for a senior

Design a scope vocabulary integrators can reason about, and pair scope checks with per-record policies so a scope never widens access.

for a principal

Decide scope granularity as a published contract: too coarse over-grants, too fine burdens every integrator and consent screen.

## What a scope is in Passport In OAuth2 a **scope** limits what a client may do on a user's behalf. A shipping-marketplace integrator on a logistics platform might be allowed to read a shipper's shipments but not to book pickups. In Passport scopes are plain strings that you **declare** and then **check**. ## Declaring scopes ```php Passport::tokensCan([ 'shipments:read' => 'View your shipments and tracking events', 'shipments:write' => 'Create and cancel shipments', 'pickups:book' => 'Book carrier pickups', ]); Passport::defaultScopes(['shipments:read']); ``` - `tokensCan()` replaces the static list of known scopes; the descriptions are handed to your consent view (Passport 13 is headless, so you render it). - `defaultScopes()` supplies scopes when a client requests none. - When a token is issued, Passport's scope repository **drops any scope that was not declared**, strips `'*'` unless the grant is password, personal access or client credentials, and applies any per-client scope restriction. - `Passport::hasScope()`, `scopeIds()`, `scopes()` and `scopesFor()` inspect the declared list. ## Enforcing scopes on routes | Middleware | Passes when | Missing scope | |---|---|---| | `CheckToken::using('a', 'b')` | the token has **all** of `a`, `b` | `MissingScopeException` for the first one missing | | `CheckTokenForAnyScope::using('a', 'b')` | the token has **at least one** | `MissingScopeException` listing all | `::using()` builds the middleware string (`Class:a,b`) so you need no alias. Both classes extend `ValidateToken`: if the request already has a user with a current token (from `auth:api`), they reuse it; otherwise they validate the Bearer token themselves and throw Passport's `AuthenticationException` (401) when it is invalid. `MissingScopeException` extends `AuthorizationException`, which Laravel renders as **403**. On controllers, the `Laravel\Passport\Attributes\AuthorizeToken` attribute wraps the same middleware: `#[AuthorizeToken('shipments:read')]` requires all listed scopes, `anyScope: true` switches to any-of, and `only` / `except` target methods. ## Checking scopes in code `$request->user()->tokenCan('pickups:book')` returns true when the current access token carries that scope or `'*'`; a token with no scopes can do nothing. Use it when the needed scope depends on the input, for example requiring `pickups:book` only when a shipment request also asks for a pickup. ## Passport 13 renames | Before 13 | Passport 13 | |---|---| | `CheckScopes` | `CheckToken` | | `CheckForAnyScope` | `CheckTokenForAnyScope` | | `CheckClientCredentials` | `CheckToken` | | `CheckClientCredentialsForAnyScope` | `CheckTokenForAnyScope` | Old aliases such as `scopes:` and `scope:` pointing at the removed classes must be updated when upgrading. ## Scopes are not the whole authorization story A scope says the **user allowed this client** to attempt an action. It does not say the user may act on this particular shipment. An integrator holding `shipments:write` for shipper A must still be refused shipper B's shipment, which is a policy or gate decision on the user and the model. ## Consent and remembered approvals On the authorization-code flow, Passport's authorization controller parses the requested scopes and passes them, with their descriptions, to your consent view. If the user already approved the same client for the same scopes, the approval screen can be skipped; a client model can also override `skipsAuthorization()` for trusted first-party clients (it returns `false` by default). That is why scope descriptions should be written for **end users**, not developers: they are the only thing a shipper reads before granting access. ## Naming conventions that age well 1. Use `resource:action` names (`shipments:read`, `pickups:book`); they sort and group naturally. 2. Keep read and write separate so integrators can ask for the least they need. 3. Never repurpose a published scope: add a new one and deprecate the old one in your integrator docs, because existing tokens keep the scopes they were issued with. 4. Avoid one "admin" scope; it turns every leaked token into a full compromise.

  • A client requests scope=shipments:export, which was never passed to Passport::tokensCan(). What happens?
    Passport's scope repository only recognises declared scopes, so the OAuth2 server rejects the request with an invalid-scope error instead of issuing a token. Declaring the scope, with a description for the consent view, is what makes it requestable.
  • Why can an integrator's user token not be given the '*' scope through the authorization-code flow?
    When finalising scopes, Passport strips `'*'` for every grant except password, personal access and client credentials. A third-party app acting for a user must name the scopes it needs, and the user approves exactly those on the consent screen.

saying these in an interview costs you the question

  • CheckToken passes when the token has any one of the listed scopes
  • A token missing a scope gets a 401 response
  • Clients can request any scope string without it being declared
  • CheckScopes is still the current Passport 13 class name
  • A token with an empty scope list can do everything
  • Holding a scope proves the user may act on that specific record
open as a page

What steps set up Laravel Passport 13 in a fresh Laravel 13 app, and what does each step add?

level: juniorimportance: should knowfreq 35%

basics

~20 s

Run php artisan install:api --passport, which requires Passport and runs passport:install (keys, config, migrations, an optional personal access client). Then add HasApiTokens and OAuthenticatable to User, define an api guard with driver passport, and protect routes with auth:api.

open as a page

A carrier's nightly sync calls a Laravel Passport route with a client-credentials token and gets 401 from auth:api — why, and what should protect that route instead?

level: seniorimportance: should knowfreq 28%

basics

~10 s

A client-credentials token has no user, so Passport's TokenGuard resolves no user and auth:api answers 401. Protect machine routes with EnsureClientIsResourceOwner (optionally ::using(scopes)), which validates the token itself and accepts only client-owned tokens.

open as a page

When a Laravel Passport API runs on several servers behind a load balancer, where must the signing keys come from, and what breaks if each server runs passport:keys?

level: seniorimportance: should knowfreq 30%

basics

~20 s

Every server must share one key pair, injected through PASSPORT_PRIVATE_KEY and PASSPORT_PUBLIC_KEY or a shared path set with Passport::loadKeysFrom(). If each server runs passport:keys, tokens signed on one server fail verification on another, so requests randomly get 401.

open as a page

Laravel Passport access tokens last a year by default — how do tokensExpireIn and its siblings work, why is expires_at display-only, and what does passport:purge remove?

level: seniorimportance: should knowfreq 32%

basics

~10 s

Passport::tokensExpireIn(), refreshTokensExpireIn() and personalAccessTokensExpireIn() each default to one year; clientCredentialsTokensExpireIn() falls back to tokensExpireIn. Expiry is baked into the signed token, so expires_at is display-only; revoke to invalidate. passport:purge deletes revoked and long-expired rows.

open as a page

In Laravel Passport 13, what kind of client does each passport:client flag create, and why does a --password client fail until enablePasswordGrant() is called?

level: middleimportance: nice to knowfreq 24%

basics

~20 s

No flag creates an authorization-code client; --client a client-credentials client, --device a device-flow client, --personal the personal access client, --password and --implicit legacy clients, --public one without a secret. The password grant itself is only registered after Passport::enablePasswordGrant().

open as a page