Skip to main content

Security

Security Model

ReactWP combines WordPress permissions with a restrictive REST gate. It does not make every WordPress REST endpoint public simply because the frontend uses React.

Treat three cases separately:

  • public ReactWP content endpoints
  • authenticated WordPress REST requests
  • project-specific private endpoints

REST Gate

Routes listed by rwp_allowed_rest_routes bypass the default ReactWP block. The built-in ReactWP endpoints are added there by the runtime.

For every other REST request:

  • an authenticated administrator with a valid WordPress REST session can continue
  • a browser that merely has an admin account but sends no valid cookies/nonce is not authenticated
  • guests and non-admin users are blocked unless the route is explicitly allowed

An external request does not gain admin access from the username alone. Cookie requests must use credentials: 'include', and protected WordPress REST requests normally need the current X-WP-Nonce.

Public Project Routes

Allow only the exact project route that must be public:

add_filter('rwp_allowed_rest_routes', function($routes){
$routes[] = '/my-project/v1/contact';
return $routes;
});

The allowlist only lets the request reach its REST registration. Your permission_callback still owns authorization.

register_rest_route('my-project/v1', '/account', [
'methods' => 'GET',
'permission_callback' => function(){
return current_user_can('read');
},
'callback' => 'my_project_account_payload',
]);

Do not use __return_true for private data.

Public Content Visibility

The public bootstrap and route endpoints resolve only posts that WordPress considers publicly viewable. ReactWP also excludes password-protected content from public payloads. Drafts, private posts, scheduled posts, trashed posts, and non-public post types return the normalized 404 route instead of their ACF or SEO data.

Authentication does not silently widen those public endpoints. Use the signed preview endpoint for unpublished editorial previews, and use a project-specific endpoint with its own permission_callback for private application data.

Protecting a WordPress Page

A page can remain published while its route payload is conditional. Enforce the condition in PHP, not only by hiding the React component.

For example, a route filter can replace protected data for unauthorized visitors, while a private REST endpoint can use a capability check. Client-side redirects are user experience; server-side permission checks are the security boundary.

Never include a secret in route.data, the bootstrap payload, public settings, or rendered HTML and then rely on CSS or React to hide it.

Headless Authentication

ReactWP provides:

  • POST /reactwp/v1/auth/login
  • GET /reactwp/v1/auth/me
  • POST /reactwp/v1/auth/logout

The login endpoint is rate-limited and returns a generic failure message. Auth and preview responses use no-store headers. Credentialed origins must be explicitly allowlisted and should use HTTPS outside local development.

The public bootstrap never contains currentUser. Read identity and the REST nonce from /auth/me, which is intentionally separated from cacheable public content.

Use the restNonce returned after login for later authenticated WordPress REST calls. See Headless API.

CORS

Allowed headless origins come from the site URLs, Site settings > Headless API, and rwp_headless_allowed_origins.

ReactWP ignores wildcard origins for authenticated headless access. Register the exact scheme, host, and port:

https://app.example.com
http://localhost:3000

The rwp_headless_allow_insecure_auth filter exists as an escape hatch, but production credential flows should use HTTPS instead.

Preview Tokens

Create a signed preview token on the server:

$token = rwp::preview_token($post_id, 600);

Tokens are scoped to one post, expire, and are signed with WordPress salts. They are credentials: do not log them, store them permanently, or expose them to unrelated origins.

Sanitizing and Escaping

Sanitize untrusted input when accepting it and escape output for its final context.

$email = rwp::sanitize('email', [
'value' => $_POST['email'] ?? ''
]);

echo rwp::escape('attr', $email);

For AJAX or REST forms also use:

  • a nonce where the request comes from your own frontend
  • strict server-side validation
  • capability checks for privileged actions
  • MIME and extension validation for uploads
  • WordPress upload APIs instead of trusting the client filename
  • size and file-count limits

Frontend validation improves feedback but does not replace PHP validation.

React HTML Sinks

Normal JSX text is escaped by React. When a field intentionally contains rich HTML, define and enforce its allowed markup on the backend before it reaches the public payload:

  • use a small explicit dangerouslySetInnerHTML boundary when trusted, backend-sanitized HTML must be inserted unchanged
  • use ReactWP RichText / html-react-parser only when rendering must remove, replace, or modify nodes or attributes
  • do not treat html-react-parser, replace, or transform as an HTML sanitizer
  • never send arbitrary WordPress, ACF, API, editor, or user HTML to either path without the upstream trust and sanitization contract

ReactWP's RichText is a transformed rendering component: it has its own tag, attribute, URL, srcset, and _blank-link policy. That policy is useful defense in depth for its supported content, but it does not retroactively sanitize a value for other sinks.

The bundled SVG plugin sanitizes uploads with enshrined/svg-sanitize, removes executable markup and event attributes, permits only same-document href references, and rewrites the temporary upload with the cleaned XML before WordPress stores it. The sanitizer library is shipped with the plugin and audited through Composer.

Runtime Response Headers

On non-admin responses the ReactWP mu-plugin:

  • removes X-Powered-By when PHP permits it
  • sends WordPress no-cache headers plus Vary: Cookie for logged-in visitors
  • sends X-Content-Type-Options: nosniff
  • sends X-Frame-Options: SAMEORIGIN
  • sends Referrer-Policy: strict-origin-when-cross-origin
  • sends Permissions-Policy: camera=(), geolocation=(), microphone=() by default through rwp_permissions_policy
  • sends Content-Security-Policy: base-uri 'self'; object-src 'none'; frame-ancestors 'self'; form-action 'self' by default through rwp_content_security_policy
  • sends Strict-Transport-Security: max-age=31536000 only for HTTPS production responses, through rwp_hsts_header

Returning an empty string from the CSP or HSTS filter suppresses that header. The baseline CSP intentionally has no script-src, style-src, img-src, connect-src, or other project asset directives: each deployment must test and add a stricter application policy compatible with its own CDN, fonts, media, analytics, embeds, development server, and inline requirements.

XML-RPC is disabled by default, the method list becomes empty, and X-Pingback is removed. rwp_allow_xmlrpc can deliberately restore it; a project that does so owns method-level hardening and rate limits.

Generated Core Hardening

The tracked root .htaccess applies Apache-specific defaults when the required modules exist:

  • disable directory indexes and server signatures
  • deny common environment, manifest, lock, test, backup, log, SQL, and editor-artifact filenames
  • deny direct access to theme render fragments, theme ACF JSON, runtime render fragments, and PHP-like files under uploads
  • preserve the Authorization header for WordPress/PHP routing
  • serve generated Brotli/gzip sidecars with correct MIME and Vary: Accept-Encoding
  • cache versioned script/style/font assets for one year as immutable and image assets for 30 days

Nginx, IIS, a CDN, or another front server does not execute .htaccess; configure equivalent rules there.

The tracked wp-config-sample.php supports local, development, staging, and production, defaulting unknown/missing values to production. Non-production enables debug logging and displays errors only for local/development. Production disables debugging and display, sets DISALLOW_FILE_EDIT and FORCE_SSL_ADMIN, and refuses to start until all eight WordPress authentication keys/salts are unique, non-placeholder strings of at least 32 characters.

Never commit a populated src/core/wp-config.php. It is ignored project-local state; deploy secrets through the environment or a protected deployment-specific config.

Public Payload Review

Before adding data through rwp_bootstrap, rwp_route_payload, or rwp_headless_public_settings, assume every visitor can read it. Keep API secrets, SMTP credentials, private user fields, server paths, and internal tokens out of public payloads.