9

I would like to understand the best practices regarding nonce validation in REST APIs.

I see a lot of people talking about wp_rest nonce for REST requests. But upon looking on WordPress core code, I saw that wp_rest is just a nonce to validate a logged in user status, if it's not present, it just runs the request as guest.

That said, should I submit two nonces upon sending a POST request to a REST API? One for authentication wp_rest and another for the action foo_action?

If so, how should I send wp_rest and foo_action nonce in JavaScript, and, in PHP, what's the correct place to validate those nonces? (I mean validate_callback for a arg? permission_callback?)

3 Answers 3

12

You should pass the special wp_rest nonce as part of the request. Without it, the global $current_user object will not be available in your REST class. You can pass this from several ways, from $_GET to $_POST to headers.

The action nonce is optional. If you add it, you can't use the REST endpoint from an external server, only from requests dispatched from within WordPress itself. The user can authenticate itself using Basic Auth, OAuth2, or JWT from an external server even without the wp_rest nonce, but if you add an action nonce as well, it won't work.

So the action nonce is optional. Add it if you want the endpoint to work locally only.

Example:

/**
* First step, registering, localizing and enqueueing the JavaScript
*/
wp_register_script( 'main-js', get_template_directory_uri() . '/js/main.js', [ 'jquery' ] );
wp_localize_script( 'main-js', 'data', [
    'rest' => [
        'endpoints' => [
            'my_endpoint'       => esc_url_raw( rest_url( 'my_plugin/v1/my_endpoint' ) ),
        ],
        'timeout'   => (int) apply_filters( "my_plugin_rest_timeout", 60 ),
        'nonce'     => wp_create_nonce( 'wp_rest' ),
        //'action_nonce'     => wp_create_nonce( 'action_nonce' ), 
    ],
] );
wp_enqueue_script( 'main-js' );

/**
* Second step, the request on the JavaScript file
*/
jQuery(document).on('click', '#some_element', function () {
    let ajax_data = {
        'some_value': jQuery( ".some_value" ).val(),
        //'action_nonce': data.rest.action_nonce
    };

    jQuery.ajax({
        url: data.rest.endpoints.my_endpoint,
        method: "GET",
        dataType: "json",
        timeout: data.rest.timeout,
        data: ajax_data,
        beforeSend: function (xhr) {
            xhr.setRequestHeader('X-WP-Nonce', data.rest.nonce);
        }
    }).done(function (results) {
        console.log(results);
        alert("Success!");
    }).fail(function (xhr) {
        console.log(results);
        alert("Error!");
    });
});

/**
* Third step, the REST endpoint itself
*/
class My_Endpoint {
        public function registerRoutes() {
        register_rest_route( 'my_plugin', 'v1/my_endpoint', [
            'methods'             => WP_REST_Server::READABLE,
            'callback'            => [ $this, 'get_something' ],
            'args'                => [
                'some_value'     => [
                    'required' => true,
                ],
            ],
            'permission_callback' => function ( WP_REST_Request $request ) {
                return true;
            },
        ] );
    }

    /**
    *   @return WP_REST_Response
    */
    private function get_something( WP_REST_Request $request ) {
        //if ( ! wp_verify_nonce( $request['nonce'], 'action_nonce' ) ) {
        //  return false;
        //}

        $some_value        = $request['some_value'];

        if ( strlen( $some_value ) < 5 ) {
            return new WP_REST_Response( 'Sorry, Some Value must be at least 5 characters long.', 400 );
        }

        // Since we are passing the "X-WP-Nonce" header, this will work:
        $user = wp_get_current_user();

        if ( $user instanceof WP_User ) {
            return new WP_REST_Response( 'Sorry, could not get the name.', 400 );
        } else {
            return new WP_REST_Response( 'Your username name is: ' . $user->display_name, 200 );
        }
    }
}
4
  • 3
    It's important that you also add 'required' => true to the foo_nonce args otherwise you can still post to the endpoint without the nonce.
    – Dylan
    Commented Jun 5, 2019 at 3:04
  • Where does data.rest.nonce come from? I don't see that defined in your code. Commented Jan 10, 2020 at 4:53
  • @Dylan It's a good idea to make it required, but if you don't send the action nonce it would fail on the wp_verify_nonce that runs before executing the action Commented Oct 17, 2021 at 20:25
  • @drwatsoncode data.rest.nonce comes from wp_localize_script in the example code Commented Oct 17, 2021 at 20:39
3

Building on what @lucas-bustamante wrote (which helped me a ton!), once you have the X-WP-Nonce header setup in your custom routes you can do the following:

    register_rest_route('v1', '/my_post', [
        'methods' => WP_REST_Server::CREATABLE,
        'callback' => [$this, 'create_post'],
        'args' => [
            'post_title' => [
                'required' => true,
            ],
            'post_excerpt' => [
                'required' => true,
            ]
        ],
        'permission_callback' => function ( ) {
            return current_user_can( 'publish_posts' );
        },
    ]);

Note that the permission_callback is on the root level not under args (documented here) and I've removed the additional nonce check from args since checking the permission alone will fail if the nonce is invalid or not supplied (I've tested this extensively and can confirm I get an error when no nonce is supplied or it's invalid).

3

One thing to note about @Lucas Bustamante's answer is that the verification process described is user-based authentication. This means if you have an anonymous API end point which doesn't require a user, then by simply by not providing the X-WP-NONCE header you'll pass the described nonce check. Supplying an incorrect nonce will still throw an error.

The reason for this is that the rest_cookie_check_errors which is what does the verification will simply set the current_user to empty if no nonce is provided. This works fine when a user is required, but not otherwise. (see: https://developer.wordpress.org/reference/functions/rest_cookie_check_errors/)

If you want to expand on Lucas' answer to also include annonymous end points, then you can add a manual nonce check to the start of your end point, like this:

if ( !$_SERVER['HTTP_X_WP_NONCE'] || !wp_verify_nonce( $_SERVER['HTTP_X_WP_NONCE'], 'wp_rest' ) ) {
    header('HTTP/1.0 403 Forbidden');
    exit;
}

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.