This page is for the code that serves your API or MCP endpoint and must accept only valid tokens.
Ask the server for a middleware that belongs to one resource:
$middleware = $server->bearerMiddleware(
'https://example.com/api', // the resource
'https://example.com/.well-known/oauth-protected-resource/api', // optional metadata URL
['read'], // optional required scopes
);bearerMiddleware(string $resource, ?string $resourceMetadataUrl = null, array $requiredScopes = [])
returns a PSR-15 MiddlewareInterface. It throws InvalidArgumentException at build time when the
resource is not declared, or when a required scope is not declared by that resource. A typo shows
up when you build the route, not as a 401 in production.
Use it with any PSR-15 stack:
$response = $middleware->process($request, $yourApiHandler);For each request it:
- Reads
Authorization: Bearer <token>. - Validates the token for this resource (see below).
- Checks that the token holds every required scope.
- Adds two attributes to the request and calls your handler.
| Attribute | Type | Content |
|---|---|---|
ValidatedToken::class |
ValidatedToken |
The token with its owner, client, resource and scopes |
ResourceOwner::class |
ResourceOwner |
The person the token acts for, freshly loaded through findBySubject(); for a client credentials token, the identity your forClient() returns |
use CakeDC\OAuth\Server\Owner\ResourceOwner;
use CakeDC\OAuth\Server\Token\ValidatedToken;
public function handle(ServerRequestInterface $request): ResponseInterface
{
$owner = $request->getAttribute(ResourceOwner::class);
$token = $request->getAttribute(ValidatedToken::class);
if ($token->hasScope('write')) {
// ...
}
return new Response(200, ['Content-Type' => 'application/json'], json_encode(['user' => $owner->subject]));
}Use $owner->identity to get your own user object back. Do not trust anything else the client sent
about who they are.
Failures are JSON responses with a WWW-Authenticate: Bearer ... header (RFC 6750). Members, when
they apply: resource_metadata="<url>" (when you passed one), scope="a b" (the required scopes),
error="..." and error_description="...".
| Case | Status | Header | Body |
|---|---|---|---|
No Authorization header, or another scheme, or an empty bearer |
401 | Bearer resource_metadata="...", scope="read" |
{} |
| Malformed header | 400 | Bearer resource_metadata="...", error="invalid_request", error_description="Malformed Authorization header." |
error object |
| Unknown, revoked, expired or foreign token, or owner gone | 401 | Bearer resource_metadata="...", error="invalid_token", error_description="..." |
error object |
| Valid token, missing scope | 403 | Bearer resource_metadata="...", scope="read", error="insufficient_scope", error_description="The access token lacks a required scope." |
error object |
With no token the challenge has no error, as RFC 6750 asks, and the body is an empty object {}.
Clients use the scope and resource_metadata members to find out what to ask for.
The error_description of an invalid_token answer tells the developer why:
The access token is invalid.(unknown, or a refresh token)The access token was revoked.The access token expired.The access token was issued for another resource.The client of this access token was revoked.The owner of this access token no longer has access.The token no longer grants any scope.(the computed scopes are empty, see below)Personal access tokens are disabled.(a personal token whilepersonalTokensis off)
resource_metadata points to the RFC 9728 document of your resource. It tells clients which
authorization server to use. The library names the URL in challenges, but your application serves the
document. A minimal one:
{
"resource": "https://example.com/api",
"authorization_servers": ["https://auth.example.com"],
"scopes_supported": ["read", "write"],
"bearer_methods_supported": ["header"]
}Serve it at /.well-known/oauth-protected-resource followed by the resource's path (here
/.well-known/oauth-protected-resource/api). With the MCP SDK the SDK serves it for you. See
MCP.
Browser clients send a preflight (OPTIONS with an Access-Control-Request-Method header) without a
token. The middleware lets exactly those requests through to your handler, without the attributes
above, and your handler must answer them without side effects. Any other OPTIONS request is
challenged like every other request.
Put your CORS layer outside this middleware, so it adds its headers to the 401 and 403 answers as
well, and expose the WWW-Authenticate header (Access-Control-Expose-Headers).
When you are not on PSR-15, or need your own responses, validate by hand:
use CakeDC\OAuth\Server\Token\ValidatedToken;
use CakeDC\OAuth\Server\Token\ValidationFailure;
$result = $server->tokenValidator()->validate($bearer, 'https://example.com/api');
if ($result instanceof ValidationFailure) {
// $result->error is 'invalid_token'; $result->description says why.
return unauthorized($result->description);
}
// $result is a ValidatedToken.validate(string $token, string $resource, bool $touch = true): ValidatedToken|ValidationFailure.
It throws InvalidArgumentException when $resource is not declared (a configuration error).
With $touch set to true a use is recorded (last_used_at of the token and its client, at most once
a minute per token). Pass false to look without recording, as introspection does.
| Property | Type | Meaning |
|---|---|---|
token |
Token |
The stored token: id, type (access or personal), subject, subjectType, resource, scopes, createdAt, expiresAt, name and more |
owner |
ResourceOwner |
Its owner, loaded now: findBySubject() for a person's token, forClient() for a client credentials token |
client |
Client or null |
The client. null for personal tokens |
resource |
ProtectedResource |
The resource |
scopes |
list<string> |
Scopes held right now, implied ones included |
hasScope(string $scope): bool tells whether the token holds a scope.
scopes is not a copy of what was granted. It is computed on every request:
- Start with the scopes granted to the token.
- Add the scopes they imply (through
implies, transitively). - Keep only what the client may hold (its registered scopes, if it has a limit).
- Keep only what the person may hold now (
scopesFor()). - Keep only what the resource still declares.
If the result is empty the token is refused outright (invalid_token, The token no longer grants any scope.): a token that holds nothing is not an authenticated request with no permissions. A refresh applies
the client and person allowances (steps 3 and 4) to the new access token, reduced to the minimal set (a
client narrowed from content:write to content:read gets a content:read token), and answers
invalid_scope when the client may hold none of the scopes, invalid_grant when the person may hold none.
The new refresh token keeps the scopes of the one presented (minus scopes the resource no longer declares),
so a person promoted again, or a client widened again, gets the scopes back at the next refresh.
So a role change, a removed scope in the configuration or a narrowed client takes effect at the next
request, without revoking anything. A token for content:publish that implies content:write passes a
content:write check.
Personal tokens (personal type) follow the same rules, and have no client.