Skip to content

Resource server

How the server validates tokens and publishes its metadata.

Validating tokens

For JWT-minting authorization servers, the SDK ships JwksAccessTokenValidator. It rides the suggested firebase/php-jwt package, which stays out of the SDK's own requirements, so install it alongside:

composer require firebase/php-jwt:^7.0
use Firebase\JWT\CachedKeySet;
use Nexus\Mcp\Server\Auth\JwksAccessTokenValidator;

$validator = new JwksAccessTokenValidator(
    new CachedKeySet(
        'https://auth.example.com/.well-known/jwks.json',
        $httpClient,          // any PSR-18 client
        $requestFactory,      // any PSR-17 request factory
        $cache,               // any PSR-6 cache
        300,
        rateLimit: true,
    ),
    'https://auth.example.com',  // the `iss` every accepted token must carry
    'https://mcp.example.com/mcp',  // the resource every accepted token's `aud` must name
);

Constructing it without the package installed throws a LogicException that names the install command.

CachedKeySet fetches the JWKS synchronously the first time it meets a kid it does not hold, and it meets that kid before the signature is checked, so an unsigned token naming an unknown one costs a fetch. A PSR-18 client that blocks holds the event loop, and every other fiber with it, for the whole round trip. Keep rateLimit: true, which bounds those fetches to ten a minute per cache, and prefer a PSR-18 client built on amphp/http-client, which yields instead.

What the validator refuses

The validator refuses a token whose signature does not verify, whose iss is absent or is not the issuer you named, whose aud does not name the resource, or which carries no exp at all.

A key set may sign for several issuers, so the issuer is what bounds the tenant rather than the audience alone. A token minted with no expiry would otherwise be a permanent credential.

The validator maps the claim spellings the common providers use: scope or scp (string or list) for scopes, and azp, client_id, or cid for the authorizing client. The provider recipes name each provider's JWKS URL and quirks.

Your own validator

For anything else (opaque tokens, introspection endpoints, provider SDKs), verification stays yours:

use Nexus\Mcp\Core\Auth\VerifiedAccessToken;
use Nexus\Mcp\Server\Auth\AccessTokenValidatorInterface;

final class JwtAccessTokenValidator implements AccessTokenValidatorInterface
{
    public function validate(string $token): ?VerifiedAccessToken
    {
        $claims = $this->verifySignature($token);

        if (null === $claims || ($claims['iss'] ?? null) !== $this->expectedIssuer) {
            return null;
        }

        if (! $this->resource->matchesAudience(array_values(array_filter((array) ($claims['aud'] ?? []), is_string(...))))) {
            return null;
        }

        // Most JWT libraries check an expiry only when present, so an absent one never expires.
        if (! is_numeric($claims['exp'] ?? null) || $claims['exp'] < time()) {
            return null;
        }

        // An identity claim that is absent, empty, or not a string names nobody, so normalise all three to null.
        $subject = $claims['sub'] ?? null;
        $clientId = $claims['client_id'] ?? null;

        return new VerifiedAccessToken(
            audience: $claims['aud'],
            scopes: explode(' ', $claims['scope'] ?? ''),
            subject: is_string($subject) && '' !== $subject ? $subject : null,
            clientId: is_string($clientId) && '' !== $clientId ? $clientId : null,
            expiresAt: (int) $claims['exp'],
        );
    }
}

The validator owns signature checking, the issuer, the audience, and expiry. BearerAuthenticationMiddleware checks the audience again and enforces the endpoint's own rules on top. A token minted for another resource is refused even if a validator of your own lets it through, and so is a token handed over already expired.

The middleware's expiry check tolerates no clock skew by default, and a validator's own tolerance does not reach it. If you set JWT::$leeway for firebase/php-jwt, or your validator allows skew some other way, pass the same allowance as expiryLeewaySeconds to BearerAuthenticationMiddleware. Otherwise it refuses what the validator deliberately accepted.

Mounting the middleware

Mount it on the endpoint:

use Nexus\Mcp\Server\Transport\Http\Middleware\BearerAuthenticationMiddleware;
use Nexus\Mcp\Server\Transport\Http\SecuredHttpEndpoint;

$endpoint = new SecuredHttpEndpoint(
    $transport,
    ['https://app.example.com'],
    $responseFactory,
    $streamFactory,
    authentication: new BearerAuthenticationMiddleware(
        new JwtAccessTokenValidator(),
        'https://mcp.example.com/mcp',
        'https://mcp.example.com/.well-known/oauth-protected-resource/mcp',
        $responseFactory,
        requiredScopes: ['mcp:use'],
    ),
);

Authentication runs after CORS and DNS-rebinding protection, and before anything reads the body. An unauthorized request is turned away without being parsed.

Publishing the metadata document

Clients find your authorization server by reading a metadata document. Route ProtectedResourceMetadataHandler at both well-known paths, and name the same URL in the middleware above:

use Nexus\Mcp\Server\Transport\Http\ProtectedResourceMetadataHandler;

$metadata = new ProtectedResourceMetadataHandler(
    'https://mcp.example.com/mcp',
    ['https://auth.example.com'],
    $responseFactory,
    $streamFactory,
    scopesSupported: ['mcp:use'],
    resourceName: 'Example MCP Server',
);
Path Served by
/mcp SecuredHttpEndpoint
/.well-known/oauth-protected-resource/mcp ProtectedResourceMetadataHandler
/.well-known/oauth-protected-resource ProtectedResourceMetadataHandler

The handler serves the document only at those two paths, which RFC 9728 derives from the MCP server's own URL. Mounting it anywhere else answers 404 rather than publish the same document under a name no client will look it up by.

Serving both well-known paths is worth the two lines. A client that never saw a WWW-Authenticate header falls back to probing them, path-scoped first.

Reading the token in a handler

The validated token reaches handlers on the receive context:

$builder->addTool(new Tool(name: 'whoami'), function (CallToolRequest $request, ServerContext $context) {
    $authInfo = $context->receiveContext->authInfo;
    $subject = match (true) {
        null === $authInfo => 'anonymous',
        null === $authInfo->subject => 'authenticated, unnamed',
        default => $authInfo->subject,
    };

    return new CallToolResult(content: [new TextContent(text: $subject)]);
});

authInfo is null on an unprotected endpoint and over stdio. Its subject is separately null for an accepted token that carries no non-empty string sub claim. That is why the two are tested apart above. An authenticated caller the token cannot name is not an anonymous one.