OAuth2-Protected REST API with Laravel
Build a bookmarks API secured by OAuth2 Authorization Code + PKCE flow.
Introduction
30 Second Summary
Every time you log into a third-party app using "Sign in with Google" or authorize a service to access your GitHub repos, OAuth2 is the protocol running behind the scenes. It lets applications request limited access to your data without ever seeing your password.
In this project, you will build a Bookmarks REST API protected by a full OAuth2 authorization server using Laravel Passport. You will implement the Authorization Code + PKCE flow, issue scoped access tokens, and prove that a read-only token gets rejected when trying to write data.
What You'll Build
Picture this: you paste a cURL command into your terminal and the API rejects you with a 401. Then you walk through the OAuth flow in your browser, log in, approve access, receive a token, and suddenly the same endpoint returns your bookmarks as JSON.
By the end of this project, you'll have:
- A CRUD REST API for bookmarks that returns JSON responses and requires a valid Bearer token for every request.
- A working OAuth2 authorization server that issues access tokens through the Authorization Code + PKCE grant. You can register a client, log in, approve access, and exchange the authorization code for a token.
- Scope-based access control where a bookmarks:read token can list bookmarks but receives a 403 Forbidden when attempting to create or delete them.
- Secret Mission: Implement token refresh and revocation so tokens can be rotated and invalidated on demand.
Are there any prerequisites?
This project assumes solid Laravel experience including routing, Eloquent, migrations, and middleware. You should already have PHP 8.4 and Composer installed on your machine.
Before We Start
Before We Start...
Before we dive in, let's lock in what you're about to build and why it matters.
Set Up Laravel and Install Passport
Before you can protect any API endpoint with OAuth2, you need a running Laravel application with Passport wired in. This step gets the foundation in place: the database tables for storing OAuth clients and tokens, the encryption keys for signing those tokens, and the User model configured to issue them.
By the end of this step you will have a fresh Laravel 13 project with Laravel Passport fully installed, a test user seeded, and token scopes defined for the bookmarks API you will build next.
In this step, get ready to:
- Create a Laravel 13 project and install Passport with the HasApiTokens trait.
- Configure the API auth guard, seed a test user, and run migrations.
- Define token scopes and verify the encryption keys are generated.
Create a new Laravel project and install Passport
Every Laravel project starts with the laravel new command, which scaffolds a full application structure and asks you a few configuration questions.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create a new Laravel project called bookmarks-api by running:
laravel new bookmarks-api
What will the installer ask?
The Laravel installer will prompt you to select a starter kit, testing framework, and database. Choose the following:
- Starter kit: select None (no starter kit).
- Testing framework: select Pest (or PHPUnit, either works).
- Database: select SQLite (the default).
- Run default migrations: select yes.
Once the installer finishes, you will see a success message and your new project folder on the Desktop.
- Move into your new project folder by running:
cd bookmarks-api
- Install Laravel Passport and set up the OAuth2 infrastructure by running:
php artisan install:api --passport
What does this command do?
This command installs the laravel/passport package via Composer, publishes and runs the database migrations that create the OAuth2 tables (for clients and access tokens), generates encryption keys for signing tokens, and creates a routes/api.php file.
You will see a message at the end asking you to add the HasApiTokens trait to your User model. You will do that next.
- Open app/Models/User.php in your code editor.
- Replace the entire contents of the file with the following code:
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Laravel\Passport\Contracts\OAuthenticatable;
use Laravel\Passport\HasApiTokens;
class User extends Authenticatable implements OAuthenticatable
{
use HasApiTokens, HasFactory, Notifiable;
protected $fillable = [
'name',
'email',
'password',
];
protected $hidden = [
'password',
'remember_token',
];
protected function casts(): array
{
return [
'email_verified_at' => 'datetime',
'password' => 'hashed',
];
}
}
What changed in the User model?
You added two things from Passport. The HasApiTokens trait gives your User model helper methods for inspecting tokens and scopes. The OAuthenticatable interface tells Passport this model can participate in OAuth2 flows.
- Save app/Models/User.php.
Seeing a class not found error?
Make sure php artisan install:api --passport completed successfully. If Composer failed to install the package, re-run it and check your internet connection.
help me fix a class not found error after installing Passport
Configure the auth guard, seed a test user, and run migrations
Passport needs the api authentication guard to use its own driver so it can validate Bearer tokens on incoming requests.
- Open config/auth.php and find the guards array.
- Replace the guards section so it looks like this:
'guards' => [
'web' => [
'driver' => 'session',
'provider' => 'users',
],
'api' => [
'driver' => 'passport',
'provider' => 'users',
],
],
What does this change?
The api guard now uses the passport driver instead of the default. This tells Laravel to use Passport's TokenGuard to validate access tokens on any route protected by the auth:api middleware.
- Save config/auth.php.
Next, you need a test user so you can log in and authorize OAuth clients later.
- Open database/seeders/DatabaseSeeder.php and replace the entire contents with:
<?php
namespace Database\Seeders;
use App\Models\User;
use Illuminate\Database\Seeder;
class DatabaseSeeder extends Seeder
{
public function run(): void
{
User::factory()->create([
'name' => 'Test User',
'email' => 'test@example.com',
'password' => bcrypt('password'),
]);
}
}
What does this seeder do?
It creates a single user with the email test@example.com and password password. You will use these credentials to log in during the OAuth authorization flow in later steps.
- Save database/seeders/DatabaseSeeder.php.
- Run the seeder to create the test user by running:
php artisan db:seed
You should see a message confirming the seeder ran successfully.
Getting a table not found error?
If the users table does not exist, run php artisan migrate first. The laravel new command should have run migrations already, but if you skipped that prompt, run them now.
help me fix a table not found error when seeding
Define token scopes and verify encryption keys
Token scopes let you issue tokens with specific permissions. A token with bookmarks:read can list bookmarks but cannot create or delete them. You will enforce these scopes on your API routes later.
- Open app/Providers/AppServiceProvider.php and replace the entire contents with:
<?php
namespace App\Providers;
use Illuminate\Support\ServiceProvider;
use Laravel\Passport\Passport;
class AppServiceProvider extends ServiceProvider
{
public function register(): void
{
//
}
public function boot(): void
{
// Define the scopes tokens can request
Passport::tokensCan([
'bookmarks:read' => 'List and view bookmarks',
'bookmarks:write' => 'Create, update, and delete bookmarks',
]);
// Default scope when none is explicitly requested
Passport::defaultScopes([
'bookmarks:read',
]);
// Custom view for the OAuth authorization screen
Passport::authorizationView('auth.oauth.authorize');
}
}
What do these scope definitions do?
Passport::tokensCan() registers two scopes: bookmarks:read for read-only access and bookmarks:write for full CRUD access. When a client requests a token, it must ask for specific scopes.
Passport::defaultScopes() sets bookmarks:read as the fallback if a client does not request any scope explicitly. Passport::authorizationView() tells Passport which Blade view to render when users are asked to approve or deny an OAuth client.
- Save app/Providers/AppServiceProvider.php.
The install:api --passport command already generated encryption keys. Let's confirm they exist.
- Before you run the next command, what two files do you expect to find in the storage/ folder?
- Verify the encryption keys by running:
php artisan passport:keys
- Confirm the key files exist by running:
ls storage/oauth-*.key
You should see storage/oauth-private.key and storage/oauth-public.key listed. These RSA keys are what Passport uses to sign and verify access tokens.
Not seeing the key files?
Make sure you ran php artisan passport:keys from inside the bookmarks-api folder. Also check that the storage/ directory is writable by your user.
help me fix missing Passport encryption keys
- Start the Laravel development server by running:
php artisan serve
Your app is now running at http://localhost:8000. Keep this terminal open for the rest of the project.
✔️ Awesome, I've got everything!
Great. Double check you have saved all three files before moving on.
ⓧ I'd like to double check the full code
Scroll up and confirm you have each of these chunks in place, in order:
- app/Models/User.php with the HasApiTokens trait and OAuthenticatable interface.
- config/auth.php with the api guard set to the passport driver.
- database/seeders/DatabaseSeeder.php with the test user factory call.
- app/Providers/AppServiceProvider.php with tokensCan, defaultScopes, and authorizationView calls.
Your OAuth2 foundation is in place. Next up, you will build the Bookmarks API with endpoints for creating, reading, updating, and deleting bookmarks.
Build the Bookmarks API
Your Laravel project is set up with Passport ready to issue tokens. Now you need something worth protecting.
Before adding OAuth protection, you need working API endpoints. Building them unprotected first lets you confirm the CRUD logic works. When requests later fail with 401, you will know the problem is authentication, not broken endpoints.
In this step, get ready to:
- Create the Bookmark model and database migration.
- Build a controller with full CRUD methods returning JSON.
- Register API routes and verify the endpoints respond with cURL.
Create the Bookmark model and migration
Every bookmark needs a title, a URL, an optional description, and a reference to the user who owns it. Laravel's Artisan tooling generates both the model class and its migration file in one command.
- Generate the Bookmark model and its migration by running this command:
php artisan make:model Bookmark -m
What does this command do?
The -m flag tells Artisan to create both the model file at app/Models/Bookmark.php and a migration file in database/migrations/. The migration filename includes a timestamp so Laravel knows the order to run them.
You should see output confirming both the model and migration were created.
Now open the generated migration file in database/migrations/. It will have a timestamped name ending in _create_bookmarks_table.php.
- Replace the entire contents of the migration file with the following schema definition:
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('bookmarks', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->string('title');
$table->string('url');
$table->text('description')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('bookmarks');
}
};
What does this migration do?
- foreignId('user_id')->constrained()->cascadeOnDelete() creates a foreign key linking each bookmark to a user. If the user is deleted, their bookmarks are deleted too.
- string('title') and string('url') store the bookmark's name and link.
- text('description')->nullable() allows an optional longer description field.
Now open app/Models/Bookmark.php to define the model's fillable fields and its relationship to the User model.
- Replace the contents of app/Models/Bookmark.php with this code:
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class Bookmark extends Model
{
use HasFactory;
protected $fillable = [
'title',
'url',
'description',
'user_id',
];
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}
What does this model do?
The $fillable array tells Laravel which fields can be mass-assigned when creating or updating a bookmark. The user() method defines a belongsTo relationship so you can look up which user owns a bookmark.
- Save both files.
- Run the migration to create the bookmarks table by running:
php artisan migrate
You should see output confirming the create_bookmarks_table migration ran successfully.
Migration failed?
- Check that your migration file has no syntax errors. A missing semicolon or bracket is the most common cause.
- Make sure the users table already exists. The foreignId('user_id')->constrained() references it. If you skipped seeding in Step 1, run php artisan migrate:fresh --seed to recreate everything.
Help me debug my migration error.
Build the BookmarkController
With the database ready, you need a controller that handles incoming HTTP requests and returns JSON responses. Each method will correspond to a standard CRUD operation: list, create, read, update, and delete.
- Generate the controller file by running:
php artisan make:controller BookmarkController
You should see a confirmation message that the controller was created at app/Http/Controllers/BookmarkController.php.
- Open app/Http/Controllers/BookmarkController.php and replace its contents with the following code for the index and store methods:
<?php
namespace App\Http\Controllers;
use App\Models\Bookmark;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class BookmarkController extends Controller
{
public function index(Request $request): JsonResponse
{
// Retrieve all bookmarks for the authenticated user
$bookmarks = $request->user()->bookmarks()->latest()->get();
return response()->json($bookmarks);
}
public function store(Request $request): JsonResponse
{
// Validate incoming data
$validated = $request->validate([
'title' => 'required|string|max:255',
'url' => 'required|url|max:2048',
'description' => 'nullable|string|max:1000',
]);
// Create the bookmark for the current user
$bookmark = $request->user()->bookmarks()->create($validated);
return response()->json($bookmark, 201);
}
What do these methods do?
- index retrieves all bookmarks belonging to the current user, ordered by most recent first, and returns them as JSON.
- store validates the request body (title required, url must be valid, description optional), creates the bookmark, and returns it with a 201 status code.
- Now add the show, update, and destroy methods directly below the store method's closing brace, still inside the class:
public function show(Request $request, Bookmark $bookmark): JsonResponse
{
// Ensure the bookmark belongs to the current user
if ($bookmark->user_id !== $request->user()->id) {
return response()->json(['message' => 'Not found'], 404);
}
return response()->json($bookmark);
}
public function update(Request $request, Bookmark $bookmark): JsonResponse
{
if ($bookmark->user_id !== $request->user()->id) {
return response()->json(['message' => 'Not found'], 404);
}
// Validate only the fields that are present
$validated = $request->validate([
'title' => 'sometimes|required|string|max:255',
'url' => 'sometimes|required|url|max:2048',
'description' => 'nullable|string|max:1000',
]);
$bookmark->update($validated);
return response()->json($bookmark);
}
What do these methods do?
- show uses route model binding to load a bookmark by ID, then checks ownership before returning it.
- update uses sometimes validation so the client only needs to send the fields they want to change.
- Both methods return a 404 if the bookmark belongs to a different user, preventing unauthorized access.
- Finally, add the destroy method below update, and close the class with a final }:
public function destroy(Request $request, Bookmark $bookmark): JsonResponse
{
if ($bookmark->user_id !== $request->user()->id) {
return response()->json(['message' => 'Not found'], 404);
}
$bookmark->delete();
return response()->json(['message' => 'Bookmark deleted']);
}
}
What does destroy do?
It checks ownership the same way as show and update. If the user owns the bookmark, it deletes it and returns a confirmation message.
- Save app/Http/Controllers/BookmarkController.php.
✔️ Awesome, I've got everything!
Great. Double check you have saved the file.
ⓧ I'd like to double check the full code
Your complete BookmarkController.php should contain these sections in order:
- The namespace, use statements, and class declaration opening.
- The index method (retrieve user's bookmarks).
- The store method (validate and create).
- The show method (ownership check, return one).
- The update method (ownership check, partial validate, update).
- The destroy method (ownership check, delete).
- The closing } brace for the class.
Scroll up and confirm each chunk is present. Every method should have a return type of JsonResponse.
Register the API routes and test with cURL
The controller exists but Laravel does not know when to call it. You need to register routes that map HTTP methods and URLs to controller methods.
- Open routes/api.php and replace its contents with the following route definitions:
<?php
use App\Http\Controllers\BookmarkController;
use Illuminate\Support\Facades\Route;
Route::get('/bookmarks', [BookmarkController::class, 'index']);
Route::post('/bookmarks', [BookmarkController::class, 'store']);
Route::get('/bookmarks/{bookmark}', [BookmarkController::class, 'show']);
Route::put('/bookmarks/{bookmark}', [BookmarkController::class, 'update']);
Route::delete('/bookmarks/{bookmark}', [BookmarkController::class, 'destroy']);
What are these routes doing?
Each line maps an HTTP method and URL pattern to a controller method. The {bookmark} segment is a route parameter that Laravel automatically resolves to a Bookmark model instance using route model binding.
These routes are unprotected right now. Anyone can hit them without authentication. That is intentional for this step so you can verify they work before locking them down.
- Save routes/api.php.
Before you test, what do you think curl http://localhost:8000/api/bookmarks will return? There are no bookmarks in the database yet.
- Test the bookmarks index endpoint by running:
curl http://localhost:8000/api/bookmarks
You should see an empty JSON array returned with a 200 status:
[]
Seeing a 404 or 500 error?
- A 404 means Laravel cannot find the route. Make sure you saved routes/api.php and that the URL includes the /api/ prefix.
- A 500 error usually means a syntax issue in the controller or model. Check storage/logs/laravel.log for the full error message.
- Make sure your dev server is still running. If it stopped, restart it with php artisan serve.
Help me debug my cURL request to the bookmarks API.
Your API is live and responding. Right now anyone can access it without logging in. In the next step, you will lock it down with Passport's authentication middleware so only requests with a valid Bearer token can get through.
Protect the API with Passport
In Step 2, you confirmed that your bookmarks API works perfectly. The problem is that it works for everyone. Anyone who knows your URL can read, create, or delete bookmarks without identifying themselves.
In this step, you'll lock the API behind Passport's authentication middleware. Every request will need a valid Bearer token, and your existing cURL commands will immediately break.
In this step, get ready to:
- Add the auth:api middleware to all bookmark routes.
- Confirm the API now rejects unauthenticated requests with a 401 response.
Add the auth:api middleware to your routes
Laravel's auth:api middleware checks every incoming request for a valid access token. When Passport is configured as the API guard driver (which you did in Step 1), this middleware specifically validates OAuth2 Bearer tokens.
- Open routes/api.php in your editor.
- Find the existing route definitions:
Route::get('/bookmarks', [BookmarkController::class, 'index']);
Route::post('/bookmarks', [BookmarkController::class, 'store']);
Route::get('/bookmarks/{bookmark}', [BookmarkController::class, 'show']);
Route::put('/bookmarks/{bookmark}', [BookmarkController::class, 'update']);
Route::delete('/bookmarks/{bookmark}', [BookmarkController::class, 'destroy']);
- Replace the entire contents of routes/api.php with the following code, which wraps all routes in a middleware group:
<?php
use App\Http\Controllers\BookmarkController;
use Illuminate\Support\Facades\Route;
// All routes require a valid OAuth2 access token
Route::middleware('auth:api')->group(function () {
Route::get('/bookmarks', [BookmarkController::class, 'index']);
Route::get('/bookmarks/{bookmark}', [BookmarkController::class, 'show']);
Route::post('/bookmarks', [BookmarkController::class, 'store']);
Route::put('/bookmarks/{bookmark}', [BookmarkController::class, 'update']);
Route::delete('/bookmarks/{bookmark}', [BookmarkController::class, 'destroy']);
});
What does this code do?
Route::middleware('auth:api')->group(function () { ... }) wraps every route inside the closure with Passport's token validation. Any request that does not include a valid Bearer token in the Authorization header is immediately rejected before it ever reaches your controller methods.
- Save routes/api.php.
Test the locked API
In Step 2, running curl http://localhost:8000/api/bookmarks returned an empty JSON array. The middleware you just added should change that behavior completely.
Before you run the command again, what do you think will happen? Will the request still succeed, or will something different come back?
- Run the same cURL command from Step 2 in your terminal:
curl http://localhost:8000/api/bookmarks
Instead of the empty [] array, you now see a JSON error response. The API rejects your request because you did not include a valid access token.
What should I see?
Your terminal should display a JSON response indicating the request is unauthorized. This is Passport telling you that the route requires authentication and no valid token was provided.
This is the designed obstacle. Your API is now locked, and you need a valid OAuth token to access it. The next step builds the complete OAuth2 flow that produces that token.
Still seeing the empty array?
- Make sure you saved routes/api.php after editing.
- Check that your routes are inside the Route::middleware('auth:api')->group(function () { ... }) closure, not outside it.
- Confirm your dev server is still running. If you stopped it, restart it with php artisan serve.
Why is my API still returning data instead of a 401?
✔️ Awesome, I've got everything!
Great. Double check you've saved routes/api.php and confirmed the 401 response.
ⓧ I'd like to double check the full code
Here is the complete routes/api.php file. Make sure yours matches exactly:
<?php
use App\Http\Controllers\BookmarkController;
use Illuminate\Support\Facades\Route;
Route::middleware('auth:api')->group(function () {
Route::get('/bookmarks', [BookmarkController::class, 'index']);
Route::get('/bookmarks/{bookmark}', [BookmarkController::class, 'show']);
Route::post('/bookmarks', [BookmarkController::class, 'store']);
Route::put('/bookmarks/{bookmark}', [BookmarkController::class, 'update']);
Route::delete('/bookmarks/{bookmark}', [BookmarkController::class, 'destroy']);
});
Your API is now locked down. No one can access your bookmarks without proving who they are. Next up, you'll build the complete OAuth2 authorization flow that produces valid access tokens.
Complete the OAuth2 PKCE Authorization Flow
Your API is locked. Every request returns 401 Unauthorized because no valid token exists yet.
In this step, you will build the complete OAuth2 Authorization Code + PKCE flow: registering a client, logging in, authorizing the client, and exchanging the authorization code for an access token. PKCE is the modern secure method that does not require a client secret.
In this step, get ready to:
- Register a PKCE-enabled OAuth client and create a login form.
- Build consumer app routes that handle the full PKCE redirect flow.
- Walk through the authorization flow in the browser and use the token to access the API.
Register the OAuth client and create the login form
Before a third-party app can request tokens, it must be registered as an OAuth client. The --public flag creates a PKCE-enabled client that does not need a client secret.
- Open a new terminal tab (keep the dev server running in the original tab).
- Register a PKCE-enabled OAuth client by running this command:
php artisan passport:client --public
What will the command ask?
The command prompts you for two values:
- "What should we name the client?" Type Bookmarks Consumer and press Enter.
- "Where should we redirect the request after authorization?" Type http://localhost:8000/consumer/callback and press Enter.
After the prompts, the command prints the Client ID. Copy this value and keep it handy. You will paste it into your code shortly.
Save that Client ID
The Client ID is a UUID like 9e3a1c8f-.... You will need it in the next code block. Keep the terminal visible or paste it somewhere accessible.
Now you need a login page so users can authenticate before they see the authorization screen. Laravel requires session-based authentication for the OAuth consent flow.
- Create the login view by adding a new file at resources/views/login.blade.php with this content:
<!DOCTYPE html>
<html>
<head>
<title>Login</title>
</head>
<body>
<h1>Login</h1>
@if ($errors->any())
<div style="color: red;">
@foreach ($errors->all() as $error)
<p>{{ $error }}</p>
@endforeach
</div>
@endif
<form method="POST" action="/login">
@csrf
<div>
<label>Email:</label>
<input type="email" name="email" value="test@example.com" required>
</div>
<div>
<label>Password:</label>
<input type="password" name="password" value="password" required>
</div>
<button type="submit">Log In</button>
</form>
</body>
</html>
What does this view do?
This is a minimal login form pre-filled with the test user credentials (test@example.com / password). It posts to /login and shows validation errors if authentication fails.
Pre-filling credentials saves time during development. In production, you would remove the default values.
The login view needs routes to serve it and handle the form submission. You will replace the default routes/web.php with the authentication routes.
- Replace the entire contents of routes/web.php with the following imports and login routes:
<?php
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\Str;
Route::get('/', function () {
return view('welcome');
});
// Minimal login form for OAuth authorization
Route::get('/login', function () {
return view('login');
})->name('login');
Route::post('/login', function (Request $request) {
$credentials = $request->validate([
'email' => 'required|email',
'password' => 'required',
]);
if (Auth::attempt($credentials)) {
$request->session()->regenerate();
return redirect()->intended('/consumer/redirect');
}
return back()->withErrors(['email' => 'Invalid credentials.']);
});
What does this code do?
- The use statements import every facade and helper the full file will need (including Http and Str for later routes).
- GET /login renders the login form you just created.
- POST /login validates credentials and starts a session. On success, it redirects to /consumer/redirect which you will build next.
- Save routes/web.php.
- Open your browser and visit http://localhost:8000/login.
You should see a simple login form with the email and password fields pre-filled.
Seeing a 500 error or blank page?
- Make sure the file resources/views/login.blade.php exists and is saved. The route renders this view by name.
- Confirm your dev server is still running in the other terminal tab.
- Check for PHP syntax errors in routes/web.php (missing semicolons, unclosed brackets).
Still stuck? Why am I getting a 500 error on my login page?
Build the consumer app routes
In a real-world scenario, a third-party app redirects users to your OAuth server, then receives a callback with an authorization code. You will simulate this "consumer app" within the same Laravel project using two web routes.
- Add the logout and consumer redirect routes below the POST /login route in routes/web.php:
Route::post('/logout', function (Request $request) {
Auth::logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
return redirect('/login');
})->name('logout');
// Consumer app routes (simulates a third-party OAuth client)
Route::get('/consumer/redirect', function (Request $request) {
$request->session()->put('state', $state = Str::random(40));
$request->session()->put('code_verifier', $codeVerifier = Str::random(128));
$codeChallenge = strtr(rtrim(
base64_encode(hash('sha256', $codeVerifier, true)),
'='), '+/', '-_');
$query = http_build_query([
'client_id' => $request->session()->get('oauth_client_id', 'your-client-id'),
'redirect_uri' => url('/consumer/callback'),
'response_type' => 'code',
'scope' => 'bookmarks:read bookmarks:write',
'state' => $state,
'code_challenge' => $codeChallenge,
'code_challenge_method' => 'S256',
]);
return redirect(url('/oauth/authorize') . '?' . $query);
});
What does this code do?
- The logout route clears the session so you can test the flow from scratch.
- The /consumer/redirect route generates a random code_verifier (128 characters) and hashes it into a code_challenge using SHA-256. This is the PKCE handshake that proves the same app that started the flow is the one finishing it.
- It redirects the user to Passport's /oauth/authorize endpoint with all the required OAuth2 parameters.
Notice the 'your-client-id' placeholder in the code. You need to replace it with the Client ID from the artisan command you ran earlier.
- Find the line 'client_id' => $request->session()->get('oauth_client_id', 'your-client-id'), and replace your-client-id with the actual Client ID you copied from the terminal.
Now add the callback route that receives the authorization code and exchanges it for an access token.
- Add the callback route below the /consumer/redirect route in routes/web.php:
Route::get('/consumer/callback', function (Request $request) {
$state = $request->session()->pull('state');
if (strlen($state) === 0 || $state !== $request->state) {
return response()->json(['error' => 'Invalid state'], 422);
}
$codeVerifier = $request->session()->pull('code_verifier');
$response = Http::asForm()->post(url('/oauth/token'), [
'grant_type' => 'authorization_code',
'client_id' => $request->session()->get('oauth_client_id', 'your-client-id'),
'redirect_uri' => url('/consumer/callback'),
'code_verifier' => $codeVerifier,
'code' => $request->code,
]);
return response()->json($response->json());
});
What does this code do?
- It first validates the state parameter to prevent CSRF attacks. If the state does not match, it returns a 422 error.
- It retrieves the code_verifier from the session and sends it alongside the authorization code to /oauth/token. Passport verifies the code_verifier matches the original code_challenge.
- On success, Passport returns an access_token in JSON. The route displays this response so you can copy the token.
- Replace your-client-id in the callback route with the same Client ID you used in the redirect route.
- Save routes/web.php.
The last piece is the authorization view. When Passport presents the consent screen, it renders the view you configured in AppServiceProvider.
- Create the directory resources/views/auth/oauth/ by running:
mkdir -p resources/views/auth/oauth
- Create the authorization view at resources/views/auth/oauth/authorize.blade.php with the scope display section:
<!DOCTYPE html>
<html>
<head>
<title>Authorize Application</title>
</head>
<body>
<h1>Authorization Request</h1>
<p><strong>{{ $client->name }}</strong> is requesting access to your account.</p>
@if (count($scopes) > 0)
<p>This application will be able to:</p>
<ul>
@foreach ($scopes as $scope)
<li>{{ $scope->description }}</li>
@endforeach
</ul>
@endif
What does this section do?
Passport passes $client, $scopes, $request, and $authToken to this view. The template shows the user which application is requesting access and what permissions (scopes) it wants.
- Add the authorize and deny forms below the @endif line in the same file:
<form method="POST" action="{{ route('passport.authorizations.approve') }}">
@csrf
<input type="hidden" name="state" value="{{ $request->state }}">
<input type="hidden" name="client_id" value="{{ $client->getKey() }}">
<input type="hidden" name="auth_token" value="{{ $authToken }}">
<button type="submit" style="background: green; color: white; padding: 10px 20px;">Authorize</button>
</form>
<form method="POST" action="{{ route('passport.authorizations.deny') }}">
@csrf
@method('DELETE')
<input type="hidden" name="state" value="{{ $request->state }}">
<input type="hidden" name="client_id" value="{{ $client->getKey() }}">
<input type="hidden" name="auth_token" value="{{ $authToken }}">
<button type="submit" style="background: red; color: white; padding: 10px 20px;">Deny</button>
</form>
</body>
</html>
What do these forms do?
The Authorize form POSTs to Passport's approve route with hidden fields for state, client_id, and auth_token. The Deny form sends a DELETE request to reject the authorization. Both are required by Passport to process the user's decision.
- Save resources/views/auth/oauth/authorize.blade.php.
- Visit http://localhost:8000/consumer/redirect in your browser.
You should be redirected to the login page. This confirms the consumer redirect route is registered and Laravel's auth middleware is redirecting unauthenticated users to login.
Seeing a 404 or route error?
- Ensure all the routes are inside the same routes/web.php file with the <?php opening tag at the top.
- Check that you did not accidentally leave a duplicate <?php tag or close the file early.
- Confirm the file saved successfully and the dev server is running.
Need help? Why is my /consumer/redirect route returning a 404?
Walk through the full OAuth flow
All the pieces are in place. Time to walk through the entire OAuth2 PKCE flow from start to finish in the browser.
- In your browser, visit http://localhost:8000/consumer/redirect.
- You will be redirected to the login page. Click Log In (the credentials are pre-filled).
- After logging in, you will see the Authorization Request page showing "Bookmarks Consumer is requesting access to your account" with the list of scopes.
- Click the green Authorize button.
Your browser redirects to /consumer/callback and displays a JSON response containing an access_token field. This is your Bearer token.
- Copy the value of the access_token field from the JSON response (the long string, not including the quotes).
What just happened?
You walked through the full Authorization Code + PKCE flow:
- The consumer generated a code_verifier and sent a hashed code_challenge to the authorization server.
- You authenticated and approved access.
- Passport issued an authorization code back to the callback URL.
- The callback exchanged that code (plus the original code_verifier) for an access token. Passport verified the verifier matches the challenge before issuing the token.
Before you run the next command, do you think the API will return your bookmarks or reject the request? You now have a valid Bearer token.
- Test the protected API by running this cURL command (paste your access token in place of the placeholder):
curl -H "Authorization: Bearer [[ACCESS_TOKEN="your access token from the JSON response"]]" http://localhost:8000/api/bookmarks
You should see a 200 response with an empty JSON array []. The API accepted your token and returned the bookmarks list (empty because you have not created any yet).
Still seeing 401 Unauthorized?
- Make sure you copied the full access_token value without extra spaces or line breaks.
- Verify the header is Authorization: Bearer (capital B, one space before the token).
- If your token expired or you waited too long, walk through the flow again by visiting /consumer/redirect to get a fresh token.
- Check that the Client ID in your routes/web.php matches the one from php artisan passport:client --public.
Need help? Why is my Bearer token being rejected with 401?
✔️ Awesome, I've got everything!
Great. Double check you saved both routes/web.php and both Blade view files.
ⓧ I'd like to double check the full code
Your routes/web.php should contain these sections in order. Scroll through the file and confirm each one is present:
- The <?php tag and six use import statements (Request, Auth, Http, Route, Str).
- GET / route returning the welcome view.
- GET /login and POST /login routes.
- POST /logout route.
- GET /consumer/redirect route with PKCE code_verifier and code_challenge generation.
- GET /consumer/callback route with state validation and token exchange.
Your resources/views/login.blade.php should look like this:
<!DOCTYPE html>
<html>
<head>
<title>Login</title>
</head>
<body>
<h1>Login</h1>
@if ($errors->any())
<div style="color: red;">
@foreach ($errors->all() as $error)
<p>{{ $error }}</p>
@endforeach
</div>
@endif
<form method="POST" action="/login">
@csrf
<div>
<label>Email:</label>
<input type="email" name="email" value="test@example.com" required>
</div>
<div>
<label>Password:</label>
<input type="password" name="password" value="password" required>
</div>
<button type="submit">Log In</button>
</form>
</body>
</html>
Your resources/views/auth/oauth/authorize.blade.php should contain:
- The HTML skeleton with <h1>Authorization Request</h1> and the scope listing section.
- The Authorize form posting to passport.authorizations.approve with hidden fields for state, client_id, and auth_token.
- The Deny form with a DELETE method posting to passport.authorizations.deny with the same hidden fields.
Your OAuth2 flow is fully operational. You can now obtain access tokens programmatically. Next up, you will add scope-based access control so tokens carry specific permissions.
Enforce Token Scopes
Right now, any valid token grants full access to every endpoint. A client with a read-only purpose could delete all your bookmarks just as easily as it can list them.
Token scopes solve this by attaching specific permissions to each token at the moment it is issued. In this step, you will enforce scope-based access control so a bookmarks:read token can only list bookmarks, while write operations require bookmarks:write.
In this step, get ready to:
- Restrict the consumer app to request only a read-only scope.
- Add scope-checking middleware to each API route.
- Demonstrate that write requests are rejected with 403 Forbidden.
Restrict the consumer app to read-only scope
You already defined token scopes in AppServiceProvider back in Step 1. The scopes bookmarks:read and bookmarks:write exist, but nothing enforces them yet. To prove enforcement works, you will first make the consumer app request only read-only access.
- In routes/web.php, scroll down to the /consumer/redirect route.
- Find this line inside the http_build_query array:
'scope' => 'bookmarks:read bookmarks:write',
- Replace it with only the read scope, so the line now looks like this:
'scope' => 'bookmarks:read',
- Save routes/web.php.
What does this change do?
When the consumer app redirects to the authorization server, it now asks for only bookmarks:read permission. The resulting token will carry only that scope, even though the server knows about other scopes.
Add scope-checking middleware to your API routes
The API routes currently check that a valid token exists, but they do not check what the token is allowed to do. Laravel Passport provides the CheckToken middleware to enforce that a token carries specific scopes.
- Replace the entire contents of routes/api.php with the following code:
<?php
use App\Http\Controllers\BookmarkController;
use Illuminate\Support\Facades\Route;
use Laravel\Passport\Http\Middleware\CheckToken;
Route::middleware('auth:api')->group(function () {
Route::get('/bookmarks', [BookmarkController::class, 'index'])
->middleware(CheckToken::using('bookmarks:read'));
Route::get('/bookmarks/{bookmark}', [BookmarkController::class, 'show'])
->middleware(CheckToken::using('bookmarks:read'));
Route::post('/bookmarks', [BookmarkController::class, 'store'])
->middleware(CheckToken::using('bookmarks:write'));
Route::put('/bookmarks/{bookmark}', [BookmarkController::class, 'update'])
->middleware(CheckToken::using('bookmarks:write'));
Route::delete('/bookmarks/{bookmark}', [BookmarkController::class, 'destroy'])
->middleware(CheckToken::using('bookmarks:write'));
});
What does this code do?
- CheckToken::using('bookmarks:read') on GET routes means the token must carry the bookmarks:read scope to list or view bookmarks.
- CheckToken::using('bookmarks:write') on POST, PUT, and DELETE routes means only tokens with the bookmarks:write scope can create, update, or delete bookmarks.
- The outer auth:api middleware still runs first. It confirms the token is valid. Then CheckToken runs second to confirm the token has the right permissions.
- Save routes/api.php.
Seeing a class not found error?
Make sure the import at the top of the file matches exactly: use Laravel\Passport\Http\Middleware\CheckToken;. A typo in the namespace will cause a runtime error.
Help me debug a CheckToken middleware import error in Laravel Passport.
Test the scoped flow end-to-end
The middleware is in place and the consumer app now requests only bookmarks:read. Time to walk through the OAuth flow again and see what happens when a read-only token tries to write.
- Open your browser and navigate to http://localhost:8000/consumer/redirect.
- Log in with test@example.com and password if prompted.
- Click Authorize on the authorization screen. Notice the scope list now shows only "List and view bookmarks".
- Copy the access_token value from the JSON response in your browser.
First, confirm that read access still works. Test the GET endpoint by running this in your terminal:
curl -H "Authorization: Bearer your-access-token-here" http://localhost:8000/api/bookmarks
You should see a 200 response with your bookmarks array (or an empty [] if you have not created any yet). The read scope is satisfied.
Now for the key test. Before you run the next command, what do you think will happen when a read-only token tries to create a new bookmark?
- Attempt to create a bookmark with your read-only token by running:
curl -X POST -H "Authorization: Bearer your-access-token-here" -H "Content-Type: application/json" -d '{"title":"Test","url":"https://example.com"}' http://localhost:8000/api/bookmarks
You will see a 403 Forbidden response. The token is valid but it lacks the bookmarks:write scope, so the CheckToken middleware rejects the request before it reaches the controller.
Why is this 403 and not 401?
A 401 Unauthorized means "I don't know who you are." A 403 Forbidden means "I know who you are, but you don't have permission." Your token is valid (you passed the auth:api check), but it does not carry the required scope.
Restore full scope access for the consumer app
You have proven that scope enforcement works. In production, a consumer app requests whatever scopes it actually needs. Your consumer app needs both read and write access for normal operation.
- In routes/web.php, find the scope line you changed earlier in the /consumer/redirect route:
'scope' => 'bookmarks:read',
- Restore both scopes so the line looks like this:
'scope' => 'bookmarks:read bookmarks:write',
- Save routes/web.php.
Why restore both scopes?
Scope enforcement lives on the API routes (server-side), not in what the consumer requests. Even if your consumer app requests both scopes, the middleware still blocks any route whose required scope is missing from the token. The restriction is always enforced at the server.
Run through the flow one more time to confirm full access is restored. Navigate to http://localhost:8000/consumer/redirect in your browser, authorize, and copy the new token.
- Test creating a bookmark with the new token by running:
curl -X POST -H "Authorization: Bearer your-access-token-here" -H "Content-Type: application/json" -d '{"title":"Laravel Docs","url":"https://laravel.com/docs"}' http://localhost:8000/api/bookmarks
You should see a 201 response with the bookmark JSON. The token now carries bookmarks:write, so the middleware allows the request through.
Still getting 403 with the new token?
Make sure you completed the OAuth flow again after restoring the scope line. Your old token still carries only bookmarks:read. You need a freshly issued token that includes both scopes.
Help me debug why my token is still rejected after changing the scope.
✔️ Awesome, I've got everything!
Great. Double check you have saved both files.
ⓧ I'd like to double check the full code
Here is the complete routes/api.php file:
<?php
use App\Http\Controllers\BookmarkController;
use Illuminate\Support\Facades\Route;
use Laravel\Passport\Http\Middleware\CheckToken;
Route::middleware('auth:api')->group(function () {
Route::get('/bookmarks', [BookmarkController::class, 'index'])
->middleware(CheckToken::using('bookmarks:read'));
Route::get('/bookmarks/{bookmark}', [BookmarkController::class, 'show'])
->middleware(CheckToken::using('bookmarks:read'));
Route::post('/bookmarks', [BookmarkController::class, 'store'])
->middleware(CheckToken::using('bookmarks:write'));
Route::put('/bookmarks/{bookmark}', [BookmarkController::class, 'update'])
->middleware(CheckToken::using('bookmarks:write'));
Route::delete('/bookmarks/{bookmark}', [BookmarkController::class, 'destroy'])
->middleware(CheckToken::using('bookmarks:write'));
});
And the scope line in routes/web.php inside the /consumer/redirect route should be:
'scope' => 'bookmarks:read bookmarks:write',
Your API now enforces fine-grained permissions at the route level. Next up, the Secret Mission challenges you to implement token refresh and revocation for production-ready token lifecycle management.
Secret mission
Implement Token Refresh and Revocation
In production, access tokens should be short-lived. A leaked token that never expires is a permanent security hole. Can you implement token refresh and revocation so tokens rotate safely and compromised tokens can be invalidated instantly?
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally, so there are no ongoing costs.
Resources you used:
- Your Laravel project folder on the Desktop (contains all source code, SQLite database, OAuth encryption keys, and Composer dependencies).
- The dev server process running at http://localhost:8000.
Keep everything running
No action needed. Choose this if you're still actively building or experimenting with your OAuth2 API.
- Your dev server stays running at http://localhost:8000.
- Your SQLite database retains all bookmarks and OAuth tokens.
- Your encryption keys in storage/ remain ready for signing tokens.
Pause - I'll come back to this later
Shut down the running dev server to free up your terminal and port, but keep all project files intact.
- Switch to the terminal where your dev server is running.
- Press Ctrl+C to stop the server.
- Your project folder stays on the Desktop. When you want to resume, open the terminal, navigate to the project folder, and run php artisan serve again.
Delete - I don't want to use this again
Remove all project files and stop any running processes. This cannot be undone.
- Switch to the terminal where your dev server is running and press Ctrl+C to stop it.
- Navigate to your Desktop and remove the project folder by running the appropriate command for your system.
macOS
cd ~/Desktop
rm -rf bookmarks-api
Windows
cd ~\Desktop
rmdir /s /q bookmarks-api
- Confirm the folder is gone by running ls (macOS) or dir (Windows) on your Desktop. You should no longer see the project folder listed.
Nice Work!
Nice Work!
You just built a production-grade OAuth2 authorization server and protected REST API from scratch using Laravel Passport. This is the same authorization pattern that powers GitHub, Google, and Stripe APIs.
Here's what you learned:
- Built a CRUD REST API for bookmarks that returns JSON responses, protected by Bearer token authentication via the auth:api middleware.
- Implemented the complete OAuth2 Authorization Code + PKCE flow end-to-end: client registration, user authorization in the browser, and token exchange without exposing a client secret.
- Enforced fine-grained access control with token scopes so a bookmarks:read token can list bookmarks but gets rejected with 403 when attempting write operations.
- Secret Mission: Implemented token refresh rotation and revocation for production-ready token lifecycle management, including short-lived access tokens and long-lived refresh tokens.
Ready to quiz yourself?