Protect an MCP server with Cbox ID
Protect an MCP server with Cbox ID
The MCP authorization model is OAuth 2.1 with four standards on top: the resource server
publishes protected resource metadata (RFC 9728) and points at it from its 401; the
client either registers itself (RFC 7591) or presents a client ID metadata document
URL; it runs the authorization code flow with PKCE; and it names the MCP server with
resource (RFC 8707) so the token it gets back is good there and nowhere else.
This package is the authorization server half. Your application serves the MCP endpoint
and /authorize; this page is the glue between them.
What you get, and what you write
| Piece | Who |
|---|---|
RFC 8414 metadata, PAR, /oauth/token, /oauth/register, JWKS |
this package |
| RFC 9728 document for your MCP endpoint | this package, once you declare the endpoint |
resource → aud, refresh bound to it, invalid_target for anything else |
this package |
| Fetching and validating client ID metadata documents (SSRF-guarded) | this package |
| The MCP endpoint itself and its 401 | you — with BearerChallenge |
/authorize, sign-in and the consent screen |
you — with AuthorizationClients |
1. Configure
// config/cbox-id.php
'oauth' => [
// Where your /authorize lives, joined to each environment's issuer. Without it the
// discovery document advertises no code flow at all.
'authorization_endpoint_path' => '/oauth/authorize',
// Your MCP endpoint, at https://{host}/mcp in every environment.
'protected_resources' => [
[
'path' => '/mcp',
'name' => 'MCP',
'scopes' => ['mcp:read', 'mcp:write'],
'dynamic_clients' => true, // MCP clients register themselves
],
],
// Open registration, held to the MCP profile: public clients, PKCE, https or loopback
// redirect URIs, and only the scopes declared above (plus allowed protocol scopes).
'dynamic_registration' => [
'mode' => 'mcp',
'max_per_ip_per_hour' => 20,
],
// Optional: accept an https URL as client_id and read the client from it.
'client_id_metadata_documents' => [
'enabled' => true,
],
],
'prune' => [
'retention_days' => [
// Sweep self-registered clients nobody has used in 30 days.
'oauth_clients' => 30,
],
],
The endpoint is now described at https://{host}/.well-known/oauth-protected-resource/mcp:
{
"resource": "https://acme.example/mcp",
"authorization_servers": ["https://acme.example"],
"scopes_supported": ["mcp:read", "mcp:write", "offline_access"],
"bearer_methods_supported": ["header"],
"resource_name": "MCP"
}
When the list depends on more than config — a plan, a feature flag — bind your own
Cbox\Id\OAuthServer\Contracts\ProtectedResources instead.
2. Guard the MCP endpoint
Accept a token only if it is live and audienced to this endpoint. Every refusal is a 401
whose WWW-Authenticate carries resource_metadata, which is how an MCP client that knows
nothing but your URL finds everything else.
use Cbox\Id\OAuthServer\Contracts\ProtectedResources;
use Cbox\Id\OAuthServer\Contracts\TokenIntrospector;
use Cbox\Id\OAuthServer\Dpop\DpopResourceGuard;
use Cbox\Id\OAuthServer\Support\BearerChallenge;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
final class AuthenticateMcp
{
public function __construct(
private readonly ProtectedResources $resources,
private readonly TokenIntrospector $tokens,
private readonly DpopResourceGuard $dpop,
) {}
public function handle(Request $request, Closure $next, string $scope = 'mcp:read'): Response
{
// Looked up by path, so it is this environment's /mcp whatever host or proxy
// the request arrived through.
$resource = $this->resources->forMetadataPath('/.well-known/oauth-protected-resource/mcp')
?? abort(404);
$challenge = BearerChallenge::for($resource);
$token = $this->dpop->bearer($request);
$introspection = $token === null ? null : $this->tokens->introspect($token);
// `isAudience()` alone treats a token with no `aud` as first-party; every token
// this package mints carries one, so require it outright.
if ($introspection === null || ! $introspection->active
|| ! isset($introspection->claims['aud'])
|| ! $introspection->isAudience($resource->identifier)) {
return response()->json(['error' => 'invalid_token'], 401,
$challenge->withError('invalid_token')->headers());
}
if (! $introspection->hasScope($scope)) {
return response()->json(['error' => 'insufficient_scope'], 403,
$challenge->withError('insufficient_scope')->withScopes([$scope])->headers());
}
$request->attributes->set('mcp.subject', $introspection->subject);
return $next($request);
}
}
Tools that need a stronger login
A live, audienced, scoped token can still come from a password-only login made
yesterday. For tools that delete or pay, also check the login behind the token. Answer
401 with the RFC 9470 challenge, and the MCP client re-authorizes with acr_values and
max_age:
use Cbox\Id\OAuthServer\Enums\AuthenticationContextClass;
use Cbox\Id\OAuthServer\ValueObjects\AuthenticationRequirement;
$stepUp = AuthenticationRequirement::of(AuthenticationContextClass::Aal2, maxAge: 300)
->assessToken($introspection);
if (! $stepUp->isSatisfied()) {
return response()->json(['error' => 'insufficient_user_authentication'], 401,
$stepUp->challenge($challenge)->headers());
}
Your /authorize then has to honour those two parameters. See
Require step-up authentication.
3. Handle the MCP client at /authorize
Three calls cover what is new: resolve the client (registered or described by a document),
read the one resource, and ask the audience resolver whether that client may be audienced
there — the same resolver the token endpoint asks, so /authorize can never accept what
redemption would refuse.
use Cbox\Id\OAuthServer\Contracts\AudienceResolver;
use Cbox\Id\OAuthServer\Contracts\AuthorizationClients;
use Cbox\Id\OAuthServer\Contracts\AuthorizationCodes;
use Cbox\Id\OAuthServer\Exceptions\InvalidAudience;
use Cbox\Id\OAuthServer\Exceptions\InvalidClientMetadataDocument;
use Cbox\Id\OAuthServer\Support\ResourceParameter;
// $pushed is the consumed PAR payload, or null for a plain query request.
try {
$authorizing = app(AuthorizationClients::class)->resolve((string) $clientId);
} catch (InvalidClientMetadataDocument $e) {
// No verified redirect URI yet: render, never redirect (RFC 6749 §4.1.2.1).
return $this->failure($e->getMessage());
}
if ($authorizing === null || ! $authorizing->allowsRedirectUri((string) $redirectUri)) {
return $this->failure('Unknown client or redirect URI.');
}
try {
// One resource per request; a repeated one is invalid_target. PAR already validated
// the pushed value, and fromValue() reads it the same way.
$resource = $pushed !== null
? ResourceParameter::fromValue($pushed['resource'] ?? null)
: ResourceParameter::fromRequest($request);
$granted = app(AudienceResolver::class)->resolve(
$authorizing->client,
array_values(array_filter($requestedScopes, $authorizing->client->allows(...))),
$resource,
);
} catch (InvalidAudience $e) {
return $this->redirectError($redirectUri, $e->error, $state, $e->getMessage());
}
// $granted->scopes is what the token will carry — show those on the consent screen.
// Never skip consent for a self-registered client:
$mustAsk = $authorizing->consentRequired() || ! $this->firstPartySkip($authorizing->client);
// For a metadata document client, lead with the verified host, not the self-declared name:
// "{$authorizing->documentHost} wants to use MCP on your behalf"
Once the person approves, bind the code to the resource:
$code = app(AuthorizationCodes::class)->issue(
$authorizing->client->client_id, $userId, $organizationId, $redirectUri,
$requestedScopes, $codeChallenge, resource: $resource,
);
The token endpoint does the rest: it refuses a different resource at redemption, stamps
aud, and binds any refresh token to the same audience.
4. What the MCP client sees
POST https://acme.example/mcp→401,WWW-Authenticate: Bearer resource_metadata="https://acme.example/.well-known/oauth-protected-resource/mcp", error="invalid_token".- It reads that document, then
/.well-known/oauth-authorization-serverfrom the listed authorization server. - It registers at
registration_endpoint(token_endpoint_auth_method: none, its loopback or https callback) — or, whenclient_id_metadata_document_supportedistrue, uses its document URL asclient_idand skips registration. - It opens
/oauth/authorize?…&code_challenge=…&resource=https://acme.example/mcp. - It redeems the code at
/oauth/tokenwith the sameresource, and gets a token whoseaudishttps://acme.example/mcp. - It refreshes with
refresh_token(and the sameresource, or none) until the grant ends.
5. Test it
it('serves MCP to a client with a metadata document', function (): void {
$this->declareProtectedResource('/mcp', ['mcp:read']);
$this->fakeClientMetadataDocuments()->serve('https://client.example/meta.json', [
'client_id' => 'https://client.example/meta.json',
'client_name' => 'Example',
'redirect_uris' => ['http://127.0.0.1:33418/callback'],
]);
$client = app(AuthorizationClients::class)->resolve('https://client.example/meta.json');
expect($client->documentHost)->toBe('client.example');
});
fakeClientMetadataDocuments() answers every document fetch from memory; ->refuseAsUnsafe($url)
makes one fail the way the SSRF guard refuses a private address.
Limits
- One resource per token. RFC 8707 allows several; this server refuses a repeated
resourcewithinvalid_target, because a token valid at two resource servers can be replayed by either at the other. A client that needs two asks twice. - A refresh token never changes audience. Naming a different
resourceon refresh isinvalid_target, and the refresh token survives the refusal. - Metadata documents are a draft. The validation follows
draft-ietf-oauth-client-id-metadata-document; details may move before it is an RFC.
Inline
jwksin a document is refused — publish keys atjwks_uri. - The package does not serve
/authorizeor the MCP endpoint; both are yours, as above.