Artificial intelligence

How to Add OAuth to a WordPress MCP Server

Standing up an MCP server on WordPress takes an afternoon: the mcp-adapter plugin turns your abilities into tools any client can list. The hard part is not there, it is at the door. The adapter ships no authorization server: no endpoint to authorize, none to issue tokens, and no metadata to announce. It only understands requests that arrive already authenticated as a WordPress user.

PHP code for an OAuth server that connects MCP clients to WordPress

This article walks through how I solved it on DevCodeLight with my own mu-plugin: OAuth 2.1 with PKCE, dynamic client registration and Bearer tokens, so Claude or Cursor connect with a button instead of a hand-pasted application password.

The shortcut that does not scale: application passwords

WordPress ships a native solution that is quite good: application passwords. You generate them in the user profile, they work over Basic auth and they need no changes to the adapter. In fact, they are still my fallback.

The problem is everything else:

  • You create them by hand, one per client, and the client cannot discover them.
  • They have to be copied and pasted into the configuration, where they sit in plain text.
  • There is no consent screen and no expiry: if they leak, they work until someone remembers to revoke them.
  • Whoever generates them is usually an administrator, when an editor would do.

A modern MCP client expects something else: call without credentials, get a 401, read the metadata, register itself and open a consent screen in the browser. That is OAuth.

What OAuth 2.1 actually requires

If you come from OAuth 2.0, what matters are the cuts the 2.1 revision makes:

  • Authorization code with PKCE. The client generates a verifier, sends its hash (S256) when authorizing and presents it when redeeming. No PKCE, no flow.
  • Public clients only. A desktop program cannot keep a secret, so registration rejects any client_secret.
  • Dynamic client registration (RFC 7591): the client signs itself up and gets a client_id.
  • Two metadata documents: the protected resource (RFC 9728) and the authorization server (RFC 8414).
  • No implicit flow and no password grant.

The six endpoints

  • /.well-known/oauth-protected-resource: where the MCP lives and who protects it.
  • /.well-known/oauth-authorization-server: endpoints, PKCE and grant types.
  • /dcl-oauth/register: client sign-up.
  • /dcl-oauth/authorize: consent and code delivery.
  • /dcl-oauth/token: code exchange and refresh.
  • /dcl-oauth/revoke: revocation.

Step 1: your own routes, not REST

Consent is an HTML form riding on the WordPress session cookie. Had I built it on the REST API I would be fighting the REST nonce, which does not exist in a request coming from the client. Rewrite rules plus template_redirect skip that problem.

add_action( 'init', function () {
	add_rewrite_rule( '^dcl-oauth/authorize/?$', 'index.php?dcl_oauth=authorize', 'top' );
	add_rewrite_rule( '^dcl-oauth/token/?$', 'index.php?dcl_oauth=token', 'top' );
	add_rewrite_rule( '^dcl-oauth/register/?$', 'index.php?dcl_oauth=register', 'top' );
	add_rewrite_rule( '^dcl-oauth/revoke/?$', 'index.php?dcl_oauth=revoke', 'top' );
} );

add_filter( 'query_vars', function ( $vars ) {
	$vars[] = 'dcl_oauth';

	return $vars;
} );

New rules do not exist until permalinks are flushed. In the mu-plugin I do it once: I store a version number in an option and compare it on init.

Step 2: the metadata

It is the first thing a client reads, so it has to be clean JSON and it must not sit behind a cache.

$resource = array(
	'resource'                 => rest_url( 'mcp/mcp-adapter-default-server' ),
	'authorization_servers'    => array( home_url() ),
	'bearer_methods_supported' => array( 'header' ),
	'scopes_supported'         => array( 'mcp' ),
);
$server = array(
	'issuer'                                => home_url(),
	'authorization_endpoint'                => home_url( '/dcl-oauth/authorize' ),
	'token_endpoint'                        => home_url( '/dcl-oauth/token' ),
	'registration_endpoint'                 => home_url( '/dcl-oauth/register' ),
	'revocation_endpoint'                   => home_url( '/dcl-oauth/revoke' ),
	'response_types_supported'              => array( 'code' ),
	'grant_types_supported'                 => array( 'authorization_code', 'refresh_token' ),
	'code_challenge_methods_supported'      => array( 'S256' ),
	'token_endpoint_auth_methods_supported' => array( 'none' ),
);

The field that breaks the flow when missing is code_challenge_methods_supported: a client that does not see S256 there refuses to start.

Step 3: dynamic client registration

The important part here is not accepting, it is rejecting. These are the checks I kept:

$uri = (string) $request['redirect_uris'][0];

if ( wp_parse_url( $uri, PHP_URL_FRAGMENT ) ) {
	return dcl_oauth_error( 'invalid_redirect_uri', 'The URL cannot carry a fragment.' );
}

$is_https = 'https' === wp_parse_url( $uri, PHP_URL_SCHEME );
$is_local = in_array( wp_parse_url( $uri, PHP_URL_HOST ), array( 'localhost', '127.0.0.1' ), true );

if ( ! $is_https && ! $is_local ) {
	return dcl_oauth_error( 'invalid_redirect_uri', 'HTTPS or localhost only.' );
}

if ( ! empty( $request['client_secret'] ) ) {
	return dcl_oauth_error( 'invalid_client_metadata', 'Public clients only.' );
}

Clients are stored in an option without autoload, with their client_id and redirect_uris. On authorize they are compared character by character: no wildcards, no “starts with”.

With no session, send the user to the login screen and come back to the same place:

if ( ! is_user_logged_in() ) {
	wp_safe_redirect( wp_login_url( home_url( '/dcl-oauth/authorize' ) . '?' . http_build_query( $params ) ) );
	exit;
}

if ( ! current_user_can( 'edit_posts' ) ) {
	wp_die( 'Your user cannot publish content.' );
}

The screen is a form with a nonce and two buttons. On approval a single-use code is generated, its hash is stored and the user goes back to the client with the state untouched.

Step 5: redeeming the code

The exchange verifies PKCE, consumes the code and issues the tokens:

$key = 'dcl_oauth_code_' . hash( 'sha256', $code );
$expected = get_transient( $key );

delete_transient( $key );

if ( ! $expected || ! hash_equals( $expected['challenge'], dcl_oauth_s256( $verifier ) ) ) {
	return dcl_oauth_error_json( 'invalid_grant', 'Wrong code or verifier.' );
}

Three decisions that save you grief:

  • Codes and tokens are stored hashed. If someone reads the database they find nothing usable.
  • The code is deleted before the verifier is checked, so it cannot be replayed.
  • The refresh token rotates: every use issues a new one and kills the previous. If another client_id presents it, it is rejected.

Step 6: validating the Bearer

This closes the loop. A filter on determine_current_user turns the token into a WordPress user and the MCP adapter never knows:

add_filter( 'determine_current_user', function ( $user ) {
	if ( $user ) {
		return $user;
	}

	$header = isset( $_SERVER['HTTP_AUTHORIZATION'] ) ? $_SERVER['HTTP_AUTHORIZATION'] : '';

	if ( 0 !== stripos( $header, 'Bearer ' ) ) {
		return $user;
	}

	$data = get_transient( 'dcl_oauth_access_' . hash( 'sha256', substr( $header, 7 ) ) );

	return $data && ! empty( $data['user_id'] ) ? (int) $data['user_id'] : $user;
}, 20 );

Priority 20 and the early return when a user already exists matter: otherwise you would override the dashboard session.

Two traps that cost an afternoon

wp_safe_redirect sends you to the dashboard. I tested it with Claude and ended up in wp-admin with no idea why. wp_safe_redirect only allows your own domain; since the callback lives on claude.ai, WordPress dropped the destination and used its fallback, the admin. The fix is wp_redirect on a URL you already validated against the registered redirect_uris.

redirect_canonical eats the POST. If WordPress decides to add the trailing slash with a 301, the browser turns the consent POST into a GET and the user’s decision is lost. The handler must run on template_redirect at priority 1, before canonicalisation kicks in.

What I store and for how long

  • Authorization code: 5 minutes, single use, hashed in a transient.
  • Access token: 1 hour, hashed in a transient.
  • Refresh token: 30 days, rotated on every use.
  • Registered clients: option without autoload, no expiry, removable by hand.

How to test it without a client

You can walk the whole flow with curl and a browser. First the sign-up:

curl -X POST https://yoursite.com/dcl-oauth/register \
  -H 'Content-Type: application/json' \
  -d '{"client_name":"Test","redirect_uris":["https://example.com/cb"]}'

With the client_id it returns, open /dcl-oauth/authorize with response_type=code, your code_challenge and your state. After approving, the browser lands on your callback with ?code=…; redeem it:

curl -X POST https://yoursite.com/dcl-oauth/token \
  -d 'grant_type=authorization_code' \
  -d 'client_id=...' \
  -d 'code=...' \
  -d 'code_verifier=...'

And finally the MCP call with the token:

curl -X POST https://yoursite.com/wp-json/mcp/mcp-adapter-default-server \
  -H 'Authorization: Bearer ...' \
  -H 'Content-Type: application/json' \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'

What OAuth does not fix

The token inherits the permissions of the user who approves consent. There are no per-tool scopes: if the user is an administrator, the client can do everything an administrator can. So approve with a user that only has what it needs to publish, and revoke the token (or delete the registered clients) when you stop using the client.

The underlying idea is simple: the adapter does not need to know about OAuth, it only needs the request to arrive with a WordPress user behind it.

Leave a Comment