An OAuth 2.1 authorization server for any PHP application, ready for MCP servers.
It is a library, not a framework. It speaks PSR-7 and PSR-15, keeps its data through a small storage interface, and leaves your login and your users to you. You get a set of request handlers, one per endpoint, and route them in the framework you already use.
- OAuth 2.1 authorization code flow with PKCE (S256 only, mandatory for every client)
- Refresh tokens with rotation and reuse detection, and a configurable grace window for lost responses
- Client credentials grant for service to service calls
- Three ways to know a client: Client ID Metadata Documents (CIMD, preferred), dynamic client registration (RFC 7591) and clients you register by hand
- Resource indicators (RFC 8707): every token is bound to one resource
- The
issparameter on authorization responses (RFC 9207) - Authorization server metadata (RFC 8414)
- Token revocation (RFC 7009) and introspection (RFC 7662)
- A consent page and a connections page where people see and revoke what they connected
- Personal access tokens for scripts
- Scopes with hierarchies, checked on every request, so role changes apply at once
- Storage on PDO (SQLite, MySQL/MariaDB, PostgreSQL), an in-memory storage for tests and a contract test for your own storage
- PSR-15 middleware to protect your routes, and a bridge for the official MCP PHP SDK
- PSR-14 events for audit logs, rate limiting through any PSR-16 cache, and a pruning job
- Checked against the official MCP conformance suite for authorization servers
- PHP 8.2 or newer
- A PSR-17 implementation, for example
nyholm/psr7 ext-pdoforPdoStorageandext-curlfor Client ID Metadata Documents (both optional)
composer require cakedc/oauth-server nyholm/psr7This is the shape of every integration. examples/standalone/ is a complete, runnable version of it
(php -S 127.0.0.1:8090 examples/standalone/server.php, see its README).
1. Create the tables.
use CakeDC\OAuth\Server\Storage\Pdo\PdoStorage;
$pdo = new PDO('sqlite:' . __DIR__ . '/oauth.sqlite');
PdoStorage::createTables($pdo);2. Tell the server about your people. Implement UserProviderInterface (who exists, what they may
do) and UserContextInterface (who is signed in, how to send a visitor to the login). One class can do
both:
use CakeDC\OAuth\Server\Client\Client;
use CakeDC\OAuth\Server\Owner\ResourceOwner;
use CakeDC\OAuth\Server\Owner\UserContextInterface;
use CakeDC\OAuth\Server\Owner\UserProviderInterface;
use CakeDC\OAuth\Server\Resource\ProtectedResource;
use Nyholm\Psr7\Response;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
final class MyUsers implements UserProviderInterface, UserContextInterface
{
private const PEOPLE = ['1' => 'Ada', '2' => 'Grace'];
public function findBySubject(string $subject): ?ResourceOwner
{
return isset(self::PEOPLE[$subject]) ? new ResourceOwner($subject, self::PEOPLE[$subject]) : null;
}
public function canAuthorize(ResourceOwner $owner, Client $client, ProtectedResource $resource): bool
{
return true;
}
public function scopesFor(ResourceOwner $owner, ProtectedResource $resource): ?array
{
return null; // every scope of the resource
}
public function forClient(Client $client, ProtectedResource $resource): ?ResourceOwner
{
return null; // no client credentials
}
public function currentOwner(ServerRequestInterface $request): ?ResourceOwner
{
$subject = $_SESSION['user'] ?? null;
return is_string($subject) ? $this->findBySubject($subject) : null;
}
public function loginResponse(ServerRequestInterface $request, string $returnTo): ResponseInterface
{
return new Response(302, ['Location' => '/login?return=' . rawurlencode($returnTo)]);
}
}3. Build the server.
use CakeDC\OAuth\Server\AuthorizationServer;
use CakeDC\OAuth\Server\Config\ServerConfig;
use Nyholm\Psr7\Factory\Psr17Factory;
$factory = new Psr17Factory();
$users = new MyUsers();
$server = new AuthorizationServer(
ServerConfig::fromArray([
'issuer' => 'https://auth.example.com',
'secret' => getenv('OAUTH_SECRET'), // at least 32 characters (for example 32 random bytes, hex or base64 encoded)
'resources' => [
'https://auth.example.com/api' => [
'name' => 'Example API',
'scopes' => ['read' => 'Read your profile', 'write' => 'Change your profile'],
'implies' => ['write' => ['read']],
'defaultScopes' => ['read'],
],
],
]),
new PdoStorage($pdo),
$users,
$users,
$factory,
$factory,
);4. Route the handlers. Each endpoint is a PSR-15 request handler. This is the routing of
examples/standalone/server.php:
$config = $server->config();
$path = $request->getUri()->getPath();
$response = match (true) {
$path === $config->metadataPath() => $server->metadataHandler()->handle($request),
$path === $config->endpointPath('authorize') => $server->authorizeHandler()->handle($request),
$path === $config->endpointPath('token') => $server->tokenHandler()->handle($request),
$path === $config->endpointPath('register') => $server->registerHandler()->handle($request),
$path === $config->endpointPath('revoke') => $server->revokeHandler()->handle($request),
$path === $config->endpointPath('introspect') => $server->introspectHandler()->handle($request),
$path === $config->endpointPath('connections') => $server->connectionsHandler()->handle($request),
default => $yourApplication->handle($request),
};5. Protect a route.
$protected = $server->bearerMiddleware('https://auth.example.com/api', requiredScopes: ['read']);
$response = $protected->process($request, $yourApiHandler);Your handler finds the person in $request->getAttribute(ResourceOwner::class).
Start with docs/index.md. All pages:
| Page | What it covers |
|---|---|
| docs/index.md | Table of contents |
| docs/concepts.md | OAuth in plain words, and the authorization code flow |
| docs/installation.md | Requirements, tables, first configuration, routing |
| docs/configuration.md | Every setting, with defaults |
| docs/users.md | Connecting your people and your login |
| docs/storage.md | Storages, writing your own, the schema |
| docs/endpoints.md | The HTTP reference |
| docs/errors.md | Every error code and what to do |
| docs/protecting-resources.md | Bearer middleware and token validation |
| docs/mcp.md | Using it with the official MCP PHP SDK |
| docs/frameworks.md | Plain PHP, Slim, Mezzio, Symfony, Laravel |
| docs/clients.md | Client types, registration, claude.ai, Claude Code and others |
| docs/pages.md | The consent and connections pages, custom renderers, translation |
| docs/personal-tokens.md | Personal access tokens |
| docs/events.md | PSR-14 events and an audit log |
| docs/security.md | Threat model, RFC 9700, production checklist |
| docs/deployment.md | Proxies, nginx, Apache, Cloudflare, CORS |
| docs/operations.md | Pruning, revocation, secret rotation, monitoring |
| docs/troubleshooting.md | Symptoms, causes and fixes |
| docs/conformance.md | The MCP conformance run (tests/Conformance/run.sh) |
CakePHP applications should use the plugin cakedc/cakephp-oauth-server,
which wraps this library with CakePHP routing, authentication, views and migrations. This package is
the framework-agnostic core.
composer install
composer check # phpunit, phpcs and phpstanThe storage tests for MySQL and PostgreSQL run when OAUTH_TEST_MYSQL_DSN and OAUTH_TEST_PGSQL_DSN
are set. The conformance run is described in docs/conformance.md.
MIT. See LICENSE.