REST Authentication
Cookie authentication with the wp_rest nonce, application passwords, and why permission_callback must not default to __return_true.
'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
<?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.
<?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.
<?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