REST Authentication

Cookie authentication with the wp_rest nonce, application passwords, and why permission_callback must not default to __return_true.

Syntax'permission_callback' => function ( WP_REST_Request $request ) { return current_user_can( 'edit_posts' ); }

The REST API has no session of its own. Each request is authenticated, the current user is set, and from there current_user_can() behaves exactly as it does inside wp-admin.

Cookies plus a nonce

Requests made from your own logged-in pages already carry the WordPress auth cookie, but the cookie alone is not accepted: the API also requires a nonce created with the action wp_rest, sent either as the _wpnonce parameter or the X-WP-Nonce header. Core verifies it for you, so do not re-check it in your callback. A missing nonce quietly downgrades the request to logged-out; a wrong one returns 403 rest_cookie_invalid_nonce.

Application passwords

WordPress 5.6 added application passwords: per-user, per-application credentials generated on the user's profile screen and sent as HTTP Basic auth. They are for server-to-server callers, CLI scripts and integrations — not for JavaScript on your own site, which should use the cookie and nonce. wp_is_application_passwords_available() gates the feature to sites on SSL or a local environment.

permission_callback is the gate

'__return_true' and function () { return true; } are the same thing written two ways, and both mean "anyone on the internet, unauthenticated, including bots". That is a legitimate choice for a read-only public route and a security bug on anything else. Anything that writes, or reads what is not already public, must check a capability.

Example

Example · php
<?php
add_action(
    'rest_api_init',
    function () {
        register_rest_route(
            'acme/v1',
            '/invoices/(?P<id>\d+)',
            array(
                'methods'             => WP_REST_Server::EDITABLE, // POST, PUT, PATCH
                'callback'            => 'acme_update_invoice',
                'permission_callback' => 'acme_can_edit_invoice',
            )
        );
    }
);

/**
 * Runs BEFORE the callback. Return true, false, or a WP_Error.
 * Never '__return_true' on a route that writes.
 */
function acme_can_edit_invoice( WP_REST_Request $request ) {
    $invoice_id = (int) $request['id'];

    // Check the specific object, not just a blanket role. The meta-capability
    // map turns 'edit_post' into edit_others_posts / edit_published_posts etc.
    if ( ! current_user_can( 'edit_post', $invoice_id ) ) {
        return new WP_Error(
            'acme_cannot_edit_invoice',
            __( 'You are not allowed to edit this invoice.', 'acme' ),
            // 401 when nobody is logged in, 403 when the user simply lacks the
            // capability. Core uses the same distinction.
            array( 'status' => is_user_logged_in() ? 403 : 401 )
        );
    }

    return true;
}

// Do NOT do this inside the callback -- core already verified the wp_rest
// nonce during cookie authentication, and a second check on a request that
// authenticated by application password will fail for no reason.
// wp_verify_nonce( $request->get_header( 'X-WP-Nonce' ), 'wp_rest' );

When to use it

  • Letting a deploy pipeline publish release notes into WordPress without sharing the account password.
  • Locking a write endpoint to editors while leaving its read endpoint public.
  • Debugging a 403 rest_cookie_invalid_nonce on a page that has been open for a day.
  • Giving a partner integration a revocable credential scoped to one service account.

More examples

Handing the nonce to your own front-end script

This is the whole of cookie authentication from the plugin side: enqueue the script, attach the root URL and a wp_rest nonce, and let the browser supply the cookie. credentials: 'same-origin' matters — without it fetch omits the cookie and the request runs as logged out with no error to explain it.

Example · php
<?php
add_action(
    'wp_enqueue_scripts',
    function () {
        wp_enqueue_script(
            'acme-frontend',
            plugins_url( 'build/frontend.js', __FILE__ ),
            array(),
            '1.0.0',
            true
        );

        // The nonce action is literally 'wp_rest'. Any other action fails.
        // For a logged-out visitor wp_create_nonce() still returns a token,
        // but it authenticates nobody -- it is not a login.
        wp_localize_script(
            'acme-frontend',
            'acmeApi',
            array(
                'root'  => esc_url_raw( rest_url( 'acme/v1/' ) ),
                'nonce' => wp_create_nonce( 'wp_rest' ),
            )
        );
    }
);

// In frontend.js:
//   fetch( acmeApi.root + 'invoices/42', {
//       method:      'POST',
//       credentials: 'same-origin',            // send the auth cookie
//       headers: {
//           'Content-Type': 'application/json',
//           'X-WP-Nonce':   acmeApi.nonce,     // or ?_wpnonce= on the URL
//       },
//       body: JSON.stringify( { status: 'paid' } ),
//   } );

Calling the API from another server with an application password

Application passwords are the right answer whenever the caller is not a browser holding a WordPress cookie: CI pipelines, a headless build step, a partner system. No nonce is involved, and revoking one credential does not disturb the user's own login.

Example · php
<?php
/**
 * A cron job on a different host publishing into WordPress.
 *
 * Generate the credential at Users > Profile > Application Passwords
 * (WordPress 5.6+). It is shown once, is revocable per application, and
 * carries exactly the capabilities of the user it belongs to.
 * Store it outside version control -- an env var or wp-config constant.
 */
$user = ACME_WP_USER;          // e.g. 'integrations'
$pass = ACME_WP_APP_PASSWORD;  // 'abcd EFGH ijkl MNOP qrst UVWX' -- spaces are fine

$response = wp_remote_post(
    'https://example.com/wp-json/wp/v2/posts',
    array(
        'headers' => array(
            // RFC 7617 Basic auth. HTTPS is not optional: application passwords
            // are unavailable unless the site is on SSL or a local environment.
            'Authorization' => 'Basic ' . base64_encode( $user . ':' . $pass ),
            'Content-Type'  => 'application/json',
        ),
        'body'    => wp_json_encode(
            array(
                'title'   => 'Nightly build 4.2.1',
                'content' => 'All checks passed.',
                'status'  => 'draft',
            )
        ),
        'timeout' => 15,
    )
);

if ( is_wp_error( $response ) ) {
    error_log( $response->get_error_message() );
} elseif ( 201 !== wp_remote_retrieve_response_code( $response ) ) {
    // The body carries a 'code' and 'message' explaining the refusal.
    error_log( wp_remote_retrieve_body( $response ) );
}

Discussion

  • Be the first to comment on this lesson.