skip to content

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%

answer

  1. no user behind the token
  2. sub equals the client ID
  3. TokenGuard returns null user
  4. EnsureClientIsResourceOwner::using()
  5. UUID client IDs avoid collisions

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.

solid answer

~40 s

`auth:api` asks the `passport` guard for a **user**. For a client-credentials token the OAuth2 server sets the token's subject to the **client ID**; Passport's `TokenGuard` sees that the subject equals the client and that the client has the `client_credentials` grant, and returns no user, so `auth:api` throws 401. The Passport 13 docs say to protect such routes with `EnsureClientIsResourceOwner` instead. It extends `ValidateToken`, so it validates the Bearer token on its own, rejects tokens issued to a user, and with `EnsureClientIsResourceOwner::using('rates:write')` checks scopes too. Inside the route `$request->user()` is null; identify the caller through the validated token's client. UUID client IDs, the Passport 13 default, keep a client ID from ever matching a user's integer ID; with `Passport::$clientUuids = false` a client token could resolve a user with the same ID.

code

php · 15 lines
php
<?php

use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
use Laravel\Passport\Http\Middleware\EnsureClientIsResourceOwner;

// Machine-to-machine: carriers push rate tables nightly
Route::post('/carrier/rates', function (Request $request) {
    // $request->user() is null here: no user stands behind the token
    return response()->noContent();
})->middleware(EnsureClientIsResourceOwner::using('rates:write'));

// User-delegated: marketplace integrators acting for a shipper
Route::get('/shipments', fn (Request $request) => $request->user()->shipments)
    ->middleware('auth:api');

go deeper

for a junior

Remember that a client-credentials token has no user, so auth:api cannot authenticate it.

for a middle

Explain how TokenGuard returns no user when the token subject equals the client ID, and which middleware validates the token instead.

for a senior

Separate machine and user route groups, identify callers by client, and know why UUID client IDs close a token-confusion hole.

for a principal

Decide how machine integrators are identified, scoped and rotated across the platform, independent of any user account.

## Two kinds of access token A logistics platform's Passport server issues tokens for two very different callers: - **User-delegated tokens** — a freight marketplace acting for shipper #812 through the authorization-code flow. The token's subject is the **user**. - **Client tokens** — a carrier's nightly rate sync using the **client credentials** grant. No human is involved; the OAuth2 server sets the token's subject to the **client's identifier**. Laravel's authentication layer is built around users, which is where the 401 comes from. ## Why `auth:api` refuses a client token `auth:api` calls the `passport` guard's `user()` method. Passport's `TokenGuard`: 1. validates the Bearer token with the public key and checks it is not revoked; 2. loads the active client named in the token; 3. reads the token's user id; if it is empty, **or equals the client id while the client has the `client_credentials` grant**, it returns `null`; 4. otherwise loads the user through the guard's provider. A client-credentials token stops at step 3, so there is no user and the `auth` middleware throws `AuthenticationException` — 401. ## What to use instead | Middleware | Accepts | Rejects | |---|---|---| | `auth:api` | tokens with a user | client-credentials tokens (no user) | | `EnsureClientIsResourceOwner` | tokens owned by the client itself | user-delegated tokens (401) | | `EnsureClientIsResourceOwner::using('rates:write')` | client-owned tokens with every listed scope | missing scope (403) | | `CheckToken::using(...)` without `auth:api` | any valid token with all scopes | missing scope (403) | `EnsureClientIsResourceOwner` and `CheckToken` both extend `ValidateToken`. When no authenticated user with a token is already on the request, `ValidateToken` converts the request to PSR-7 and asks the OAuth2 resource server to validate the Bearer token itself; failure becomes Passport's `AuthenticationException` (401). `EnsureClientIsResourceOwner` then rejects any token whose user id is set and differs from its client id, so a shipper's delegated token cannot call the carrier-only endpoint. ## The integer-ID trap Passport 13 identifies clients by **UUID** by default. The documentation warns that if you set `Passport::$clientUuids = false`, a client-credentials token's subject is an integer like `5`, which can collide with user `5`: the guard might resolve that user, and `EnsureClientIsResourceOwner` can no longer guarantee the token is a client token. The upgrade guide adds that running the new `oauth_clients` migration is strongly recommended for integer IDs. ## Inside the handler - `$request->user()` is `null` — do not write code that assumes it. - To identify the carrier, call `Auth::guard('api')->client()`, which returns the active client for the Bearer token, and map it to your own carrier record. - Authorization for machine clients is about **which client** and **which scopes**, not about a user's policies. ## Design notes 1. Give each carrier its own `--client` client, so one leaked secret can be revoked alone. 2. Keep machine routes in their own group, protected only by `EnsureClientIsResourceOwner::using(...)`. 3. Keep user routes under `auth:api` plus `CheckToken`, so a machine token can never reach them. ## Choosing the lifetime and scopes for machine clients Client-credentials tokens use `Passport::clientCredentialsTokensExpireIn()` when you set it and otherwise inherit the one-year `tokensExpireIn()` default. Because a machine can ask for a new token at any time with its secret, there is no reason to hand it a year-long token: 15 to 60 minutes is common. The client credentials grant also keeps the `'*'` scope when requested, unlike user-delegated grants, so be explicit on the route with `::using(...)` rather than relying on the token being narrow. ## Checklist when a machine route fails | Status | Likely cause | |---|---| | 401 under `auth:api` | client token on a user route | | 401 under `EnsureClientIsResourceOwner` | invalid or revoked token, or a user-delegated token | | 403 | token lacks a scope listed in `::using()` | | client error at `/oauth/token` | wrong secret, revoked client, or a client whose `grant_types` lack `client_credentials` |

  • Could you protect the carrier route with CheckToken alone instead?
    Yes, `CheckToken` validates the Bearer token itself when no user is on the request and checks scopes. But it accepts any valid token with those scopes, including a user-delegated token. `EnsureClientIsResourceOwner` adds the guarantee that the caller is the client acting for itself.
  • Why is setting Passport::$clientUuids = false risky for client-credentials tokens?
    The token's subject is the client identifier. With integer client IDs, client 5's token carries subject 5, which is also user 5's identifier, so the guard may resolve that user and the middleware can no longer tell client tokens from user tokens. UUIDs cannot collide with integer user keys.

saying these in an interview costs you the question

  • A client-credentials token authenticates as the client's owner user
  • auth:api accepts client-credentials tokens
  • CheckClientCredentials is the Passport 13 middleware for machine clients
  • Integer client IDs are harmless for machine tokens
  • $request->user() returns the client model on machine routes