Home › Blog › Article

Securing the WP REST API: Auth, Rate Limits and Attack Surface

Securing the WP REST API: Auth, Rate Limits and Attack Surface

Answer first: Lock down WordPress REST API security by inventorying every public route, disabling endpoints you do not need, requiring Application Passwords or OAuth for write access, enforcing edge and application rate limits with temporary bans, and making every custom route pass through a strict permission_callback. You will know it was done right when an unauthenticated GET /wp-json/ reveals only the routes you intentionally publish, anonymous POST, PUT, PATCH and DELETE requests return 401 or 403, abusive clients receive 429 then a ban, and your logs show named identities for every successful mutation. Verify with curl, WP-CLI, server logs and a staging replay of production traffic before rollout.

Step / Process: Run the 2026 REST Hardening Baseline

  1. Snapshot the route index with curl -s https://example.com/wp-json/ | jq '.routes | keys' on WordPress 6.9 / PHP 8.3 and save it as rest-routes-before.json in your release artefact.
  2. Classify routes into public-read, authenticated-read, authenticated-write and remove-me, then record the decision in docs/rest-api-inventory.md.
  3. Apply auth, rate-limit and route-level changes in a mu-plugin such as wp-content/mu-plugins/rest-security.php so theme switches cannot undo them.
  4. Replay the route index plus a matrix of anonymous and authenticated curl calls in staging; diff against rest-routes-before.json.
  5. Deploy behind Nginx or Cloudflare limits, watch 429, 401 and 403 ratios for 24 hours, then keep the rollback file rest-security.php.bak for one release.
  6. Repeat after every core, plugin or WooCommerce upgrade in 2026, because plugins register routes at rest_api_init and silently expand the attack surface.

This sequence matters because REST registration is distributed: core, WooCommerce, ACF, SEO plugins, headless helpers and your own code all add namespaces during the same hook. A one-off rest_authentication_errors filter is not an inventory. If you are rebuilding a headless front end or evaluating delivery partners alongside this audit, our 2026 vendor guide for building a WordPress site gives a useful procurement baseline without changing the hardening order above.

[ ] Audit What Default Endpoints Expose

Why: WordPress core ships a discoverable route index at /wp-json/. On a default install it advertises /wp/v2/posts, /wp/v2/pages, /wp/v2/media, /wp/v2/comments, /wp/v2/taxonomies, /wp/v2/categories, /wp/v2/tags, /wp/v2/users, /wp/v2/settings, /wp/v2/types, /wp/v2/statuses, /wp/v2/blocks, /wp/v2/templates, /wp/v2/global-styles, /wp/v2/search, /wp/v2/block-renderer, and the /wp-site-health/v1 and /oembed/1.0 namespaces. Even when a response is public, the index tells an attacker which plugins are installed, which post types are queryable, whether author enumeration is possible via ?search=, and where to send crafted context=edit or _fields probes. In 2026, /wp/v2/users still returns public author slugs, avatar URLs and link data for users with published content; /wp/v2/settings is write-protected but its schema still leaks site shape; /wp/v2/search can be used as a cheap content-existence oracle.

How to do it

Start with a machine-readable inventory, not a browser view:

curl -sS -D /tmp/rest-headers.txt https://example.com/wp-json/ 
  | jq '{namespaces: .namespaces, route_count: (.routes | length), routes: (.routes | keys)}' 
  > /tmp/rest-routes-before.json

wp rest api list --format=table --path=/var/www/html

curl -sS 'https://example.com/wp-json/wp/v2/users?per_page=100&_fields=id,slug,link' | jq .
curl -sS -o /dev/null -w '%{http_code}n' 
  'https://example.com/wp-json/wp/v2/posts?context=edit'

Then remove discovery and enumeration you do not need. Keep the index if a headless app consumes it, but strip user routes and block unauthenticated context=edit:

// wp-content/mu-plugins/rest-security.php
add_filter( 'rest_endpoints', function ( $endpoints ) {
    unset( $endpoints['/wp/v2/users'] );
    unset( $endpoints['/wp/v2/users/(?P<id>[d]+)'] );
    unset( $endpoints['/wp/v2/users/me'] );
    return $endpoints;
}, 20 );

add_filter( 'rest_pre_dispatch', function ( $result, $server, $request ) {
    if ( 'edit' === $request->get_param( 'context' ) && ! is_user_logged_in() ) {
        return new WP_Error( 'rest_forbidden_context', 'Edit context requires authentication.', [ 'status' => 401 ] );
    }
    return $result;
}, 10, 3 );

If the site is not headless, go further and return a controlled error for anonymous index requests while leaving logged-in editors intact:

add_filter( 'rest_authentication_errors', function ( $result ) {
    if ( ! empty( $result ) ) {
        return $result;
    }
    if ( is_user_logged_in() ) {
        return true;
    }
    $uri = $_SERVER['REQUEST_URI'] ?? '';
    if ( 0 === strpos( $uri, '/wp-json/' ) || '/wp-json' === $uri ) {
        return new WP_Error( 'rest_disabled_guest', 'REST index is private on this site.', [ 'status' => 401 ] );
    }
    return $result;
}, 5 );

How to verify

Expect 404 for /wp-json/wp/v2/users, 401 for anonymous context=edit, and an unchanged 200 for your public posts feed. Compare jq -r '.routes[]' /tmp/rest-routes-before.json with a fresh capture; the diff should contain only deliberate removals. Check X-WP-Total and X-WP-TotalPages headers are still sane for paginated public collections.

Pitfalls and rollback

Common mistakes: removing /wp/v2/users/me breaks the block editor’s current-user lookup; a blanket rest_authentication_errors block breaks Gutenberg, Jetpack, WooCommerce cart blocks, mobile apps and wp-json/oembed previews; caching /wp-json/ at the CDN serves a stale route map after a plugin update. Scope by URI and is_user_logged_in(), bypass /wp-json/oembed, and purge CDN cache on deploy. Roll back by renaming wp-content/mu-plugins/rest-security.php to rest-security.php.bak over SSH, then wp cache flush and curl -X PURGE https://example.com/wp-json/. Do not roll back by editing the theme.

Why: Browser cookie auth plus a valid X-WP-Nonce is fine for Gutenberg, but it is a poor fit for scripts, mobile clients, CI jobs and integrations. Application Passwords have been in core since WordPress 5.6 and remain the simplest 2026 baseline: a 24-character, space-grouped token stored as a hash in wp_usermeta, sent via HTTP Basic over HTTPS, revocable per device, and compatible with wp rest and external clients. OAuth 2.1 / OIDC via a trusted plugin is the right answer when you need scoped consent, short-lived access tokens, refresh rotation, a separate identity provider, or multi-client audit trails. Neither replaces TLS, and neither should be handed to a shared administrator account.

Get a Quote

Email + requirement + budget range — we reply with a quote within 24 hours.

How to do it: Application Passwords

Confirm the feature is available and force HTTPS before issuing tokens. WordPress core disables the UI when the site is not served over HTTPS, but an explicit check protects CLI provisioning too:

wp option get siteurl --path=/var/www/html
wp eval 'var_export( wp_is_application_passwords_available() );' --path=/var/www/html
wp user application-password create deploy-bot 7 --app-name=ci-release-2026 --porcelain --path=/var/www/html

# Store the printed token in your secret manager, never in wp-config.php.
curl -sS -u 'deploy-bot:XXXX XXXX XXXX XXXX XXXX XXXX' 
  -H 'Content-Type: application/json' 
  -d '{"title":"Release note","status":"draft","content":"Staged"}' 
  https://example.com/wp-json/wp/v2/posts | jq '.id, .status'

Create one dedicated user per integration with the minimum role. A deploy bot that only drafts posts should be author, not administrator. Block Application Passwords entirely on sites that use only OAuth by returning false from wp_is_application_passwords_available, and keep a revocation runbook:

wp user application-password list deploy-bot --format=table --path=/var/www/html
wp user application-password delete deploy-bot <uuid> --path=/var/www/html
wp user application-password delete-all deploy-bot --path=/var/www/html   # emergency

How to do it: OAuth 2.1 / OIDC

When consent and scopes are required, install an actively maintained OAuth server plugin, register clients with explicit scopes such as posts:read and posts:write, set access-token TTL to 600–900 seconds, enable refresh-token rotation, and map the external sub claim to a low-privilege WordPress user rather than auto-promoting. Terminate TLS at the edge, forward Authorization: Bearer … untouched, and reject tokens whose aud is not your siteurl. Keep the official REST authentication reference at developer.wordpress.org in your runbook so onboarding engineers do not reinvent nonce handling.

How to verify

Anonymous POST /wp-json/wp/v2/posts must return 401 rest_cookie_invalid_nonce or 401 rest_not_logged_in; a valid Application Password must return 201 with a draft ID; a revoked token must return 401 within one request. For OAuth, decode the JWT in staging and confirm exp, aud, scope, then confirm a token with posts:read gets 403 on a write. Grep access logs for Authorization: Basic appearing in query strings or referrers; it must never appear there.

Pitfalls and rollback

Basic auth is stripped by some Nginx fastcgi_pass setups unless you add fastcgi_param HTTP_AUTHORIZATION $http_authorization;. Apache with mod_security may block the space-grouped token. Tokens pasted into CI logs are the most common incident we see in 2026; rotate immediately with delete-all. Rolling back OAuth means disabling the plugin, revoking refresh tokens at the IdP, and switching clients to Application Passwords from the secret manager; rolling back Application Passwords means delete-all plus re-enabling cookie/nonce auth for editors. Never roll back by switching the integration user to administrator.

[ ] Enforce Rate Limiting and Temporary Bans

Why: REST endpoints are cheap to call and expensive to answer. /wp/v2/search?search=, /wp/v2/users?search=, /wp-json/ itself, ?_fields permutations and WooCommerce /wc/store/v1/products are favourite enumeration and inventory-scraping targets. A 401 still costs a PHP worker, so auth alone does not protect capacity. Put a coarse limit at the edge and a finer, identity-aware limit in PHP; then escalate repeat offenders to a temporary ban so you are not paying for their retries.

How to do it: edge layer

In Nginx 1.27 on a 2026 stack, define zones before server {} and apply them only to REST traffic:

# /etc/nginx/conf.d/rest-limits.conf
limit_req_zone $binary_remote_addr zone=rest_guest:10m rate=12r/m;
limit_req_zone $binary_remote_addr zone=rest_burst:10m rate=1r/s;
limit_req_status 429;
limit_conn_zone $binary_remote_addr zone=rest_conn:10m;

# inside server {}
location ~ ^/wp-json/ {
    limit_req zone=rest_guest burst=8 nodelay;
    limit_req zone=rest_burst burst=20 nodelay;
    limit_conn rest_conn 6;
    add_header Retry-After 60 always;
    try_files $uri $uri/ /index.php?$args;
}

location = /wp-json {
    limit_req zone=rest_guest burst=4 nodelay;
    try_files $uri $uri/ /index.php?$args;
}

Test with nginx -t && systemctl reload nginx. If you use Cloudflare, add a WAF rate-limit rule for starts_with(http.request.uri.path, "/wp-json") with the same 12 requests/minute threshold and a 60-second Block action, then exempt your monitoring IPs and your headless build runner by a scoped token rather than a blanket allowlist.

How to do it: application layer and bans

Edge limits do not know whether a caller is an editor, a deploy bot or an anonymous scraper. Add a small transient counter keyed by IP plus authenticated user ID, and hand repeat offenders to the edge ban list:

add_filter( 'rest_pre_dispatch', function ( $result, $server, $request ) {
    if ( ! empty( $result ) ) {
        return $result;
    }
    $ip  = $_SERVER['REMOTE_ADDR'] ?? 'unknown';
    $uid = get_current_user_id() ?: 'guest';
    $key = 'rl_' . md5( $ip . '|' . $uid . '|' . $request->get_route() );
    $hit = (int) get_transient( $key ) + 1;
    set_transient( $key, $hit, MINUTE_IN_SECONDS );

    if ( $hit > 30 ) {
        set_transient( 'ban_' . md5( $ip ), 1, 15 * MINUTE_IN_SECONDS );
        error_log( sprintf( 'REST ban ip=%s uid=%s route=%s', $ip, $uid, $request->get_route() ) );
        return new WP_Error( 'rest_rate_limited', 'Too many REST requests.', [ 'status' => 429 ] );
    }
    if ( get_transient( 'ban_' . md5( $ip ) ) ) {
        return new WP_Error( 'rest_banned', 'Temporarily banned.', [ 'status' => 429 ] );
    }
    return $result;
}, 9, 3 );

On a multi-node site, get_transient() is per-node unless you have a persistent object cache. Point it at Redis 7.x via wp redis enable or a drop-in at wp-content/object-cache.php, otherwise counts drift. For hard bans, push the IP to Nginx deny or the CDN firewall via API from the same error_log consumer, and expire it after 15 minutes. Keep Retry-After on every 429 so well-behaved clients back off.

How to verify

for i in $(seq 1 20); do
  curl -sS -o /dev/null -w '%{http_code} ' https://example.com/wp-json/wp/v2/search?search=a
done; echo

grep -c ' 429 ' /var/log/nginx/access.log
wp transient list --search=rl_ --path=/var/www/html | head
wp transient list --search=ban_ --path=/var/www/html | head

You should see 200 for the first burst, then 429, a populated ban_ transient, and a matching REST ban line in the PHP error log. Authenticated editor traffic in Gutenberg should stay under threshold; if it does not, raise the authenticated bucket rather than disabling the rule.

Pitfalls and rollback

Do not key only on IP behind a reverse proxy: use X-Forwarded-For only from trusted proxy ranges or you will let attackers rotate fake headers. Do not rate-limit /wp-json/oembed and admin-ajax editor heartbeats with the same bucket. A missing persistent object cache turns the PHP counter into a no-op on round-robin nodes. Rollback: comment the two limit_req lines, nginx -t && systemctl reload nginx, then wp transient delete --all and rename the mu-plugin file. Keep the CDN rule disabled but documented for 24 hours so you can compare traffic shape.

[ ] Put Permission Callbacks on Every Custom Route

[ ] Put Permission Callbacks on Every Custom Route

Why: Since WordPress 5.5, omitting permission_callback triggers _doing_it_wrong(); in 2026 core still registers the route but emits a notice, and a surprising number of agency plugins ship with 'permission_callback' => '__return_true' copied from a tutorial. That single line turns a private sync endpoint, an export route or a webhook receiver into an unauthenticated write primitive. The callback is the only place core checks authorisation for your namespace; rest_authentication_errors answers “who are you”, the callback answers “may you do this exact thing to this exact object”.

How to do it

Register with an explicit capability check, schema validation and object-level ownership. Do not rely on the route being “hidden” because it is not in the index:

add_action( 'rest_api_init', function () {
    register_rest_route( 'acme/v1', '/invoice/(?P<id>d+)', [
        'methods'             => WP_REST_Server::EDITABLE, // POST, PUT, PATCH
        'callback'            => 'acme_update_invoice',
        'permission_callback' => function ( WP_REST_Request $request ) {
            if ( ! is_user_logged_in() ) {
                return new WP_Error( 'acme_not_logged_in', 'Authentication required.', [ 'status' => 401 ] );
            }
            $id = (int) $request->get_param( 'id' );
            if ( ! current_user_can( 'edit_post', $id ) ) {
                return new WP_Error( 'acme_forbidden', 'Not allowed on this invoice.', [ 'status' => 403 ] );
            }
            if ( ! wp_verify_nonce( $request->get_header( 'X-WP-Nonce' ), 'wp_rest' )
                 && ! acme_is_application_password_request() ) {
                return new WP_Error( 'acme_bad_nonce', 'Invalid nonce.', [ 'status' => 403 ] );
            }
            return true;
        },
        'args' => [
            'id'     => [ 'type' => 'integer', 'minimum' => 1, 'required' => true ],
            'amount' => [ 'type' => 'number', 'minimum' => 0, 'required' => true ],
            'note'   => [ 'type' => 'string', 'maxLength' => 500, 'sanitize_callback' => 'sanitize_text_field' ],
        ],
    ] );
} );

function acme_update_invoice( WP_REST_Request $request ) {
    $id = (int) $request->get_param( 'id' );
    update_post_meta( $id, '_acme_amount', (float) $request->get_param( 'amount' ) );
    return rest_ensure_response( [ 'id' => $id, 'updated' => true ] );
}

For machine-to-machine routes, replace the nonce branch with a scope check against your OAuth token or a dedicated capability such as acme_sync_invoices granted only to the integration user via WP_User::add_cap(). For truly public read routes, still write a callback that returns true deliberately and add 'args' limits plus rest_prepare_* filtering so _fields cannot pull private meta. The route-registration reference on developer.wordpress.org is the canonical source for the args and permission_callback shape used above.

How to verify

curl -sS -o /dev/null -w '%{http_code}n' -X PATCH 
  -H 'Content-Type: application/json' -d '{"amount":99}' 
  https://example.com/wp-json/acme/v1/invoice/42

curl -sS -o /dev/null -w '%{http_code}n' -X PATCH 
  -u 'deploy-bot:XXXX XXXX XXXX XXXX XXXX XXXX' 
  -H 'Content-Type: application/json' -d '{"amount":99}' 
  https://example.com/wp-json/acme/v1/invoice/42

wp eval 'print_r( rest_get_server()->get_routes( "acme/v1" ) );' --path=/var/www/html
grep -R "__return_true" wp-content/plugins wp-content/mu-plugins --include=*.php

Expect 401 anonymous, 403 for a logged-in user who cannot edit post 42, 200 for the scoped bot, and zero __return_true permission callbacks in your own codebase. Add a PHPUnit or WP-CLI integration test that asserts the status codes so a future refactor cannot silently widen access. If custom routes are part of a larger build, our WordPress Plugin Development team typically ships this test matrix with the mu-plugin rather than leaving it to a launch-day manual check.

Pitfalls and rollback

Three failures recur: returning true after checking only is_user_logged_in() (any subscriber can write), checking current_user_can( 'manage_options' ) on a route meant for authors (breaks the client), and trusting $request->get_param( 'id' ) before casting and validating (type juggling). Also remember that WP_REST_Server::EDITABLE includes POST; if you meant update-only, declare the methods array explicitly. Rollback by shipping a previous mu-plugin build from your release artefact, not by flipping the callback to __return_true; if a client is locked out, grant the missing capability to its dedicated user, re-run the curl matrix, and remove the capability after the incident review. Keep the old route registered but returning 410 for one release so cached mobile apps fail loudly instead of silently writing to a renamed endpoint. For broader context on shipping route changes alongside block work, see our technical column index.

Frequently asked questions

Does disabling the REST API break the block editor in WordPress 6.9?

Yes, if you block all anonymous REST traffic with a blanket rest_authentication_errors filter. Gutenberg needs /wp/v2/users/me, /wp/v2/types, /wp/v2/block-renderer and oEmbed even for logged-in editors. Scope restrictions by URI, keep is_user_logged_in() as a bypass, and test an actual post save in staging before deploying.

Are Application Passwords safe enough, or do I need OAuth in 2026?

Application Passwords are safe for one trusted integration over HTTPS with a dedicated low-privilege user and a secret manager. Choose OAuth 2.1 / OIDC when you need per-client scopes, consent screens, short TTLs, refresh rotation or a central IdP. Many agency sites run both: OAuth for customer-facing apps, Application Passwords for CI and cron.

Where should rate limits live: CDN, Nginx or PHP?

All three, with different jobs. CDN and Nginx absorb volume before PHP starts; a PHP transient counter adds identity- and route-aware thresholds and triggers bans. Use a persistent object cache on multi-node hosting, otherwise the PHP layer undercounts. Always return 429 with Retry-After and log the decision.

Final action checklist

  • [ ] Save rest-routes-before.json, diff it after deploy, and confirm only intentional routes remain public.
  • [ ] Issue one revocable Application Password or scoped OAuth client per integration; prove anonymous writes return 401.
  • [ ] Reload Nginx with REST zones, replay 20 requests, and confirm 429 plus a 15-minute ban in logs.
  • [ ] Grep for __return_true and add an automated status-code test for every custom permission_callback.
  • [ ] Keep rest-security.php.bak, the WP-CLI revocation commands and a CDN purge step in the 2026 runbook.

Need this audited against a live site? Our engineers review route inventories, auth flows and custom endpoints, then ship tested mu-plugins. See plugin development or browse all services.

References & further reading

Need WordPress Development?

Free proposal and quote within 24 hours — we serve clients worldwide.

QQ