WordPress REST API security: hiding endpoints and preventing user enumeration

A security scan flagged /wp-json/wp/v2/users as exposed. This article explains what the endpoint actually reveals to unauthenticated visitors, which data is the real risk (login slugs, not emails), and three ways to lock it down without breaking the block editor or Application Passwords.

Table of contents

What the REST API exposes by default

The WordPress REST API has been bundled into core since version 4.7 (December 2016). By design, content that is public on your site is also public through the API. That includes posts, pages, comments, categories, tags, and users who have published content. This is not a bug. It is how headless frontends, mobile apps, and the block editor retrieve data.

The default base URL is https://yoursite.nl/wp-json/wp/v2/. Visiting it in a browser returns a JSON index of every registered route. No authentication needed. The routes themselves vary by what plugins are installed, but WordPress core always registers endpoints for posts, pages, media, users, comments, categories, tags, and taxonomies.

The security question is not "is data accessible?" (it is, by design) but "is the wrong data accessible?" For most endpoints, the answer is no. The users endpoint is the exception.

User enumeration via /wp-json/wp/v2/users

Open https://yoursite.nl/wp-json/wp/v2/users in a browser. If you have not applied any restrictions, you get a JSON array listing every WordPress user who has published at least one post on a public post type. This is the endpoint that security scanners flag, and the flag is legitimate.

The REST API Users reference documents exactly which fields are returned to unauthenticated requests (the view context):

Field Example value Risk level
id 1 Low (internal database ID)
name Jorijn Schrijvershof Low (display name, already visible on posts)
slug jorijn High (derived from user_login)
url https://jorijn.com Low (public profile URL)
description WordPress developer Low (public bio)
link https://yoursite.nl/author/jorijn/ Low (author archive, already public)
avatar_urls {"96":"https://secure.gravatar.com/..."} Low (Gravatar hash)

The field that matters is slug. On most WordPress installations, the slug is identical to the user_login value unless someone manually changed it. An attacker who enumerates slugs has a list of valid login usernames for credential-stuffing attacks against wp-login.php or xmlrpc.php.

A common misconception is that this endpoint leaks email addresses. It does not. The email and username fields require the edit context, which demands authentication with the list_users capability. The actual risk is the slug-to-login equivalence, not email exposure.

This behavior has been in core since WordPress 4.7. CVE-2017-5487 documented inadequate restrictions on user listings in WordPress before 4.7.1. The 4.7.1 patch restricted the endpoint to users with published posts, but did not add authentication. That is still the state in WordPress 6.7.

Hiding the users endpoint specifically

Three approaches, from least to most invasive. Pick one.

Option A: return an empty result for unauthenticated requests

This is the most surgical fix. It leaves every other REST endpoint working and only hides user data from unauthenticated visitors. Create a file called hide-rest-users.php inside the wp-content/mu-plugins/ folder. You can do this through your hosting panel's file manager or via SFTP. If the mu-plugins folder does not exist yet, create it first. This is a must-use plugin, so it loads automatically without activation. Paste the following code into the file:

<?php
// Deny unauthenticated access to the /wp/v2/users endpoint.
// Authenticated requests (Gutenberg, Application Passwords) pass through.
add_filter( 'rest_pre_dispatch', function ( $result, $server, $request ) {
    // Match any request to /wp/v2/users or /wp/v2/users/<id>
    if ( preg_match( '#^/wp/v2/users#', $request->get_route() ) && ! is_user_logged_in() ) {
        return new WP_Error(
            'rest_users_cannot_list',
            'User data is not available.',
            array( 'status' => 403 )
        );
    }
    return $result;
}, 10, 3 );

Expected output after applying: curl -s https://yoursite.nl/wp-json/wp/v2/users | python3 -m json.tool returns a JSON object with "code": "rest_users_cannot_list" and HTTP status 403. Logged-in requests from the block editor continue to work because Gutenberg authenticates via WordPress cookies.

Option B: use a plugin

If you prefer not to write code, WP Cerber Security and Disable REST API both offer granular endpoint blocking. WP Cerber lets you allowlist specific namespaces while blocking others. The Stop User Enumeration plugin focuses specifically on blocking ?author=N queries and the REST users endpoint.

Evaluate whether the plugin's scope matches your need. A plugin that blanket-blocks all unauthenticated REST access may break public-facing features that use the REST API (live search, headless frontends, WooCommerce cart interactions). A plugin that only blocks the users endpoint is the right scope for this specific problem.

Option C: block at the web server (nginx or Apache)

If you have SSH access: you can handle the block at the web server level so the request never reaches PHP. This is faster but less flexible: you cannot distinguish authenticated from unauthenticated requests at this layer. If you do not manage your own web server, use Option A or B instead.

# nginx: block /wp-json/wp/v2/users entirely
location ~* ^/wp-json/wp/v2/users {
    deny all;
    access_log off;
    return 403;
}
# Apache: in .htaccess

    RewriteEngine On
    RewriteRule ^wp-json/wp/v2/users - [F,L]

Caveat: this blocks the endpoint for everyone, including logged-in administrators. The block editor fetches user data from this endpoint for the author selector dropdown. If you use Gutenberg and this dropdown breaks, switch to Option A instead, which only blocks unauthenticated requests.

You will know it worked when curl -s -o /dev/null -w "%{http_code}" https://yoursite.nl/wp-json/wp/v2/users returns 403 instead of 200.

Restricting the entire REST API to authenticated users

If your site does not serve any public-facing content through the REST API (no headless frontend, no public search widget, no WooCommerce cart on the frontend), you can require authentication for all REST requests. This is the canonical pattern from the REST API FAQ, using the rest_authentication_errors hook (available since WordPress 4.4). Create a file called require-rest-auth.php in wp-content/mu-plugins/ using your hosting panel's file manager or SFTP:

<?php
// Require authentication for all REST API requests.
add_filter( 'rest_authentication_errors', function ( $result ) {
    // If a previous authentication check already failed, pass it through.
    if ( is_wp_error( $result ) ) {
        return $result;
    }
    // If a previous check already succeeded, pass it through.
    if ( true === $result ) {
        return $result;
    }
    // No authentication has been performed yet. Require it.
    if ( ! is_user_logged_in() ) {
        return new WP_Error(
            'rest_not_logged_in',
            'Authentication is required.',
            array( 'status' => 401 )
        );
    }
    return $result;
} );

This is safe for Gutenberg. The block editor authenticates via WordPress session cookies. When an administrator is logged in and opens the editor, is_user_logged_in() returns true, and every REST request from the editor passes through.

This breaks the following, by design:

  • Headless frontends that fetch content from the REST API without authentication
  • Public-facing search widgets powered by the REST API
  • WooCommerce cart, checkout, and account pages that make unauthenticated REST calls
  • Any external integration that reads public data from your API without credentials
  • The ?rest_route= fallback URL format (same authentication check applies)

If any of these apply to your site, use the targeted users-endpoint block from the previous section instead.

Expected output: curl -s https://yoursite.nl/wp-json/wp/v2/posts | python3 -m json.tool returns "code": "rest_not_logged_in" with HTTP 401.

Why you must not disable the REST API entirely

Disabling the REST API is different from requiring authentication. "Disabling" means returning an error for all REST requests, including those from logged-in administrators. The REST API handbook is direct: "doing so will break WordPress Admin functionality that depends on the API being active."

The block editor (Gutenberg) is the biggest dependency. It fetches posts, blocks, users, media, and settings through the REST API on every page load. A real-world report in Gutenberg issue #9101 confirmed the result: a blank white screen in the editor when a security plugin disabled the REST API entirely. The fix was re-enabling it.

Other core features that depend on the REST API: the site health check (wp-admin > Tools > Site Health), the block directory browser, the plugin/theme search in the dashboard, and Application Passwords management. Disabling the API breaks all of them for all users, including admins.

The correct approach is always restrict, not disable. Require authentication (the rest_authentication_errors hook above) or block specific endpoints. Both keep the API working for logged-in users while denying unauthenticated access.

Application Passwords for REST API authentication

Application Passwords were added in WordPress 5.6 (December 2020) and are the recommended authentication method for programmatic REST API access.

Each Application Password is a 24-character alphanumeric token with over 142 bits of entropy. They are stored as hashed values in user metadata, individually revocable, and track the last-used timestamp (accurate within 24 hours). They work with the REST API and XML-RPC, but cannot be used on wp-login.php. The permissions of an Application Password are inherited from the user who created it.

How to create one. Go to wp-admin > Users > Profile > Application Passwords, enter a name for the integration (e.g., "CI deploy script" or "iOS app"), and click "Add New Application Password". WordPress generates the password once. Copy it immediately; you cannot retrieve it later.

How to use one. Send it as HTTP Basic Auth with the username and the Application Password (spaces in the password are cosmetic and ignored):

# Fetch posts authenticated via Application Password
curl -u "jorijn:ABCD 1234 EFGH 5678 IJKL 9012" \
  https://yoursite.nl/wp-json/wp/v2/posts?context=edit

HTTPS is required by default. WordPress rejects Application Password authentication over plain HTTP unless you explicitly override it with add_filter( 'wp_is_application_passwords_available', '__return_true' ). Do not override it. The credential is sent in cleartext over HTTP; that defeats the purpose.

A misconception worth addressing: Application Passwords do not make the REST API less secure. The official WP 5.6 communications state directly that they "do not expose the REST API to new vulnerabilities." Before Application Passwords existed, developers used the legacy Basic Auth plugin (which WordPress itself labels as "should only be used for development and testing") or shared their main WordPress password. Application Passwords are the structured, revocable, auditable replacement.

Rate limiting REST API requests at the server

The same rate-limiting patterns from the brute force protection article apply to the REST API. The REST API is an additional attack surface for credential stuffing (via Application Passwords or cookie-based auth) and for resource exhaustion (expensive queries against /wp/v2/posts?per_page=100&_embed).

If your site is behind Cloudflare, create a rate-limiting rule in the Cloudflare dashboard under Security > Rate Limiting Rules for path /wp-json/ with a threshold appropriate to your traffic (start at 120 requests per minute per IP and tighten from there). Many managed WordPress hosts also offer built-in rate limiting through their control panel.

If you have SSH access and manage your own nginx configuration, add a rate limit zone for the REST API path:

# In the http {} block
limit_req_zone $binary_remote_addr zone=restapi:10m rate=60r/m;

# In the server {} block
location /wp-json/ {
    limit_req zone=restapi burst=30 nodelay;
    try_files $uri $uri/ /index.php?$args;
}

Sixty requests per minute per IP with a burst of thirty is reasonable for most sites. Headless frontends or mobile apps that make many API calls may need a higher limit. Tune based on your access logs.

Security headers for the REST API

The REST API responses inherit the security headers your web server sets globally. The headers that matter most for API responses:

  • X-Content-Type-Options: nosniff prevents browsers from MIME-sniffing JSON responses as HTML. WordPress sets this by default on REST responses since 4.7.
  • X-Frame-Options: DENY or SAMEORIGIN prevents embedding API responses in iframes. Not strictly necessary for JSON, but good hygiene.
  • Cache-Control: no-store, private on authenticated responses prevents proxies from caching personalized data. WordPress handles this for edit context responses.

Most managed WordPress hosts and Cloudflare set these headers by default. You can verify by checking the response headers in your browser's developer tools: open the Network tab, load https://yoursite.nl/wp-json/wp/v2/, and inspect the response headers.

If you have SSH access and manage your own nginx configuration, add the headers manually if they are missing:

location /wp-json/ {
    add_header X-Content-Type-Options "nosniff" always;
    add_header X-Frame-Options "SAMEORIGIN" always;
    # ... other directives
}

What this article does not cover

  • Custom REST API endpoints registered by plugins or themes. Those endpoints have their own permission_callback (mandatory since WordPress 5.5, which issues a _doing_it_wrong notice if missing). Auditing custom endpoints is a plugin-by-plugin task.
  • The ?author=N query string enumeration. This is a separate, older enumeration vector that predates the REST API. The Stop User Enumeration plugin covers both vectors.
  • WAF rule sets for the REST API (ModSecurity, Cloudflare managed rules). Those are infrastructure-specific and vary by provider.
  • OAuth 2.0 and JWT authentication. These are plugin-provided authentication methods (e.g., WP OAuth Server) that sit outside WordPress core. The hardening checklist covers the authentication baseline.

When to escalate

Collect the following before asking for help:

  • The WordPress version, PHP version, and web server (nginx or Apache)
  • The output of curl -s https://yoursite.nl/wp-json/wp/v2/users | head -50 (what the endpoint currently returns)
  • Which REST API restriction method you applied and where (mu-plugin, plugin name, web server config)
  • Whether the block editor (Gutenberg) still works after the change
  • A list of plugins that depend on the REST API (WooCommerce, headless themes, search widgets)
  • The security scanner report that flagged the endpoint (Wordfence, WPScan, Acunetix, or other)

Escalate when:

  • You applied the rest_authentication_errors filter and the block editor shows a blank screen. Something is blocking authenticated requests too. Check that no other plugin is returning a WP_Error before yours runs.
  • Your WooCommerce checkout broke after restricting the API. WooCommerce uses unauthenticated REST endpoints for the cart. You need endpoint-level blocking (users only), not API-wide auth.
  • A penetration test still flags user enumeration after you blocked wp-json/wp/v2/users. Check the ?author=N vector: curl -s -o /dev/null -w "%{http_code}" https://yoursite.nl/?author=1 should return 403 or redirect to a non-informative page.
  • You see authenticated REST API requests from IPs you do not recognise. Check wp-admin > Users > Profile > Application Passwords for each admin user. Revoke any passwords you do not recognise. If you suspect a compromised account, the WordPress hacked and malware redirect article covers incident response.

Want fewer security surprises?

Staying safe is routine work: patching, monitoring, backups and defense-in-depth—done consistently.

See WordPress maintenance

Search this site

Start typing to search, or browse the knowledge base and blog.