
WordPress should become an OAuth authorization server only when the product truly needs delegated third-party access. For ordinary server-to-server administration, Application Passwords over HTTPS are simpler; for delegated access, use a maintained OAuth implementation with Authorization Code and PKCE rather than inventing a token server in a plugin.
Before implementing an OAuth2 authorization server, it is essential to evaluate how it compares against legacy WordPress authentication mechanisms across critical enterprise security vectors.
Proof Key for Code Exchange (PKCE) extends the standard Authorization Code grant to protect public clients (such as mobile apps and JavaScript SPAs) where client secrets cannot be securely stored. The cryptographic protocol executes in five discrete phases:
code_verifier (43 to 128 characters) and computes its SHA-256 hash, base64url-encoded, called the code_challenge./oauth/authorize) passing client_id, redirect_uri, response_type=code, scope, code_challenge, and code_challenge_method=S256./oauth/token, transmitting the plaintext code_verifier.code_verifier with SHA-256 and compares it against the stored code_challenge. Upon match, it issues a 60-minute Bearer Access Token and a 30-day Refresh Token.To support high-throughput token issuance, scope lookups, and instantaneous session revocation, we define four optimized relational tables with covering indexes.
-- 1. Registered OAuth2 Applications
CREATE TABLE `wp_oauth_clients` (
`client_id` varchar(64) NOT NULL,
`user_id` bigint(20) unsigned NOT NULL,
`name` varchar(255) NOT NULL,
`secret_hash` varchar(255) DEFAULT NULL,
`redirect_uris` text NOT NULL,
`allowed_scopes` text NOT NULL,
`is_confidential` tinyint(1) unsigned NOT NULL DEFAULT 0,
`created_at` datetime NOT NULL,
PRIMARY KEY (`client_id`),
KEY `idx_user_id` (`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_520_ci;
-- 2. Short-Lived Authorization Codes (5 min lifespan)
CREATE TABLE `wp_oauth_auth_codes` (
`code_hash` char(64) NOT NULL,
`client_id` varchar(64) NOT NULL,
`user_id` bigint(20) unsigned NOT NULL,
`redirect_uri` varchar(500) NOT NULL,
`code_challenge` varchar(128) NOT NULL,
`code_challenge_method` varchar(10) NOT NULL DEFAULT 'S256',
`scopes` text NOT NULL,
`expires_at` int(11) unsigned NOT NULL,
`is_used` tinyint(1) unsigned NOT NULL DEFAULT 0,
PRIMARY KEY (`code_hash`),
KEY `idx_expires_used` (`expires_at`, `is_used`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_520_ci;
-- 3. Bearer Access Tokens (60 min lifespan)
CREATE TABLE `wp_oauth_access_tokens` (
`token_hash` char(64) NOT NULL,
`client_id` varchar(64) NOT NULL,
`user_id` bigint(20) unsigned NOT NULL,
`scopes` text NOT NULL,
`expires_at` int(11) unsigned NOT NULL,
`is_revoked` tinyint(1) unsigned NOT NULL DEFAULT 0,
PRIMARY KEY (`token_hash`),
KEY `idx_client_user` (`client_id`, `user_id`),
KEY `idx_expires_revoked` (`expires_at`, `is_revoked`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_520_ci;
-- 4. Refresh Tokens for Seamless Rotation (30 day lifespan)
CREATE TABLE `wp_oauth_refresh_tokens` (
`refresh_token_hash` char(64) NOT NULL,
`access_token_hash` char(64) NOT NULL,
`client_id` varchar(64) NOT NULL,
`user_id` bigint(20) unsigned NOT NULL,
`scopes` text NOT NULL,
`expires_at` int(11) unsigned NOT NULL,
`is_revoked` tinyint(1) unsigned NOT NULL DEFAULT 0,
PRIMARY KEY (`refresh_token_hash`),
KEY `idx_access_token` (`access_token_hash`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_520_ci;
Below is the complete, strictly typed PSR-4 service class implementing PKCE code verification, token generation, and refresh token rotation.
<?php
declare(strict_types=1);
namespace WPStack\OAuth2\Services;
use wpdb;
use WP_Error;
use RuntimeException;
use InvalidArgumentException;
final class OAuth2ServerService
{
private wpdb $db;
private const AUTH_CODE_LIFESPAN = 300; // 5 Minutes
private const ACCESS_TOKEN_LIFESPAN = 3600; // 1 Hour
private const REFRESH_TOKEN_LIFESPAN = 2592000; // 30 Days
public function __construct(wpdb $db)
{
$this->db = $db;
}
/**
* Issues an ephemeral authorization code during the PKCE consent flow.
*/
public function issueAuthorizationCode(
string $clientId,
int $userId,
string $redirectUri,
string $codeChallenge,
string $codeChallengeMethod,
array $scopes
): string {
$rawCode = bin2hex(random_bytes(32));
$codeHash = hash('sha256', $rawCode);
$expiresAt = time() + self::AUTH_CODE_LIFESPAN;
$table = $this->db->prefix . 'oauth_auth_codes';
$this->db->insert(
$table,
[
'code_hash' => $codeHash,
'client_id' => $clientId,
'user_id' => $userId,
'redirect_uri' => $redirectUri,
'code_challenge' => $codeChallenge,
'code_challenge_method' => $codeChallengeMethod,
'scopes' => implode(' ', $scopes),
'expires_at' => $expiresAt,
'is_used' => 0,
],
['%s', '%s', '%d', '%s', '%s', '%s', '%s', '%d', '%d']
);
return $rawCode;
}
/**
* Exchanges an authorization code and PKCE code_verifier for Access & Refresh tokens.
*
* @return array{access_token: string, token_type: string, expires_in: int, refresh_token: string, scope: string}|WP_Error
*/
public function exchangeCodeForTokens(
string $clientId,
string $rawCode,
string $redirectUri,
string $codeVerifier
): array|WP_Error {
$codeHash = hash('sha256', $rawCode);
$table = $this->db->prefix . 'oauth_auth_codes';
$codeRow = $this->db->get_row(
$this->db->prepare(
"SELECT * FROM {$table} WHERE code_hash = %s AND client_id = %s AND is_used = 0",
$codeHash,
$clientId
)
);
if (!$codeRow) {
return new WP_Error('invalid_grant', 'Invalid or expired authorization code.', ['status' => 400]);
}
if ((int)$codeRow->expires_at < time()) {
return new WP_Error('invalid_grant', 'Authorization code has expired.', ['status' => 400]);
}
if ($codeRow->redirect_uri !== $redirectUri) {
return new WP_Error('invalid_grant', 'Redirect URI mismatch.', ['status' => 400]);
}
// Validate PKCE Code Verifier
if (!$this->verifyPkceChallenge($codeVerifier, $codeRow->code_challenge, $codeRow->code_challenge_method)) {
return new WP_Error('invalid_grant', 'PKCE code verifier validation failed.', ['status' => 400]);
}
// Mark authorization code as used immediately to prevent replay attacks
$this->db->update($table, ['is_used' => 1], ['code_hash' => $codeHash], ['%d'], ['%s']);
// Issue Access Token & Refresh Token
return $this->issueTokens((int)$codeRow->user_id, $clientId, explode(' ', $codeRow->scopes));
}
/**
* Rotates refresh token and issues fresh access token.
*/
public function refreshAccessToken(string $clientId, string $rawRefreshToken): array|WP_Error
{
$refreshHash = hash('sha256', $rawRefreshToken);
$refreshTable = $this->db->prefix . 'oauth_refresh_tokens';
$row = $this->db->get_row(
$this->db->prepare(
"SELECT * FROM {$refreshTable} WHERE refresh_token_hash = %s AND client_id = %s AND is_revoked = 0",
$refreshHash,
$clientId
)
);
if (!$row || (int)$row->expires_at < time()) {
return new WP_Error('invalid_grant', 'Invalid or expired refresh token.', ['status' => 400]);
}
// Revoke the old refresh token (Refresh Token Rotation pattern)
$this->db->update($refreshTable, ['is_revoked' => 1], ['refresh_token_hash' => $refreshHash], ['%d'], ['%s']);
// Revoke the associated old access token
$this->db->update(
$this->db->prefix . 'oauth_access_tokens',
['is_revoked' => 1],
['token_hash' => $row->access_token_hash],
['%d'],
['%s']
);
return $this->issueTokens((int)$row->user_id, $clientId, explode(' ', $row->scopes));
}
private function issueTokens(int $userId, string $clientId, array $scopes): array
{
$rawAccessToken = 'wpk_' . bin2hex(random_bytes(32));
$accessTokenHash = hash('sha256', $rawAccessToken);
$accessExpiresAt = time() + self::ACCESS_TOKEN_LIFESPAN;
$rawRefreshToken = 'wpr_' . bin2hex(random_bytes(32));
$refreshTokenHash = hash('sha256', $rawRefreshToken);
$refreshExpiresAt = time() + self::REFRESH_TOKEN_LIFESPAN;
$scopeString = implode(' ', $scopes);
// Store Access Token
$this->db->insert(
$this->db->prefix . 'oauth_access_tokens',
[
'token_hash' => $accessTokenHash,
'client_id' => $clientId,
'user_id' => $userId,
'scopes' => $scopeString,
'expires_at' => $accessExpiresAt,
'is_revoked' => 0,
],
['%s', '%s', '%d', '%s', '%d', '%d']
);
// Store Refresh Token
$this->db->insert(
$this->db->prefix . 'oauth_refresh_tokens',
[
'refresh_token_hash' => $refreshTokenHash,
'access_token_hash' => $accessTokenHash,
'client_id' => $clientId,
'user_id' => $userId,
'scopes' => $scopeString,
'expires_at' => $refreshExpiresAt,
'is_revoked' => 0,
],
['%s', '%s', '%s', '%d', '%s', '%d', '%d']
);
return [
'access_token' => $rawAccessToken,
'token_type' => 'Bearer',
'expires_in' => self::ACCESS_TOKEN_LIFESPAN,
'refresh_token' => $rawRefreshToken,
'scope' => $scopeString,
];
}
private function verifyPkceChallenge(string $verifier, string $challenge, string $method): bool
{
if ($method === 'plain') {
return hash_equals($challenge, $verifier);
}
if ($method === 'S256') {
$computedChallenge = rtrim(strtr(base64_encode(hash('sha256', $verifier, true)), '+/', '-_'), '=');
return hash_equals($challenge, $computedChallenge);
}
return false;
}
}
When an incoming HTTP request reaches the WordPress REST API with a Bearer token, the OAuth2AuthMiddleware intercepts the request, validates the token signature, sets the current WordPress user context, and verifies required scope capabilities.
<?php
declare(strict_types=1);
namespace WPStack\OAuth2\Middleware;
use wpdb;
use WP_Error;
use WP_REST_Request;
use WP_REST_Server;
final class OAuth2AuthMiddleware
{
private wpdb $db;
public function __construct(wpdb $db)
{
$this->db = $db;
}
public function registerHooks(): void
{
add_filter('rest_authentication_errors', [$this, 'authenticateBearerToken'], 20);
add_filter('rest_pre_dispatch', [$this, 'enforceRouteScopes'], 10, 3);
}
/**
* Intercepts REST authentication to authenticate valid OAuth2 Bearer tokens.
*/
public function authenticateBearerToken(mixed $status): mixed
{
// If another authentication layer (like cookies) has already succeeded, respect it
if ($status === true || (is_user_logged_in() && !isset($_SERVER['HTTP_AUTHORIZATION']))) {
return $status;
}
$authHeader = $_SERVER['HTTP_AUTHORIZATION'] ?? $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] ?? '';
if (empty($authHeader) || !str_starts_with(strtolower($authHeader), 'bearer ')) {
return $status;
}
$rawToken = trim(substr($authHeader, 7));
if (empty($rawToken)) {
return new WP_Error('invalid_token', 'Bearer token missing.', ['status' => 401]);
}
$tokenHash = hash('sha256', $rawToken);
$table = $this->db->prefix . 'oauth_access_tokens';
// Check active token in database (or Redis cache)
$tokenRow = $this->db->get_row(
$this->db->prepare(
"SELECT * FROM {$table} WHERE token_hash = %s AND is_revoked = 0",
$tokenHash
)
);
if (!$tokenRow) {
return new WP_Error('invalid_token', 'Token not recognized or revoked.', ['status' => 401]);
}
if ((int)$tokenRow->expires_at < time()) {
return new WP_Error('expired_token', 'Bearer token has expired.', ['status' => 401]);
}
// Set the active WordPress user context
wp_set_current_user((int)$tokenRow->user_id);
// Store active token scopes in global runtime request context
$GLOBALS['wpstack_oauth_active_scopes'] = explode(' ', $tokenRow->scopes);
$GLOBALS['wpstack_oauth_client_id'] = $tokenRow->client_id;
return true;
}
/**
* Enforces granular route-level scope requirements before dispatch.
*/
public function enforceRouteScopes(mixed $result, WP_REST_Server $server, WP_REST_Request $request): mixed
{
$activeScopes = $GLOBALS['wpstack_oauth_active_scopes'] ?? null;
if (!is_array($activeScopes)) {
return $result; // Non-OAuth authenticated request
}
$route = $request->get_route();
$method = $request->get_method();
// Define Scope Mapping Rules for Sensitive Endpoints
$requiredScope = match (true) {
str_starts_with($route, '/wc/v3/orders') && $method === 'GET' => 'orders:read',
str_starts_with($route, '/wc/v3/orders') && $method !== 'GET' => 'orders:write',
str_starts_with($route, '/wc/v3/products') && $method !== 'GET' => 'products:write',
str_starts_with($route, '/wp/v2/users') => 'users:manage',
default => null,
};
if ($requiredScope !== null && !in_array($requiredScope, $activeScopes, true)) {
return new WP_Error(
'insufficient_scope',
sprintf('Token lacks required scope "%s" for this operation.', $requiredScope),
['status' => 403, 'required_scope' => $requiredScope]
);
}
return $result;
}
}
We register official RFC 6749 endpoints under the namespace /wp-json/oauth/v2/ to handle authorization consent, token issuance, and token revocation.
<?php
declare(strict_types=1);
namespace WPStack\OAuth2\Controllers;
use WP_REST_Controller;
use WP_REST_Server;
use WP_REST_Request;
use WP_REST_Response;
use WP_Error;
use WPStack\OAuth2\Services\OAuth2ServerService;
final class OAuth2TokenRestController extends WP_REST_Controller
{
private OAuth2ServerService $oauthService;
public function __construct(OAuth2ServerService $oauthService)
{
$this->namespace = 'oauth/v2';
$this->rest_base = 'token';
$this->oauthService = $oauthService;
}
public function register_routes(): void
{
register_rest_route($this->namespace, '/' . $this->rest_base, [
[
'methods' => WP_REST_Server::CREATABLE,
'callback' => [$this, 'handleTokenRequest'],
'permission_callback' => '__return_true', // Public token exchange endpoint
],
]);
register_rest_route($this->namespace, '/revoke', [
[
'methods' => WP_REST_Server::CREATABLE,
'callback' => [$this, 'handleRevokeRequest'],
'permission_callback' => '__return_true',
],
]);
}
public function handleTokenRequest(WP_REST_Request $request): WP_REST_Response|WP_Error
{
$grantType = sanitize_text_field((string)$request->get_param('grant_type'));
$clientId = sanitize_text_field((string)$request->get_param('client_id'));
if (empty($clientId)) {
return new WP_Error('invalid_request', 'Missing client_id parameter.', ['status' => 400]);
}
if ($grantType === 'authorization_code') {
$code = sanitize_text_field((string)$request->get_param('code'));
$redirectUri = esc_url_raw((string)$request->get_param('redirect_uri'));
$codeVerifier = sanitize_text_field((string)$request->get_param('code_verifier'));
$result = $this->oauthService->exchangeCodeForTokens($clientId, $code, $redirectUri, $codeVerifier);
if (is_wp_error($result)) {
return $result;
}
return new WP_REST_Response($result, 200);
}
if ($grantType === 'refresh_token') {
$refreshToken = sanitize_text_field((string)$request->get_param('refresh_token'));
$result = $this->oauthService->refreshAccessToken($clientId, $refreshToken);
if (is_wp_error($result)) {
return $result;
}
return new WP_REST_Response($result, 200);
}
return new WP_Error('unsupported_grant_type', 'Unsupported grant_type parameter.', ['status' => 400]);
}
public function handleRevokeRequest(WP_REST_Request $request): WP_REST_Response
{
global $wpdb;
$token = sanitize_text_field((string)$request->get_param('token'));
if (!empty($token)) {
$tokenHash = hash('sha256', $token);
$wpdb->update($wpdb->prefix . 'oauth_access_tokens', ['is_revoked' => 1], ['token_hash' => $tokenHash]);
$wpdb->update($wpdb->prefix . 'oauth_refresh_tokens', ['is_revoked' => 1], ['refresh_token_hash' => $tokenHash]);
}
return new WP_REST_Response(['revoked' => true], 200);
}
}
Querying MySQL on every single API request introduces unnecessary latency and database load. We implement an in-memory Redis caching layer that stores hashed token payloads for the duration of their lifespan while enabling instant O(1) invalidation when a token is revoked.
<?php
declare(strict_types=1);
namespace WPStack\OAuth2\Cache;
use Redis;
use RedisException;
final class RedisTokenCacheManager
{
private ?Redis $redis = null;
private const CACHE_PREFIX = 'wpstack_oauth_token:';
public function __construct()
{
if (class_exists('Redis')) {
try {
$this->redis = new Redis();
$this->redis->connect(
(string)defined('WP_REDIS_HOST') ? WP_REDIS_HOST : '127.0.0.1',
(int)defined('WP_REDIS_PORT') ? WP_REDIS_PORT : 6379,
1.5
);
} catch (RedisException) {
$this->redis = null; // Fallback gracefully to direct MySQL lookups
}
}
}
public function getCachedToken(string $tokenHash): ?array
{
if (!$this->redis) {
return null;
}
try {
$data = $this->redis->get(self::CACHE_PREFIX . $tokenHash);
return $data ? json_decode((string)$data, true) : null;
} catch (RedisException) {
return null;
}
}
public function setCachedToken(string $tokenHash, array $tokenData, int $ttl): void
{
if (!$this->redis || $ttl <= 0) {
return;
}
try {
$this->redis->setex(self::CACHE_PREFIX . $tokenHash, $ttl, (string)wp_json_encode($tokenData));
} catch (RedisException) {
// Log silent cache write failure
}
}
public function invalidateToken(string $tokenHash): void
{
if (!$this->redis) {
return;
}
try {
$this->redis->del(self::CACHE_PREFIX . $tokenHash);
} catch (RedisException) {
// Invalidation error fallback
}
}
}
When a third-party application requests access, the user is presented with a clear consent screen showing the application name, verified developer information, requested permission scopes, and an explicit Grant/Deny dialog.