WordPress 4.7 shipped on 6 December with the REST API content endpoints in core, ending a three-year split where the infrastructure was in core and everything useful was in a plugin. For sites already running the plugin the migration is small; the more interesting question is what changes for every site that upgrades without asking for any of this.
The symptom
# four client sites, four different versions of the same plugin
site-a rest-api 2.0-beta13
site-b rest-api 2.0-beta15
site-c rest-api 2.0
site-d — (uses admin-ajax, because the plugin was 'not stable yet')Four integrations, three of them against different beta versions with incompatible response shapes, and one that avoided the whole question by building endpoints by hand.
Why it happens
The API was merged in two parts. 4.4 brought the infrastructure — the server, the routing, register_rest_route() — and no content endpoints, so a stock install had a REST API with nothing in it. Anything wanting /wp/v2/posts needed the feature plugin, which stayed in beta for two years while the authentication story was argued about.
So “does this site have the REST API” had no yes-or-no answer for three years, and the ecosystem built around whichever half it happened to have.
The fix
Removing the plugin
Core’s endpoints are the plugin’s endpoints at their final version, on the same routes. For a site on the released 2.0 the change is deactivation.
$ wp core update --version=4.7
$ wp plugin deactivate rest-api
$ curl -s https://site-c.example.com/wp-json/wp/v2/posts?per_page=1 | head -c 120
[{"id":412,"date":"2016-11-02T10:14:22","slug":"..."The beta sites were the work. Response shapes changed between betas — the most disruptive being that content and title became objects with a rendered key rather than plain strings — so each client had to be checked against the final format.
// beta13
post.title // "Hello world"
// 2.0 and core
post.title.rendered // "Hello world"
post.title.raw // only with context=edit and the right capability
Custom routes, and the callback that is not optional
add_action( 'rest_api_init', function () {
register_rest_route( 'shop/v1', '/stock/(?P<sku>[A-Z0-9-]+)', array(
'methods' => WP_REST_Server::READABLE,
'callback' => 'shop_stock_endpoint',
'permission_callback' => '__return_true', // deliberately public
'args' => array(
'sku' => array(
'validate_callback' => function ( $value ) {
return (bool) preg_match( '/^[A-Z0-9-]{3,20}$/', $value );
},
),
),
) );
} );
The args block is validation the endpoint no longer has to do, and it produces a proper 400 with a message rather than a callback dealing with rubbish input. permission_callback set to __return_true explicitly is the habit worth forming — a reviewer can then tell a public endpoint from a forgotten one.
Authentication is still the unsolved half
What shipped is cookie authentication with a nonce, which works for JavaScript running inside an authenticated WordPress page and for nothing else.
// works: the admin, or a logged-in front-end page
wp_localize_script( 'my-app', 'wpApi', array(
'root' => esc_url_raw( rest_url() ),
'nonce' => wp_create_nonce( 'wp_rest' ),
) );
fetch( wpApi.root + 'wp/v2/posts', {
credentials: 'same-origin',
headers: { 'X-WP-Nonce': wpApi.nonce }
} );
A mobile application or a server-to-server integration has no cookie and no nonce, so it needs OAuth 1.0a or application passwords — both of which are plugins, neither of which is in core, and that is precisely the argument that kept the whole thing in beta for two years.
Warning
Basic authentication over HTTPS is what most tutorials suggest and it means storing a WordPress password in a client. On a site where that account can edit posts, upload files and install plugins, a leaked integration credential is a full compromise. Use a dedicated user with the narrowest role that works.
The read API nobody asked for
Every site upgrading to 4.7 gains public read endpoints for posts, pages, categories, tags, media and — the one that surprises people — users.
$ curl -s https://any-site.example.com/wp-json/wp/v2/users | python -m json.tool | head
[
{
"id": 1,
"name": "Ada Lovelace",
"slug": "ada",
"link": "https://any-site.example.com/author/ada/"That is not a vulnerability — the same information was already available from author archives — but it is now a single request returning every author, which is a materially easier enumeration for anyone preparing a brute-force list. Whether to restrict it is a real decision rather than an obvious one.
// require authentication for the users endpoint only
add_filter( 'rest_authentication_errors', function ( $result ) {
if ( ! empty( $result ) ) {
return $result;
}
$route = $GLOBALS['wp']->query_vars['rest_route'] ?? '';
if ( 0 === strpos( $route, '/wp/v2/users' ) && ! is_user_logged_in() ) {
return new WP_Error( 'rest_forbidden', 'Authentication required.', array( 'status' => 401 ) );
}
return $result;
} );
Verifying it worked
$ wp plugin list --status=active | grep -c rest-api
0
$ for r in posts pages categories media; do
> printf '%-12s %sn' "$r" "$(curl -s -o /dev/null -w '%{http_code}'
> https://site-c.example.com/wp-json/wp/v2/$r)"
> done
posts 200
pages 200
categories 200
media 200
$ curl -s -o /dev/null -w '%{http_code}' https://site-c.example.com/wp-json/wp/v2/users
401Caching a surface that was not there yesterday
A page cache in front of WordPress typically matches on the URL and skips anything with a cookie. /wp-json/ is neither excluded nor included by most existing configurations, so it arrives at PHP every single time.
location ~ ^/wp-json/ {
# never cache a request carrying a login cookie or an auth header
set $skip 0;
if ($http_cookie ~* "wordpress_logged_in") { set $skip 1; }
if ($http_authorization) { set $skip 1; }
fastcgi_cache wpapi;
fastcgi_cache_bypass $skip;
fastcgi_no_cache $skip;
fastcgi_cache_valid 200 60s;
add_header X-Cache $upstream_cache_status;
fastcgi_pass unix:/var/run/php/php7.0-fpm.sock;
include fastcgi_params;
}
Sixty seconds is short enough that editors do not notice and long enough to absorb a client polling every few seconds — which is what a JavaScript front end built on this will do. Both bypass conditions are needed: without the header check, an authenticated request served from cache returns another user’s context=edit response.
Invalidation is the part to leave alone at first. Purging on save_post looks obvious and gets complicated quickly, because one post appears in a dozen collection responses with different query strings. A short TTL is worse in theory and considerably safer in practice.
What this costs
Every site now has a public API surface that was not part of anyone’s threat model when the site was built. It is read-only and it is real: uncached, unrate-limited, and capable of returning a hundred posts per request to anyone who asks. A page cache in front of the site does not cover /wp-json/ unless someone configures it to.
The authentication gap also means that anything genuinely integrating with WordPress from outside still needs a plugin, so the promise of the merge is only half delivered. Building against it now means betting on which of the competing authentication approaches wins, which is the same bet that was available last year with a different set of unknowns.