Skip to main content

πŸ” API Authentication with Passport

A public API is a door anyone can walk through. Laravel Passport turns it into a guarded entrance: a full OAuth2 server that hands out access tokens, checks them on every request, and lets you scope exactly what each token may do β€” all with a handful of Artisan commands.

🎯 Learning Objectives

By the end of this lesson, you will be able to:

  • Explain token-based auth and the core OAuth2 vocabulary
  • Install and configure Passport in Laravel 11 with install:api --passport
  • Choose the right grant type for first-party vs. third-party clients
  • Protect routes with the auth:api guard and restrict them by scope
  • Send Bearer tokens from a client and revoke them for logout
  • Test authenticated endpoints with Passport::actingAs()

Estimated Time: 45–60 minutes  β€’  Difficulty: Intermediate–Advanced

Hands-on: Protect an endpoint, issue a token via login, and call it with a Bearer header.

In This Lesson

Why Token Auth?

Traditional web apps authenticate with sessions and cookies: you log in, the server remembers you in a session, and the browser sends a cookie on every request. That model assumes a browser and a single server. APIs are different β€” the caller might be a mobile app, another server, or a script, and REST says each request must be stateless.

So APIs use tokens. The client authenticates once, receives a token string, and then attaches that token to every subsequent request. The server validates the token β€” no session required.

πŸ’‘ Analogy: A session cookie is like a coat-check ticket that only works at one cloakroom. A token is like a hotel key card: you prove who you are at check-in, get a card, and it opens exactly the doors you're allowed through, for a limited time β€” at any reader in the building.
graph TD A[Client Application] -->|1. Credentials| B[Passport / OAuth2 Server] B -->|2. Access Token| A A -->|3. Request + Bearer Token| C[Protected API] C -->|4. Validate token, respond| A

OAuth2 in Plain English

Passport implements OAuth2, the industry-standard authorization framework. You don't need to memorize the spec, but these five terms carry the whole model:

TermWhat it means
OAuth2The standard protocol for granting access without sharing passwords everywhere.
Grant typeThe flow a client uses to obtain a token (password, authorization code, client credentials…).
Access tokenThe short-lived credential sent with each request to reach protected resources.
Refresh tokenA longer-lived token used to get a fresh access token without re-login.
ScopeA named permission that limits what a token is allowed to do.

The key idea: authenticating (proving who you are) and authorizing (deciding what you may do) are separated. A token proves identity; its scopes decide its powers.

Passport vs. Sanctum

Laravel ships two official auth packages, and picking the right one matters. Both issue API tokens, but they solve different problems.

PassportSanctum
ModelFull OAuth2 serverSimple API tokens + SPA cookie auth
Best forThird-party clients, scopes, refresh tokens, "log in with…"Your own SPA, mobile app, or simple token needs
ComplexityHeavier β€” encryption keys, OAuth clientsLightweight β€” a tokens table

πŸ’‘ Which should you reach for?

Start with Sanctum for a first-party SPA or mobile app β€” it's the Laravel default and covers most projects. Choose Passport when you need genuine OAuth2: issuing tokens to other people's applications, standardized scopes, refresh-token flows, or an "authorize this app" consent screen. This lesson focuses on Passport because that OAuth2 case is what it uniquely handles.

Installing Passport

On Laravel 11 the whole setup is one Artisan command. The --passport flag makes install:api pull in Passport instead of Sanctum, run its migrations, and generate the encryption keys:

php artisan install:api --passport

That command creates routes/api.php, publishes and runs the Passport migrations (which add the OAuth tables), and generates the encryption keys used to sign tokens. Behind the scenes it's the equivalent of composer require laravel/passport followed by php artisan migrate and php artisan passport:keys.

1. Add the trait to your User model

<?php
// app/Models/User.php
namespace App\Models;

use Illuminate\Foundation\Auth\User as Authenticatable;
use Laravel\Passport\HasApiTokens;

class User extends Authenticatable
{
    use HasApiTokens;

    // ... the rest of your model
}

2. Point the api guard at Passport

<?php
// config/auth.php
'guards' => [
    'web' => [
        'driver'   => 'session',
        'provider' => 'users',
    ],

    'api' => [
        'driver'   => 'passport',
        'provider' => 'users',
    ],
],

3. (Optional) Configure token lifetimes

In modern Passport you no longer call Passport::routes() β€” the OAuth routes register themselves. Configuration like token lifetimes goes in a service provider's boot() method (for example App\Providers\AppServiceProvider):

<?php
// app/Providers/AppServiceProvider.php
use Laravel\Passport\Passport;

public function boot(): void
{
    Passport::tokensExpireIn(now()->addDays(15));
    Passport::refreshTokensExpireIn(now()->addDays(30));
    Passport::personalAccessTokensExpireIn(now()->addMonths(6));
}

⚠️ Legacy code you'll see online

Older tutorials call Passport::routes() inside AuthServiceProvider and run php artisan passport:install. Those steps are outdated: routes are automatic now, and Laravel 11 has no AuthServiceProvider by default. If you copy old snippets you'll hit "method not found" errors β€” stick with the install:api --passport flow above.

Grant Types

A grant type is simply the flow a client uses to get a token. Passport supports several; you pick based on who the client is.

Choosing a Passport grant type First-party apps use personal access or password-style tokens; third-party apps you don't own use the authorization code flow with user consent; machine-to-machine clients use client credentials. First-party (apps you own) Personal access tokens Third-party (others' apps) Authorization Code + user consent Machine (no user) Client Credentials
Figure 1 β€” Match the grant type to the client: your own apps, someone else's apps, or server-to-server with no human involved.

Personal access tokens (first-party)

The simplest flow: an authenticated user asks your app for a token directly, no OAuth redirect. Ideal for your own mobile app or a "generate API key" screen.

<?php
// In a controller β€” issue a token for the logged-in user
$token = $request->user()->createToken('mobile-app')->accessToken;

return response()->json(['token' => $token]);

Authorization Code (third-party)

The classic "Log in with…" / "App X wants access to your account" flow. The user is redirected to your app, approves the requested scopes on a consent screen, and the third-party app receives a token. Use this when you don't control the client.

Client Credentials (machine-to-machine)

No user at all β€” one server authenticates as itself to call another. Protect these routes with the client middleware.

Protecting Routes

Once the api guard uses Passport, guarding routes is just the auth:api middleware. Requests without a valid Bearer token get a 401 Unauthorized automatically.

<?php
// routes/api.php
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;

// A single protected route
Route::middleware('auth:api')->get('/user', function (Request $request) {
    return $request->user();
});

// A whole group of protected routes
Route::middleware('auth:api')->group(function () {
    Route::get('/products', [ProductController::class, 'index']);
    Route::post('/products', [ProductController::class, 'store']);
    Route::apiResource('orders', OrderController::class);
});

Inside any protected action, $request->user() returns the token's owner, so you can scope data to them:

<?php
public function index(Request $request)
{
    // Only this user's products
    return ProductResource::collection(
        $request->user()->products()->paginate(15)
    );
}

Token Scopes

Scopes are named permissions attached to a token. A token can be authenticated (valid) yet still forbidden from an action because it lacks the required scope β€” that's the principle of least privilege in action.

Define the available scopes

<?php
// AppServiceProvider::boot()
use Laravel\Passport\Passport;

Passport::tokensCan([
    'view-products'   => 'View products',
    'create-products' => 'Create new products',
    'edit-products'   => 'Edit existing products',
    'delete-products' => 'Delete products',
]);

Request scopes when creating a token

<?php
// Grant only the scopes this client needs
$token = $user->createToken('mobile-app', ['view-products', 'create-products'])
              ->accessToken;

Require a scope on a route

The scope middleware requires all listed scopes; scopes requires any of them:

<?php
Route::middleware(['auth:api', 'scope:view-products'])
    ->get('/products', [ProductController::class, 'index']);

Route::middleware(['auth:api', 'scope:create-products'])
    ->post('/products', [ProductController::class, 'store']);

Check a scope inside a controller

<?php
public function update(Request $request, Product $product)
{
    if (! $request->user()->tokenCan('edit-products')) {
        return response()->json(['message' => 'Missing scope.'], 403);
    }

    $product->update($request->validated());

    return new ProductResource($product);
}

Using & Revoking Tokens

Once issued, a token travels in the Authorization header as a Bearer token on every request:

// Client side (fetch)
const token = localStorage.getItem('access_token');

const res = await fetch('/api/user', {
  headers: {
    'Authorization': `Bearer ${token}`,
    'Accept': 'application/json',
  },
});
const user = await res.json();

A reusable client keeps you from repeating the header everywhere:

// A preconfigured axios instance
import axios from 'axios';

const api = axios.create({
  baseURL: '/api',
  headers: {
    Authorization: `Bearer ${localStorage.getItem('access_token')}`,
    Accept: 'application/json',
  },
});

await api.get('/user');
await api.post('/products', { name: 'New Product', price: 99.99 });

Login: issue a token

<?php
// app/Http/Controllers/Api/AuthController.php
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;

public function login(Request $request)
{
    $credentials = $request->validate([
        'email'    => ['required', 'email'],
        'password' => ['required'],
    ]);

    if (! Auth::attempt($credentials)) {
        return response()->json([
            'message' => 'The provided credentials are incorrect.',
        ], 401);
    }

    $user  = $request->user();
    $token = $user->createToken('auth-token', ['view-profile', 'place-orders']);

    return response()->json([
        'token' => $token->accessToken,
        'user'  => new UserResource($user),
    ]);
}

Logout & token management

Revoking tokens is how you log out and how you power "sign out of all devices":

<?php
// Log out the current token only
public function logout(Request $request)
{
    $request->user()->token()->revoke();

    return response()->json(['message' => 'Logged out.']);
}

// List a user's tokens
$request->user()->tokens;

// Revoke one specific token
$request->user()->tokens()->where('id', $tokenId)->update(['revoked' => true]);

// Revoke every token (log out everywhere)
$request->user()->tokens()->update(['revoked' => true]);

Testing Auth

Passport gives you actingAs() to authenticate a fake user in tests β€” optionally with specific scopes β€” so you never juggle real tokens:

<?php
// tests/Feature/Api/ProductAuthTest.php
namespace Tests\Feature\Api;

use App\Models\Product;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Laravel\Passport\Passport;
use Tests\TestCase;

class ProductAuthTest extends TestCase
{
    use RefreshDatabase;

    public function test_guests_are_rejected(): void
    {
        $this->getJson('/api/products')->assertUnauthorized(); // 401
    }

    public function test_a_user_can_view_their_products(): void
    {
        $user    = User::factory()->create();
        $product = Product::factory()->create(['user_id' => $user->id]);

        // Authenticate with just the scope under test
        Passport::actingAs($user, ['view-products']);

        $this->getJson('/api/products')
            ->assertOk()
            ->assertJsonCount(1, 'data')
            ->assertJsonPath('data.0.id', $product->id);
    }

    public function test_missing_scope_is_forbidden(): void
    {
        Passport::actingAs(User::factory()->create(), ['view-products']);

        // No create-products scope granted β†’ 403
        $this->postJson('/api/products', ['name' => 'X', 'price' => 1])
            ->assertForbidden();
    }
}

Hands-on Exercise

πŸ‹οΈ Lock Down and Log In

Objective: Protect an endpoint with Passport, issue a token through a login route, and call the endpoint with a Bearer token.

Steps

  1. Run php artisan install:api --passport in a Laravel 11 app.
  2. Add use HasApiTokens; to User and set the api guard driver to passport.
  3. Create an AuthController with a login action that validates credentials and returns createToken('auth-token')->accessToken.
  4. Register a public POST /api/login route and a protected GET /api/me route behind auth:api that returns $request->user().
  5. With Postman or curl: POST valid credentials to /api/login, copy the token, then GET /api/me with an Authorization: Bearer <token> header.
  6. Confirm that calling /api/me without the header returns 401.
πŸ’‘ Hint

Always send Accept: application/json from the client so Laravel returns JSON (including a JSON 401) instead of trying to redirect. In curl: curl -H "Authorization: Bearer $TOKEN" -H "Accept: application/json" http://localhost:8000/api/me.

βœ… Sample solution (routes + protected action)
<?php
// routes/api.php
use App\Http\Controllers\Api\AuthController;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;

Route::post('/login', [AuthController::class, 'login']);

Route::middleware('auth:api')->get('/me', function (Request $request) {
    return response()->json(['data' => $request->user()]);
});

🎯 Quick Quiz

Question 1: Why do APIs favor token-based auth over sessions and cookies?

Question 2: You're building a first-party mobile app for your own service. Which is usually the better fit?

Question 3: A token is valid but a route returns 403. What's the most likely cause?

Summary & Quiz

πŸŽ‰ Key Takeaways

  • APIs use stateless tokens instead of sessions; the client sends a Bearer token on every request.
  • Passport is a full OAuth2 server; install it on Laravel 11 with install:api --passport, add HasApiTokens, and set the api guard to passport.
  • Choose a grant type by client: personal access (yours), authorization code (third-party), client credentials (machine).
  • Guard routes with auth:api and narrow them with the scope middleware β€” least privilege per token.
  • Revoke tokens to log out; test protected routes with Passport::actingAs().

πŸ”’ Security reminders

  • Always serve your API over HTTPS so tokens can't be sniffed in transit.
  • Store tokens carefully on the client; prefer secure storage over plain localStorage where possible.
  • Set sensible expiration times and combine auth with rate limiting to blunt brute-force attempts.

πŸ“š Further Reading

πŸš€ What's Next?

You've built a REST API, shaped its responses, and secured it. In the Weekend Project, you'll pull all of Module 19 together β€” routing, Eloquent, validation, resources, and Passport β€” into one complete Laravel API you can be proud of.

πŸŽ‰ Door locked!

Only clients with a valid, correctly-scoped token get through. Let's ship a full project.