DEPLOYMENT AND PRODUCTION OPERATIONS
NETWORK INGRESS HARDENING AND API GATEWAYS

Securing a Remote Laravel MCP Server with OAuth 2.1 and PKCE

If you have already built an MCP server in Laravel, exposing it locally over stdio is the easy part. The moment you put it on a public URL so Claude Desktop, Cursor, or another remote client can reach it, Laravel MCP server authentication becomes the actual engineering problem. If you haven’t stood up the server itself yet, start with our guide to building a production MCP server in Laravel first, since this article assumes the transport and tool definitions already exist.

This is one piece of the broader AI deployment operations picture: getting an AI system past local development into something you can put in front of external clients without it becoming the weakest point in your stack. We’re not covering OAuth theory here. We’re covering what changed in the 2026-07-28 MCP specification, what that means for a Laravel implementation specifically, and where teams get the token validation layer wrong.

What the Current MCP Spec Actually Requires

MCP servers are formally OAuth 2.1 resource servers. That single sentence carries more weight than it looks like. Your Laravel MCP server does not issue tokens itself. It delegates that to a real authorization server (Passport, in our case) and its only job is to validate a bearer token on every tool-call request and enforce what that token is allowed to touch.

Three requirements matter for implementation, and two of them are easy to miss if you’re working from older MCP tutorials:

  • PKCE is mandatory for the authorization code flow. There is no non-PKCE fallback for public or first-party clients.
  • RFC 9728 (Protected Resource Metadata) must be published by your MCP server so clients can discover which authorization server to talk to, without you hardcoding that relationship into every client’s configuration.
  • RFC 8707 (Resource Indicators) must be honored by the authorization server so a token issued for one MCP server can’t be replayed against a different one. This is the piece that actually prevents a rogue or compromised MCP server from harvesting tokens meant for someone else’s resource.

There’s also a shift in how clients identify themselves. Dynamic Client Registration (RFC 7591) used to be the default recommendation. As of the current spec, it’s deprecated in favor of Client ID Metadata Documents (CIMD), where the client’s ID is a stable HTTPS URL it controls, pointing to a JSON document describing itself, rather than a client_id minted by your authorization server at registration time. DCR still works and some MCP clients in the wild only support DCR, so we’re covering both, but CIMD is where new implementations should default.

[Architect’s Note] If you’re building this today, don’t wire up RFC 7591 as your primary path and treat CIMD as a “future enhancement.” That ordering will bite you within a year as more clients drop DCR support entirely. Build CIMD-first with DCR as the compatibility fallback, not the other way around.

Sanctum or Passport: There Isn’t Actually a Choice Here

The brief for this kind of article usually frames Sanctum against Passport as a genuine trade-off. For the primary OAuth 2.1 authorization code flow with PKCE and CIMD, it isn’t. Sanctum does not implement OAuth2. It issues personal access tokens and handles first-party SPA cookie authentication. It has no concept of an authorization code grant, no PKCE support, and no mechanism for identifying external clients dynamically. None of that is a criticism of Sanctum, it was never built to do this job.

Passport is a full OAuth2 authorization server built on the League OAuth2 Server package, and it’s the only one of the two that can actually perform the flow this article is about.

Where Sanctum still has a legitimate role: if you’re building internal, first-party tooling that calls your own MCP server (a dashboard you control, calling your own tools, no external client negotiating an OAuth handshake), Sanctum’s simpler token model is fine there, and our Sanctum API authentication guide covers that flow in full. But that’s a different use case from securing a server that Claude Desktop or Cursor connects to independently. Don’t let the two get conflated in your architecture, they solve different problems and mixing them into one guard on the same routes is how you end up with inconsistent token semantics across your MCP endpoints.

[Production Pitfall] Laravel Passport 13.0.0 through 13.7.0 has a real vulnerability worth knowing before you deploy this: CVE-2026-39976. In the client_credentials grant, the JWT sub claim gets set to the client identifier since there’s no user in that grant. Passport’s token guard was passing that value directly to retrieveById() without validating it was actually a user ID, which meant a machine-to-machine token could, in the wrong circumstances, authenticate as an unrelated real user. It’s fixed in 13.7.1. Pin your composer.json accordingly:

composer require laravel/passport:^13.7.1

Installing and Configuring Passport for PKCE

Standard install, but with the PKCE-specific step that’s easy to skip:

composer require laravel/passport:^13.7.1
php artisan install:api --passport
php artisan migrate

Register the middleware in bootstrap/app.php. Do not add this to app/Http/Kernel.php, that file doesn’t exist in Laravel 13’s application skeleton:

// bootstrap/app.php
use Laravel\Passport\Http\Middleware\CheckScopes;
use Laravel\Passport\Http\Middleware\CheckForAnyScope;

->withMiddleware(function (Middleware $middleware) {
    $middleware->alias([
        'scopes' => CheckScopes::class,
        'scope' => CheckForAnyScope::class,
    ]);
})

Create a PKCE-enabled public client for your MCP integration, since MCP clients like Claude Desktop and Cursor cannot securely store a client secret:

php artisan passport:client --public --name="MCP Remote Client"

Passport’s authorization code grant with PKCE handles the code_verifier and code_challenge exchange per RFC 7636 out of the box, S256 challenge method, 43-128 character verifier. You don’t need to implement that part yourself. What you do need to build is the discovery and scoping layer around it, because Passport doesn’t ship RFC 9728, RFC 8707, or CIMD support natively.

Publishing Protected Resource Metadata (RFC 9728)

Your MCP server needs to expose a well-known endpoint that tells clients which authorization server issues valid tokens for it. Register the route in routes/console.php-adjacent API routing (your normal routes/api.php), and return it unauthenticated:

// routes/api.php
Route::get('/.well-known/oauth-protected-resource', function () {
    return response()->json([
        'resource' => config('app.url') . '/mcp',
        'authorization_servers' => [config('app.url')],
        'bearer_methods_supported' => ['header'],
        'resource_documentation' => config('app.url') . '/docs/mcp',
    ]);
});

When a client hits your MCP endpoint without a valid token, return a 401 with a WWW-Authenticate header pointing at this metadata document rather than a bare 401:

return response()->json(['error' => 'unauthorized'], 401)
    ->header('WWW-Authenticate', sprintf(
        'Bearer resource_metadata="%s/.well-known/oauth-protected-resource"',
        config('app.url')
    ));

This is what lets a client like Claude Desktop discover your authorization server automatically instead of you distributing configuration out of band for every deployment.

Enforcing Resource Indicators (RFC 8707)

This is the requirement that’s most commonly skipped, because it doesn’t produce a visible failure until it’s exploited. Without it, a token issued for MCP Server A can be presented to MCP Server B, and if Server B doesn’t check the audience, it’ll accept it.

Require the resource parameter on every authorization and token request, and validate it against the issued token’s audience claim on every incoming call:

// app/Http/Middleware/ValidateTokenAudience.php
class ValidateTokenAudience
{
    public function handle(Request $request, Closure $next)
    {
        $token = $request->user()->token();
        $audience = $token->getAttribute('aud') ?? null;

        if ($audience !== config('app.url') . '/mcp') {
            abort(403, 'Token was not issued for this resource.');
        }

        return $next($request);
    }
}

[Edge Case Alert] If you’re running multiple MCP servers behind the same Passport instance (common in a multi-product setup), audience validation is the only thing standing between a token leaked from one server and unauthorized access to another. Test this path explicitly, it’s the kind of gap that passes every functional test and fails the first time someone tries it deliberately.

Client Identification: CIMD First, DCR as Fallback

For CIMD, the client’s identifier is a URL it controls, hosting a JSON metadata document. Your authorization server fetches that document rather than storing a client record:

// app/Services/Mcp/ClientIdMetadataResolver.php
class ClientIdMetadataResolver
{
    public function resolve(string $clientIdUrl): array
    {
        $response = Http::timeout(5)->get($clientIdUrl);

        if (! $response->successful()) {
            abort(400, 'Unable to resolve client metadata.');
        }

        $metadata = $response->json();

        if ($metadata['client_id'] !== $clientIdUrl) {
            abort(400, 'client_id mismatch in metadata document.');
        }

        return $metadata;
    }
}

Keep RFC 7591 DCR available for clients that haven’t adopted CIMD yet, gated behind the same registration endpoint but treated as the legacy path:

Route::post('/oauth/register', [DynamicClientRegistrationController::class, 'store'])
    ->middleware('throttle:10,1');

Rate-limit this endpoint deliberately. An open DCR endpoint without throttling is an invitation for registration flooding.

Multi-Tenant Token Scoping for MCP Tool Calls

Once a token is validated and its audience confirmed, the last gate is whether it’s allowed to call the specific tool being requested, for the specific tenant it belongs to. Passport scopes map cleanly onto this:

Passport::tokensCan([
    'tools:read' => 'Read-only access to MCP tool listings',
    'tools:invoke:tenant-scoped' => 'Invoke tools scoped to the token\'s tenant',
    'tools:invoke:admin' => 'Invoke tools without tenant restriction',
]);

Enforce it at the route level and confirm the tenant match inside the controller, since scope alone doesn’t guarantee tenant isolation:

Route::post('/mcp/tools/{tool}/invoke', [McpToolController::class, 'invoke'])
    ->middleware(['auth:api', 'scope:tools:invoke:tenant-scoped', ValidateTokenAudience::class]);
public function invoke(Request $request, string $tool)
{
    $tenantId = $request->user()->token()->getAttribute('tenant_id');

    if ($tenantId !== $request->route('tenant')?->id) {
        abort(403, 'Token tenant does not match requested resource.');
    }

    // proceed to tool execution
}

[Word to the Wise] Don’t rely on scope names alone to imply tenant boundaries. A scope tells you what class of action is permitted. It says nothing about which tenant’s data that action applies to. That check needs to be explicit, in code, on every tool invocation, not inferred from the token’s existence.

The Full Authorization Flow

MCP CLIENT Claude Desktop AUTH SERVER Laravel Passport RESOURCE SERVER Laravel MCP Server 1. Request without token 2. 401 + WWW-Authenticate → RFC 9728 URL 3. Client Identification: CIMD URL (or RFC 7591 DCR fallback) 4. Auth Request + PKCE code_challenge 5. Authorization code 6. Token exchange + code_verifier 7. Access token (aud = resource URL) 8. Tool call + Bearer token 9. Server Validates: • Audience (RFC 8707) • Granted Scopes • tenant_id match before execution

Summary

Securing a remote Laravel MCP server isn’t a single OAuth integration, it’s four coordinated pieces: PKCE-enforced token exchange through Passport, RFC 9728 discovery metadata so clients find the right authorization server automatically, RFC 8707 audience validation so a token can’t be replayed across servers, and explicit tenant and scope checks at the tool-invocation layer. Skip any one of those and you have something that looks like OAuth but doesn’t close the actual attack surface remote MCP access opens up.

The trade-off worth sitting with is client identification. CIMD is clearly where the spec is heading and it removes the operational overhead of managing a growing client registry, but you’ll be supporting RFC 7591 DCR alongside it for a while yet, since not every client in the wild has caught up. Build for that transition rather than betting on one mechanism disappearing on a schedule you don’t control.

Everything covered here sits on top of the ingress layer, and it’s worth reading alongside our guide to PII sanitization and prompt injection defense for the same servers, since token validation and payload hardening are two different gates that both need to hold. If you’re taking this server to production, our Laravel deployment guide covers the infrastructure side once the auth layer here is in place.

If you’ve deployed OAuth 2.1 in front of an MCP server, what’s tripped you up in production: client identification drift, scope design, or something in the tenant isolation layer that only showed up under real traffic?


Frequently Asked Questions

Does Sanctum work at all for MCP server authentication?

Not for the external OAuth 2.1 flow with PKCE and CIMD, since Sanctum doesn’t implement OAuth2. It’s viable only for internal, first-party callers that don’t need an authorization code negotiation, which is a narrower use case than what this guide covers.

Do I need to support both CIMD and RFC 7591 DCR, or can I pick one?

Support both if you expect a mixed client population. CIMD is the preferred path under the current spec, but DCR is retained for backward compatibility, and some MCP clients in active use haven’t adopted CIMD yet.

What happens if I skip RFC 8707 resource indicators?

A token issued for one MCP server could potentially be accepted by another if that server doesn’t validate the audience claim. This is the specific gap RFC 8707 closes, and it’s not optional under the current spec for exactly that reason.

Can I use Passport’s default token guard without the audience validation middleware?

You can, but it defeats the point of RFC 8707 compliance. The token guard authenticates the token as valid; it does not check which resource that token was scoped to. That check has to be explicit in your middleware stack.

Is the client_credentials grant safe to use for machine-to-machine MCP calls after upgrading past 13.7.1?

Yes, 13.7.1 fixes the identifier validation issue tracked in CVE-2026-39976. Confirm your composer.lock reflects the patched version rather than just the constraint in composer.json, since a constraint alone doesn’t guarantee the installed version.

Dewald Hugo

A software architect with 15+ years of experience in the PHP and Laravel ecosystem. Dewald created Origin Main to provide the engineering rigour required to integrate AI into professional, high-concurrency production systems. He writes for developers who care less about "getting it to work" and more about "getting it to last".

Subscribe
Notify of
0 Comments
Oldest
Newest Most Voted
Scroll to Top