This guide documents the complete setup process for implementing OAuth authentication in a Laravel MCP (Model Context Protocol) server using Laravel Passport.
This project has been fully configured and tested with:
- OAuth 2.0 authentication with PKCE support
- UUID primary keys for users
- Session-based web authentication
- Protected and public MCP endpoints
- MCP Inspector integration
If this is already set up, just run:
# Start the server
php artisan serve
# In another terminal, test with MCP Inspector
php artisan mcp:inspector mcp/adminOAuth Credentials:
- Check your database for the client ID and secret in
oauth_clientstable - Test user:
test@example.com/password
- Laravel 11.x
- PHP 8.1+
- MySQL 8.0+
- Composer
php artisan install:api --passportThis command will:
- Install Laravel Passport package
- Publish and run Passport migrations
- Generate encryption keys for secure access tokens
Update app/Models/User.php:
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
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, Notifiable, HasUuids;
// ... rest of your model
}Important: The HasUuids trait is required if your users table uses UUID primary keys.
Update config/auth.php:
'guards' => [
'web' => [
'driver' => 'session',
'provider' => 'users',
],
'api' => [
'driver' => 'passport', // Changed from 'token' to 'passport'
'provider' => 'users',
],
],Critical: If using UUIDs, update Passport migrations to use foreignUuid instead of foreignId:
Edit these migration files:
database/migrations/*_create_oauth_auth_codes_table.phpdatabase/migrations/*_create_oauth_access_tokens_table.phpdatabase/migrations/*_create_oauth_device_codes_table.php
Change:
$table->foreignId('user_id')->index();To:
$table->foreignUuid('user_id')->index();Also update database/migrations/*_create_users_table.php sessions table:
$table->foreignUuid('user_id')->nullable()->index();Then run migrations:
php artisan migrate:freshCORS (Cross-Origin Resource Sharing) is critical for OAuth flows, especially when the MCP inspector or AI agents run on different origins.
Update config/cors.php:
<?php
return [
'paths' => ['api/*', 'mcp/*', 'oauth/*', '.well-known/*'],
'allowed_methods' => ['*'],
'allowed_origins' => ['*'], // For production, specify exact origins
'allowed_origins_patterns' => [],
'allowed_headers' => ['*'],
'exposed_headers' => [],
'max_age' => 0,
'supports_credentials' => true, // Required for OAuth with cookies
];- paths: Include
oauth/*for OAuth endpoints and.well-known/*for OAuth discovery - allowed_origins: Set to
['*']for development. In production, specify exact origins like['https://your-domain.com'] - supports_credentials: Must be
trueto allow cookies and authentication headers in cross-origin requests - allowed_methods:
['*']allows all HTTP methods (GET, POST, OPTIONS, etc.) - allowed_headers:
['*']allows all headers includingAuthorizationand custom headers
For production, tighten CORS settings:
'allowed_origins' => [
'https://your-production-domain.com',
'https://mcp-inspector.example.com',
],
'allowed_methods' => ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
'supports_credentials' => true,Update app/Providers/AppServiceProvider.php:
use Laravel\Passport\Passport;
public function boot(): void
{
Passport::authorizationView('mcp.authorize');
}php artisan vendor:publish --tag=mcp-viewsThis creates resources/views/mcp/authorize.blade.php.
Edit resources/views/mcp/authorize.blade.php and replace the @vite directive with inline styles to avoid loading issues:
<style>
body { font-family: sans-serif; margin: 0; padding: 0; }
.bg-background { background: #f5f5f5; }
.text-foreground { color: #333; }
.bg-card { background: white; }
.text-card-foreground { color: #333; }
.border { border: 1px solid #e5e7eb; }
.rounded-lg { border-radius: 0.5rem; }
.shadow-sm { box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05); }
.text-primary { color: #4f46e5; }
.bg-primary { background: #4f46e5; }
.text-primary-foreground { color: white; }
.bg-muted\/50 { background: rgba(243, 244, 246, 0.5); }
.text-muted-foreground { color: #6b7280; }
button:hover { opacity: 0.9; }
</style>Edit resources/views/mcp/authorize.blade.php and update the hidden state inputs:
Change:
<input type="hidden" name="state" value="">To:
<input type="hidden" name="state" value="{{ $request->state ?? '' }}">This is required for both the approve and deny forms.
Update routes/ai.php:
<?php
use App\Mcp\Servers\AdminServer;
use App\Mcp\Servers\WarriorServer;
use Laravel\Mcp\Facades\Mcp;
// OAuth discovery and client registration routes
Mcp::oauthRoutes();
// Public MCP server (no auth)
Mcp::web('/mcp/warrior', WarriorServer::class);
// Admin MCP server (OAuth protected)
Mcp::web('/mcp/admin', AdminServer::class)
->middleware('auth:api');You need a web authentication system for users to login before authorizing OAuth clients.
Create routes/auth.php:
<?php
use App\Http\Controllers\Auth\AuthenticatedSessionController;
use Illuminate\Support\Facades\Route;
Route::middleware('guest')->group(function () {
Route::get('login', [AuthenticatedSessionController::class, 'create'])->name('login');
Route::post('login', [AuthenticatedSessionController::class, 'store']);
});
Route::middleware('auth')->group(function () {
Route::get('logout', [AuthenticatedSessionController::class, 'destroy'])->name('logout');
});Create app/Http/Controllers/Auth/AuthenticatedSessionController.php:
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Http\Requests\Auth\LoginRequest;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\View\View;
class AuthenticatedSessionController extends Controller
{
public function create(Request $request): View
{
return view('auth.login');
}
public function store(LoginRequest $request): RedirectResponse
{
$request->authenticate();
$request->session()->regenerate();
return redirect()->intended(route('dashboard'));
}
public function destroy(Request $request): RedirectResponse
{
Auth::guard('web')->logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
return redirect('/');
}
}Create app/Http/Requests/Auth/LoginRequest.php:
<?php
namespace App\Http\Requests\Auth;
use Illuminate\Auth\Events\Lockout;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
class LoginRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'email' => ['required', 'string', 'email'],
'password' => ['required', 'string'],
];
}
public function authenticate(): void
{
$this->ensureIsNotRateLimited();
if (! Auth::attempt($this->only('email', 'password'), $this->boolean('remember'))) {
RateLimiter::hit($this->throttleKey());
throw ValidationException::withMessages([
'email' => trans('auth.failed'),
]);
}
RateLimiter::clear($this->throttleKey());
}
public function ensureIsNotRateLimited(): void
{
if (! RateLimiter::tooManyAttempts($this->throttleKey(), 5)) {
return;
}
event(new Lockout($this));
$seconds = RateLimiter::availableIn($this->throttleKey());
throw ValidationException::withMessages([
'email' => trans('auth.throttle', [
'seconds' => $seconds,
'minutes' => ceil($seconds / 60),
]),
]);
}
public function throttleKey(): string
{
return Str::transliterate(Str::lower($this->string('email')).'|'.$this->ip());
}
}Create resources/views/auth/login.blade.php:
<!DOCTYPE html>
<html>
<head>
<title>Login - {{ config('app.name') }}</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
</head>
<body style="display:flex;align-items:center;justify-content:center;min-height:100vh;font-family:sans-serif;background:#f5f5f5;margin:0;">
<div style="background:white;padding:40px;border-radius:8px;box-shadow:0 2px 10px rgba(0,0,0,0.1);max-width:400px;width:100%;">
<h2 style="margin:0 0 10px;text-align:center;">MCP OAuth Login</h2>
<p style="color:#666;text-align:center;margin:0 0 30px;">Login to authorize the application</p>
@if($errors->any())
<div style="background:#fee;color:#c33;padding:10px;border-radius:4px;margin-bottom:20px;">
{{ $errors->first() }}
</div>
@endif
<form method="POST" action="/login">
@csrf
<div style="margin-bottom:20px;">
<label style="display:block;margin-bottom:5px;font-weight:500;">Email</label>
<input type="email" name="email" value="{{ old('email') }}" required autofocus
style="width:100%;padding:10px;border:1px solid #ddd;border-radius:4px;font-size:14px;">
</div>
<div style="margin-bottom:20px;">
<label style="display:block;margin-bottom:5px;font-weight:500;">Password</label>
<input type="password" name="password" required
style="width:100%;padding:10px;border:1px solid #ddd;border-radius:4px;font-size:14px;">
</div>
<button type="submit" style="width:100%;padding:12px;font-size:16px;cursor:pointer;background:#4f46e5;color:white;border:none;border-radius:6px;font-weight:500;">
Login
</button>
</form>
</div>
</body>
</html>composer require laravel/breeze --dev
php artisan breeze:install blade
php artisan migrate
npm install && npm run buildUpdate routes/web.php:
<?php
use Illuminate\Support\Facades\Route;
Route::get('/', function () {
return view('welcome');
});
Route::middleware(['auth'])->group(function () {
Route::get('/dashboard', function () {
return 'Dashboard - You are logged in as ' . auth()->user()->email;
})->name('dashboard');
});
require __DIR__.'/auth.php';Note: No redirect from /authorize to /oauth/authorize is needed. The Mcp::oauthRoutes('oauth') call automatically registers Passport routes at /oauth/authorize.
php artisan passport:clientWhen prompted:
- Enter client name (e.g., "MCP Admin Inspector")
- Leave redirect URI empty (press Enter)
Save the generated Client ID and Client Secret.
The redirect URI needs to be stored as a JSON array. Update it manually:
mysql -u root your_database_nameUPDATE oauth_clients
SET redirect_uris = '["http://localhost:6274/oauth/callback"]'
WHERE id = 'your-client-id';Or use tinker:
php artisan tinker$client = \Laravel\Passport\Client::find('your-client-id');
$client->redirect = ['http://localhost:6274/oauth/callback'];
$client->save();php artisan tinker\App\Models\User::create([
'name' => 'Test User',
'email' => 'test@example.com',
'password' => bcrypt('password')
]);- Start MCP inspector:
php artisan mcp:inspector mcp/admin-
In the inspector dashboard:
- Enter your Client ID
- Enter your Client Secret
- Click "Connect"
-
Browser will open to authorization page:
- Login with your test user credentials
- Click "Authorize"
- Browser redirects back to inspector
- Inspector now has access to protected MCP server
┌─────────────┐ ┌──────────────┐
│ │ 1. Request /mcp/admin │ │
│ MCP │───────────────────────────────────>│ Laravel │
│ Inspector │ │ MCP App │
│ │ 2. 401 Unauthenticated │ │
│ │<───────────────────────────────────│ │
└─────────────┘ └──────────────┘
│ │
│ 3. Redirect to /oauth/authorize │
│──────────────────────────────────────────────────>
│ │
│ 4. Not logged in → redirect to /login │
│<──────────────────────────────────────────────────
│ │
│ 5. User enters credentials │
│──────────────────────────────────────────────────>
│ │
│ 6. Login successful → redirect to /oauth/authorize
│<──────────────────────────────────────────────────
│ │
│ 7. Show authorization screen │
│<──────────────────────────────────────────────────
│ │
│ 8. User clicks "Authorize" │
│──────────────────────────────────────────────────>
│ │
│ 9. Redirect with authorization code │
│<──────────────────────────────────────────────────
│ │
│ 10. Exchange code for access token │
│──────────────────────────────────────────────────>
│ │
│ 11. Return access token │
│<──────────────────────────────────────────────────
│ │
│ 12. Access /mcp/admin with token │
│──────────────────────────────────────────────────>
│ │
│ 13. Return MCP server response │
│<──────────────────────────────────────────────────
Solution: Check that:
- Client ID exists in
oauth_clientstable redirect_urisis stored as JSON array:["http://localhost:6274/oauth/callback"]- Client is not revoked
- You're using the plain text client secret (not the hashed version from database)
Solution:
- Make sure you're using the original client secret shown when you created the client
- The database stores a hashed version - you need the original plain text secret
- If lost, create a new client:
php artisan passport:client --no-interaction
Solution:
- Most common: Sessions table
user_idcolumn type mismatch- If using UUIDs: Change
foreignId('user_id')toforeignUuid('user_id')in sessions table migration - Run
php artisan migrate:fresh
- If using UUIDs: Change
- Clear sessions:
mysql -u root your_db -e "TRUNCATE TABLE sessions;" - Clear caches:
php artisan optimize:clear - Ensure
SESSION_DRIVER=databasein.env
Solution:
- Check that the
stateparameter is being passed in the authorization form - Update
resources/views/mcp/authorize.blade.php:<input type="hidden" name="state" value="{{ $request->state ?? '' }}">
Solution:
- Critical: All foreign keys referencing
users.idmust useforeignUuid()if users table uses UUIDs - Update these migrations:
*_create_oauth_auth_codes_table.php*_create_oauth_access_tokens_table.php*_create_oauth_device_codes_table.php*_create_users_table.php(sessions table)*_create_transactions_table.php(if exists)
- Add
HasUuidstrait to User model - Run
php artisan migrate:fresh
Solution:
- Remove
@vitedirective from authorize view - Use inline CSS instead
- Disable JavaScript in authorize view if needed
Solution:
- Verify
config/cors.phpincludesoauth/*in paths - Ensure
supports_credentialsis set totrue - Check that
allowed_originsincludes the requesting origin - Clear config cache:
php artisan config:clear
Solution:
- Ensure CORS middleware is registered in
bootstrap/app.php - Verify
allowed_methodsincludesOPTIONS - Check that web server (nginx/Apache) doesn't block OPTIONS requests
-
Never commit OAuth keys: Add to
.gitignore:storage/oauth-*.key -
Use HTTPS in production: Update
.env:APP_URL=https://your-domain.com -
Restrict CORS origins in production: Update
config/cors.php:'allowed_origins' => ['https://your-domain.com'],
-
Rotate secrets regularly: Generate new OAuth clients periodically
-
Implement scopes: Define specific permissions for different access levels
-
Monitor OAuth usage: Log authorization attempts and token usage
-
Rate limit OAuth endpoints: Protect against brute force attacks
You now have a fully functional Laravel MCP server with OAuth authentication and CORS configured:
- ✅ Public MCP server at
/mcp/warrior(no auth required) - ✅ Protected MCP server at
/mcp/admin(OAuth required) - ✅ OAuth authorization flow with user login
- ✅ CORS configured for cross-origin requests
- ✅ Secure token-based API access
AI agents can now authenticate and access your protected MCP server using standard OAuth 2.0 flows from any origin.