12

For those that arrive from Google: You probably shouldn't get the nonces from the REST API, unless you really know what you're doing. Cookie-based authentication with the REST API is only meant for plugins and themes. For a single page application, you should probably use OAuth.

This question exists because the documentation isn't/wasn't clear on how should you actually authenticate when building single page apps, JWTs aren't really fit for web apps, and OAuth is harder to implement than cookie based auth.


The handbook has an example on how the Backbone JavaScript client handles nonces, and if I follow the example, I get a nonce that the built in endpoints such as /wp/v2/posts accepts.

\wp_localize_script("client-js", "theme", [
  'nonce' => wp_create_nonce('wp_rest'),
  'user' => get_current_user_id(),

]);

However, using Backbone is out of the question, and so are themes, so I wrote the following plugin:

<?php
/*
Plugin Name: Nonce Endpoint
*/

add_action('rest_api_init', function () {
  $user = get_current_user_id();
  register_rest_route('nonce/v1', 'get', [
    'methods' => 'GET',
    'callback' => function () use ($user) {
      return [
        'nonce' => wp_create_nonce('wp_rest'),
        'user' => $user,
      ];
    },
  ]);

  register_rest_route('nonce/v1', 'verify', [
    'methods' => 'GET',
    'callback' => function () use ($user) {
      $nonce = !empty($_GET['nonce']) ? $_GET['nonce'] : false;
      return [
        'valid' => (bool) wp_verify_nonce($nonce, 'wp_rest'),
        'user' => $user,
      ];
    },
  ]);
});

I tinkered in the JavaScript console a bit, and wrote the following:

var main = async () => { // var because it can be redefined
  const nonceReq = await fetch('/wp-json/nonce/v1/get', { credentials: 'include' })
  const nonceResp = await nonceReq.json()
  const nonceValidReq = await fetch(`/wp-json/nonce/v1/verify?nonce=${nonceResp.nonce}`, { credentials: 'include' })
  const nonceValidResp = await nonceValidReq.json()
  const addPost = (nonce) => fetch('/wp-json/wp/v2/posts', {
    method: 'POST',
    credentials: 'include',
    body: JSON.stringify({
      title: `Test ${Date.now()}`,
      content: 'Test',
    }),
    headers: {
      'X-WP-Nonce': nonce,
      'content-type': 'application/json'
    },
  }).then(r => r.json()).then(console.log)

  console.log(nonceResp.nonce, nonceResp.user, nonceValidResp)
  console.log(theme.nonce, theme.user)
  addPost(nonceResp.nonce)
  addPost(theme.nonce)
}

main()

The expected result is two new posts, but I get Cookie nonce is invalid from the first one, and the second one creates the post succesfully. That's probably because the nonces are different, but why? I'm logged in as the same user in both requests.

enter image description here

If my approach is wrong, how should I get the nonce?

Edit:

I tried messing with globals without much luck. Got a bit luckier by utilizing wp_loaded action:

<?php
/*
Plugin Name: Nonce Endpoint
*/

$nonce = 'invalid';
add_action('wp_loaded', function () {
  global $nonce;
  $nonce = wp_create_nonce('wp_rest');
});

add_action('rest_api_init', function () {
  $user = get_current_user_id();
  register_rest_route('nonce/v1', 'get', [
    'methods' => 'GET',
    'callback' => function () use ($user) {
      return [
        'nonce' => $GLOBALS['nonce'],
        'user' => $user,
      ];
    },
  ]);

  register_rest_route('nonce/v1', 'verify', [
    'methods' => 'GET',
    'callback' => function () use ($user) {
      $nonce = !empty($_GET['nonce']) ? $_GET['nonce'] : false;
      error_log("verify $nonce $user");
      return [
        'valid' => (bool) wp_verify_nonce($nonce, 'wp_rest'),
        'user' => $user,
      ];
    },
  ]);
});

Now when I run the JavaScript above, two posts get created, but the verify endpoint fails!

enter image description here

I went to debug wp_verify_nonce:

function wp_verify_nonce( $nonce, $action = -1 ) {
  $nonce = (string) $nonce;
  $user = wp_get_current_user();
  $uid = (int) $user->ID; // This is 0, even though the verify endpoint says I'm logged in as user 2!

I added some logging

// Nonce generated 0-12 hours ago
$expected = substr( wp_hash( $i . '|' . $action . '|' . $uid . '|' . $token, 'nonce'), -12, 10 );
error_log("expected 1 $expected received $nonce uid $uid action $action");
if ( hash_equals( $expected, $nonce ) ) {
  return 1;
}

// Nonce generated 12-24 hours ago
$expected = substr( wp_hash( ( $i - 1 ) . '|' . $action . '|' . $uid . '|' . $token, 'nonce' ), -12, 10 );
error_log("expected 2 $expected received $nonce uid $uid action $action");
if ( hash_equals( $expected, $nonce ) ) {
  return 2;
}

and the JavaScript code now results in the following entries. As you can see, when the verify endpoint is called, uid is 0.

[01-Mar-2018 11:41:57 UTC] verify 716087f772 2
[01-Mar-2018 11:41:57 UTC] expected 1 b35fa18521 received 716087f772 uid 0 action wp_rest
[01-Mar-2018 11:41:57 UTC] expected 2 dd35d95cbd received 716087f772 uid 0 action wp_rest
[01-Mar-2018 11:41:58 UTC] expected 1 716087f772 received 716087f772 uid 2 action wp_rest
[01-Mar-2018 11:41:58 UTC] expected 1 716087f772 received 716087f772 uid 2 action wp_rest

3 Answers 3

4

Take a closer look at the function rest_cookie_check_errors().

When you get the nonce via /wp-json/nonce/v1/get, you're not sending a nonce in the first place. So this function nullifies your authentication, with this code:

if ( null === $nonce ) {
    // No nonce at all, so act as if it's an unauthenticated request.
    wp_set_current_user( 0 );
    return true;
}

That's why you're getting a different nonce from your REST call vs getting it from the theme. The REST call is intentionally not recognizing your login credentials (in this case via cookie auth) because you didn't send a valid nonce in the get request.

Now, the reason your wp_loaded code worked was because you got the nonce and saved it to a global before this rest code nullified your login. The verify fails because the rest code nullifies your login before the verify takes place.

2
  • I've not even looked at that function, but that probably makes sense. The thing is, why should I include a valid nonce for the GET request? (I get it now, but it's far from obvious) The whole point of the /verify endpoint is that I can check if the nonce is still valid, and if it's going stale or isn't valid, get a new nonce.
    – Christian
    Mar 1, 2018 at 14:27
  • Based on the source of rest_cookie_check_errors, I should change my endpoint so that it doesn't check $_GET['nonce'], but the nonce header or $_GET['_wpnonce'] parameter. Correct?
    – Christian
    Mar 1, 2018 at 14:31
1

While this solution works, it isn't recommended. OAuth is the preferred choice.


I think I got it.

I think that wp_verify_nonce is broken, as wp_get_current_user fails to get the proper user object.

It isn't, as illustrated by Otto.

Luckily it has a filter: $uid = apply_filters( 'nonce_user_logged_out', $uid, $action );

Using this filter I was able to write the following, and the JavaScript code executes like it should:

enter image description here

<?php
/*
Plugin Name: Nonce Endpoint
*/

$nonce = 'invalid';
add_action('wp_loaded', function () {
  global $nonce;
  $nonce = wp_create_nonce('wp_rest');
});

add_action('rest_api_init', function () {
  $user = get_current_user_id();
  register_rest_route('nonce/v1', 'get', [
    'methods' => 'GET',
    'callback' => function () use ($user) {
      return [
        'nonce' => $GLOBALS['nonce'],
        'user' => $user,
      ];
    },
  ]);

  register_rest_route('nonce/v1', 'verify', [
    'methods' => 'GET',
    'callback' => function () use ($user) {
      $nonce = !empty($_GET['nonce']) ? $_GET['nonce'] : false;
      add_filter("nonce_user_logged_out", function ($uid, $action) use ($user) {
        if ($uid === 0 && $action === 'wp_rest') {
          return $user;
        }

        return $uid;
      }, 10, 2);

      return [
        'status' => wp_verify_nonce($nonce, 'wp_rest'),
        'user' => $user,
      ];
    },
  ]);
});

If you spot a security problem with the fix, please give me a shout, right now I can't see anything wrong with it, other than globals.

0
0

Looking at all this code it seems like your problem is the use of closures. At init stage you should only set hooks and not evaluate data as not all of the core had finished loading and being initialized.

In

add_action('rest_api_init', function () {
  $user = get_current_user_id();
  register_rest_route('nonce/v1', 'get', [
    'methods' => 'GET',
    'callback' => function () use ($user) {
      return [
        'nonce' => $GLOBALS['nonce'],
        'user' => $user,
      ];
    },
  ]);

the $user is bound early to be used in the closure, but no one promises to you that the cookie were already handled and a user was authenticated based on them. A better code will be

add_action('rest_api_init', function () {
  register_rest_route('nonce/v1', 'get', [
    'methods' => 'GET',
    'callback' => function () {
    $user = get_current_user_id();
      return [
        'nonce' => $GLOBALS['nonce'],
        'user' => $user,
      ];
    },
  ]);

As always with anything hook in wordpress, use the latest hook possible and never try to precalculate anything you don't have to.

2
  • I used Query Monitors actions & hooks section in order to figure out what runs and in which order, set_current_user runs before init & after_setup_theme, there shouldn't be a problem with $user being defined outside & before closures.
    – Christian
    Mar 1, 2018 at 13:14
  • @Christian, and all of them might not be relevant in the context of json API. I would be very surprised if query monitor works in that context Mar 1, 2018 at 16:30

Your Answer

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

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