Skip to main content
WP HealthKit

WordPress Headless Security: Decoupled CMS Safety Guide

June 20, 202624 min readSecurityBy Jamie

Table of Contents

  1. Introduction
  2. Understanding WordPress Headless Architecture
  3. WordPress Headless Security Fundamentals
  4. REST API Security Architecture
  5. JWT Authentication Implementation
  6. CORS Policies and Frontend Security
  7. Frontend-Backend Separation Security
  8. Frequently Asked Questions
  9. Conclusion

Introduction

WordPress headless architecture decouples content management from presentation, allowing WordPress REST API to power multiple frontends: web applications, mobile apps, progressive web apps, and static site generators. This modern approach offers flexibility and performance benefits, but introduces unique WordPress headless security challenges that traditional WordPress deployments don't face.

This comprehensive guide covers securing decoupled WordPress systems through REST API hardening, proper authentication implementation, CORS policy configuration, and frontend-backend separation strategies. Whether you're building a Next.js frontend on WordPress headless infrastructure or powering mobile apps through the REST API, these security practices ensure your decoupled architecture resists common attacks.

Understanding WordPress Headless Architecture

Traditional vs. Headless WordPress

Traditional WordPress architecture tightly couples content management with presentation. The WordPress application renders HTML directly through themes and plugins, then sends it to users' browsers. This monolithic design limits flexibility but simplifies certain security concerns—the same server manages both content and presentation.

In traditional WordPress, the theme layer acts as both content access control and presentation mechanism. When you request a page, WordPress authenticates the request, applies role-based capabilities, fetches the appropriate content, and renders it directly in HTML. The security perimeter is relatively contained because data never leaves the same application layer.

Headless WordPress fundamentally changes this model. You decouple the WordPress content management system from the presentation layer. WordPress becomes purely an API server—a content backend that other applications consume. This architectural shift brings enormous benefits for modern web development but creates entirely new security challenges that traditional WordPress developers may not have encountered.

WordPress headless architecture separates these concerns:

WordPress Backend (Headless):

  • Manages content through the WordPress admin
  • Exposes content via REST API
  • Handles authentication and authorization
  • Stores media and documents
  • Processes form submissions

Frontend Application:

  • Separate web application (Next.js, React, Vue.js, etc.)
  • Runs on different infrastructure
  • Requests data from WordPress REST API
  • Handles user interface rendering
  • Manages client-side application state

This separation offers architectural advantages:

  • Framework freedom: Use any frontend framework
  • Performance: Static generation, edge caching, optimized asset delivery
  • Scalability: Frontend and backend scale independently
  • Development velocity: Frontend and backend teams work independently
  • Multi-channel: One WordPress backend powers multiple frontend applications

Each advantage brings corresponding security considerations. Framework freedom means your frontend stack isn't constrained by WordPress theme requirements—but this freedom also means you must implement security practices for whatever framework you choose. Performance benefits from edge caching and static generation—but you must ensure cached content doesn't expose sensitive information. Scalability through independent infrastructure means you can optimize each layer separately—but now you need to secure the communication between them.

The architectural flexibility of headless WordPress appeals to enterprise organizations, SaaS platforms, and modern web applications that need more control over user experience and performance than traditional WordPress themes provide. Organizations like TechCrunch, The Rolling Stone, and major news outlets use headless WordPress to power high-traffic, high-performance applications while maintaining the editorial power of WordPress content management.

WordPress Headless Security Implications

Decoupling introduces new security challenges:

  1. Exposed API: REST API becomes your security perimeter instead of a theme template engine
  2. Cross-origin requests: Frontend and backend run on different domains, enabling CORS attacks
  3. Token management: Client-side authentication requires careful token handling
  4. Data exposure: Publicly accessible endpoints may expose sensitive information
  5. Increased complexity: More services mean more attack surface

Understanding these security implications requires appreciating how they differ from traditional WordPress security. In traditional WordPress, unauthorized users simply never reach the content they shouldn't see—WordPress applies capability checks before rendering anything. In headless WordPress, your REST API exists to be accessed. The question isn't whether your API is accessible, but rather who is allowed to access it and what data they can retrieve.

Consider a practical example: A website with sensitive research papers only viewable to logged-in researchers. In traditional WordPress, unpublished posts never render to public users—the template engine enforces this. In headless WordPress, the REST API still provides that research data structure. You must explicitly authenticate API requests, validate permissions for each endpoint, and ensure the API doesn't inadvertently expose metadata about unpublished research. The responsibility shifts from relying on WordPress theme layer visibility to explicitly implementing API-level access controls.

Furthermore, headless architectures create new network boundaries. Your WordPress backend and frontend communicate across the internet (or at minimum, across your infrastructure). This communication channel—whether it's between a Next.js frontend and WordPress API, a mobile app and WordPress backend, or a static site generator fetching content—must be secured against interception, tampering, and unauthorized access. You're no longer protecting data within a single monolithic application; you're protecting data in transit between services.

WordPress Headless Security Fundamentals

REST API Security Baseline

The WordPress REST API is powerful but requires careful security implementation. By default, WordPress enables REST API access for public content to any user, authenticated or not. This openness is intentional—it's how frontends consume WordPress data. However, an unsecured REST API can expose far more information than your theme design intended.

The challenge in securing REST API endpoints is balancing functionality with protection. You want your frontend applications to fetch the data they need, but you don't want them to fetch data they shouldn't access. You want to allow necessary operations while blocking dangerous ones. The baseline security approach requires you to explicitly deny by default and explicitly allow specific, necessary operations.

// wp-config.php: Baseline REST API security
// Disable REST API for unauthenticated users
add_filter( 'rest_authentication_errors', function( $result ) {
    if ( ! is_user_logged_in() && 'GET' === $_SERVER['REQUEST_METHOD'] ) {
        // Allow GET requests for public content
        return $result;
    }
    
    if ( ! is_user_logged_in() ) {
        return new WP_Error(
            'rest_not_authenticated',
            __( 'Sorry, you must be logged in to access this resource.' ),
            array( 'status' => 401 )
        );
    }
    
    return $result;
});

// Restrict REST API endpoints to necessary operations only
add_filter( 'rest_pre_dispatch', function( $dispatch, $request ) {
    $route = $request->get_route();
    $method = $request->get_method();
    
    // Allow only specific endpoints
    $allowed_endpoints = array(
        '/wp/v2/posts',      // Read posts
        '/wp/v2/pages',      // Read pages
        '/wp/v2/categories', // Read categories
    );
    
    $allowed = false;
    foreach ( $allowed_endpoints as $endpoint ) {
        if ( strpos( $route, $endpoint ) === 0 ) {
            $allowed = true;
            break;
        }
    }
    
    if ( ! $allowed ) {
        return new WP_Error(
            'rest_endpoint_disabled',
            __( 'This endpoint is not available.' ),
            array( 'status' => 404 )
        );
    }
    
    return $dispatch;
}, 10, 2 );

Disabling Unnecessary REST API Features

Headless WordPress should disable REST API features intended for traditional theme usage. WordPress includes many REST API endpoints for theme functionality that you simply don't need if you're using a headless architecture. The users endpoint reveals user information. The comments endpoint is designed for traditional WordPress comment themes. The settings endpoint exposes WordPress configuration. None of these are necessary for a well-designed headless API.

The principle here is "least privilege"—expose only the endpoints your frontend applications actually need. If your frontend doesn't display user profiles, disable the users endpoint. If it doesn't render comments, disable the comments endpoint. Each disabled endpoint reduces your attack surface and decreases the information available to potential attackers. An attacker probing your API will find fewer endpoints to attack, less information to harvest, and fewer opportunities to find vulnerabilities.

// Disable REST API for users and comments by default
add_filter( 'rest_endpoints', function( $endpoints ) {
    // Hide user endpoints (reveals user information)
    unset( $endpoints['/wp/v2/users'] );
    unset( $endpoints['/wp/v2/users/(?P<id>[\d]+)'] );
    
    // Hide comment endpoints if not needed for frontend
    unset( $endpoints['/wp/v2/comments'] );
    
    // Hide settings endpoint (reveals WordPress configuration)
    unset( $endpoints['/wp/v2/settings'] );
    
    return $endpoints;
}, 10 );

// Disable REST API for admin users
add_action( 'rest_api_init', function() {
    register_rest_field(
        'post',
        'rest_disabled_for_admins',
        array(
            'get_callback' => function( $post ) {
                // Restrict sensitive operations
                if ( ! current_user_can( 'edit_posts' ) ) {
                    return false;
                }
                return true;
            },
            'schema' => array( 'type' => 'boolean' ),
        )
    );
});

Rate Limiting REST API

Protect your API from brute force and denial-of-service attacks. Even with proper authentication and authorization, an unsecured endpoint can be abused through sheer volume. An attacker could repeatedly request your data-heavy endpoint, flooding your server with requests and consuming bandwidth or database resources. Rate limiting prevents this by tracking request frequency and rejecting requests that exceed defined thresholds.

Rate limiting becomes increasingly important in headless architectures where your API is directly exposed to untrusted clients. A mobile app user with a buggy client could accidentally hammer your API with requests. A bot could attempt to scrape all your content. A malicious actor could mount a denial-of-service attack. Rate limiting provides a defense against all these scenarios.

The implementation strategy matters. You should rate limit by user for authenticated requests—logged-in users get more generous limits than anonymous users. For anonymous users, rate limit by IP address and optionally User-Agent to prevent easy bypassing. Consider dynamic rate limiting that tightens in response to attacks. Track rate limit violations in your monitoring system to detect coordinated attacks. Some approaches even implement adaptive rate limiting that learns from patterns and adjusts automatically.

// Implement rate limiting for REST API
class REST_API_Rate_Limiter {
    private static $rate_limit_key = 'rest_api_rate_limit_';
    private static $requests_per_minute = 60;
    
    public static function check_rate_limit() {
        $user_identifier = self::get_user_identifier();
        $cache_key = self::$rate_limit_key . $user_identifier;
        
        // Get current request count
        $request_count = wp_cache_get( $cache_key );
        if ( false === $request_count ) {
            $request_count = 0;
        }
        
        // Increment counter
        $request_count++;
        wp_cache_set( $cache_key, $request_count, '', 60 ); // 1 minute TTL
        
        // Check limit
        if ( $request_count > self::$requests_per_minute ) {
            wp_die(
                json_encode( array(
                    'code' => 'rest_rate_limit_exceeded',
                    'message' => 'Too many requests. Please try again later.',
                )),
                429,
                array( 'Content-Type' => 'application/json' )
            );
        }
    }
    
    private static function get_user_identifier() {
        // Use authenticated user ID, or IP + User-Agent for anonymous users
        if ( is_user_logged_in() ) {
            return 'user_' . get_current_user_id();
        }
        
        $ip = $_SERVER['REMOTE_ADDR'];
        $user_agent = sanitize_text_field( $_SERVER['HTTP_USER_AGENT'] ?? '' );
        return 'ip_' . md5( $ip . $user_agent );
    }
}

// Hook into REST API requests
add_action( 'rest_api_init', function() {
    add_filter( 'rest_pre_dispatch', function( $dispatch ) {
        REST_API_Rate_Limiter::check_rate_limit();
        return $dispatch;
    });
});

REST API Security Architecture

Input Validation and Output Sanitization

Headless WordPress must validate all REST API inputs and sanitize outputs. Input validation is your first line of defense against malicious requests. Every parameter your API accepts should be validated—not just checked for type, but validated for business logic. A plugin slug parameter should be validated to contain only valid characters. A post ID should be validated to actually exist. A user ID should be validated to have appropriate permissions.

Output sanitization is equally critical. Even if all your inputs are valid, the data you return from the API shouldn't contain anything the requester shouldn't see. Sensitive fields should be removed based on user permissions. HTML content should be properly escaped to prevent injection vulnerabilities. Complex objects should be carefully serialized to avoid exposing internal structure.

This separation of concerns—validating what comes in, sanitizing what goes out—creates a secure API boundary. You accept any request, validate it completely, process it safely, and return only appropriate data in a safe format.

// Register secure REST endpoint with validation
add_action( 'rest_api_init', function() {
    register_rest_route( 'healthkit/v1', '/analyze', array(
        'methods' => 'POST',
        'callback' => 'healthkit_analyze_plugin',
        'permission_callback' => function() {
            return current_user_can( 'edit_posts' );
        },
        'args' => array(
            'plugin_slug' => array(
                'type' => 'string',
                'required' => true,
                'sanitize_callback' => function( $value ) {
                    // Validate plugin slug format
                    if ( ! preg_match( '/^[a-z0-9\-]+$/', $value ) ) {
                        return new WP_Error(
                            'invalid_plugin_slug',
                            'Plugin slug must contain only lowercase letters, numbers, and hyphens.'
                        );
                    }
                    return sanitize_text_field( $value );
                },
                'validate_callback' => function( $value ) {
                    // Ensure plugin exists
                    return file_exists( WP_PLUGIN_DIR . '/' . $value );
                },
            ),
            'scan_type' => array(
                'type' => 'string',
                'enum' => array( 'quick', 'full', 'vulnerability' ),
                'sanitize_callback' => 'sanitize_text_field',
            ),
        ),
    ) );
});

function healthkit_analyze_plugin( WP_REST_Request $request ) {
    $plugin_slug = $request->get_param( 'plugin_slug' );
    $scan_type = $request->get_param( 'scan_type' ) ?? 'quick';
    
    // Perform analysis
    $results = analyze_plugin_security( $plugin_slug, $scan_type );
    
    // Sanitize output before returning
    $response = array(
        'plugin' => sanitize_text_field( $plugin_slug ),
        'scan_type' => sanitize_text_field( $scan_type ),
        'vulnerabilities' => array_map( function( $vuln ) {
            return array(
                'type' => sanitize_text_field( $vuln['type'] ),
                'severity' => sanitize_text_field( $vuln['severity'] ),
                'description' => wp_kses_post( $vuln['description'] ),
            );
        }, $results['vulnerabilities'] ),
    );
    
    return new WP_REST_Response( $response, 200 );
}

Preventing Information Disclosure

Headless WordPress endpoints should minimize information exposure. Information disclosure vulnerabilities occur when your API leaks data that shouldn't be public. This might be WordPress version numbers in headers, database error messages in responses, internal file paths in error logs, or metadata about non-public content. Each piece of information helps attackers understand your system architecture and identify potential vulnerabilities.

Real-world information disclosure can be subtle. A WordPress error message that includes database table names reveals your database structure. A REST API response that includes timestamps of unpublished posts reveals when new content is being prepared. Response headers that identify the server as WordPress with specific version numbers enable version-specific exploit attempts. Collectively, these small disclosures create an information asymmetry—attackers know more about your system than they should.

The solution is to audit everything your API exposes and remove unnecessary information. This includes response headers, error messages, response structures, and metadata fields. A well-designed API provides exactly the information the frontend needs and nothing more.

// Prevent leaking WordPress version through REST API headers
add_filter( 'rest_pre_serve_request', function( $served, $result, $request ) {
    // Remove WordPress version from response headers
    header_remove( 'X-Powered-By' );
    return $served;
}, 10, 3 );

// Don't expose user enumeration through REST API
add_filter( 'rest_user_query', function( $args, $request ) {
    // Restrict user enumeration
    if ( ! current_user_can( 'list_users' ) ) {
        $args['number'] = 0;
        $args['search'] = null;
    }
    return $args;
}, 10, 2 );

// Limit exposed post metadata
add_filter( 'rest_prepare_post', function( $response, $post, $request ) {
    if ( ! current_user_can( 'edit_post', $post->ID ) ) {
        // Hide sensitive metadata from unauthenticated users
        $data = $response->get_data();
        unset( $data['meta'] );
        $response->set_data( $data );
    }
    return $response;
}, 10, 3 );

Audit Your WordPress Headless Security

WP HealthKit scans your WordPress REST API configuration to identify security vulnerabilities, exposed endpoints, and authentication weaknesses. Strengthen your decoupled WordPress architecture with actionable security recommendations.

Scan REST API Security

JWT Authentication Implementation

Implementing JSON Web Tokens

JWT provides secure, stateless authentication for REST API clients. Traditional WordPress uses server-side sessions stored in databases or object caches. When a user logs in, WordPress creates a session and stores session data on the server. Every subsequent request presents the session cookie, and WordPress looks up the session data to determine who's making the request.

This session-based approach works well for monolithic WordPress sites where everything runs on the same server. However, headless architectures often have problems with sessions. Your frontend might be running on a CDN edge server in a different country. Your WordPress API might be behind a load balancer with multiple servers. Sessions become difficult to share across these boundaries.

JSON Web Tokens solve this problem through a different authentication model. Instead of storing session data on the server, the server creates a digitally signed token containing the user's identity and other relevant claims. The client stores this token and sends it with every request. The server verifies the token's signature without needing to look up any session data. This stateless approach scales infinitely—any server can verify any token without shared session storage.

The security of JWT depends entirely on the token's signature. If an attacker can forge a valid signature, they can create arbitrary tokens with arbitrary claims. Your implementation must use strong cryptographic keys, never expose the secret key to the frontend, and always verify signatures before trusting token contents. The code example above shows proper JWT implementation with HMAC-SHA256 signatures, expiration validation, and secure key management.

// JWT Authentication for WordPress REST API
class WordPress_JWT_Auth {
    private static $secret_key = null;
    
    public static function get_secret_key() {
        if ( null === self::$secret_key ) {
            // Store in wp-config.php, never hardcode
            self::$secret_key = defined( 'JWT_AUTH_SECRET_KEY' )
                ? JWT_AUTH_SECRET_KEY
                : wp_salt( 'auth' );
        }
        return self::$secret_key;
    }
    
    public static function generate_token( $user_id, $expiration = 3600 ) {
        $issued_at = time();
        $expire = $issued_at + $expiration;
        
        $payload = array(
            'iss' => get_option( 'siteurl' ),
            'aud' => get_option( 'siteurl' ),
            'iat' => $issued_at,
            'exp' => $expire,
            'user_id' => $user_id,
        );
        
        // Create JWT (simplified; use PHP JWT library in production)
        $header = self::base64url_encode( wp_json_encode( array( 'typ' => 'JWT', 'alg' => 'HS256' ) ) );
        $payload_encoded = self::base64url_encode( wp_json_encode( $payload ) );
        
        $signature = hash_hmac(
            'sha256',
            $header . '.' . $payload_encoded,
            self::get_secret_key(),
            true
        );
        
        $signature_encoded = self::base64url_encode( $signature );
        
        return $header . '.' . $payload_encoded . '.' . $signature_encoded;
    }
    
    public static function verify_token( $token ) {
        if ( empty( $token ) ) {
            return false;
        }
        
        $parts = explode( '.', $token );
        if ( 3 !== count( $parts ) ) {
            return false;
        }
        
        list( $header, $payload, $signature ) = $parts;
        
        // Verify signature
        $expected_signature = hash_hmac(
            'sha256',
            $header . '.' . $payload,
            self::get_secret_key(),
            true
        );
        
        $signature_decoded = base64_decode( strtr( $signature, '-_', '+/' ), true );
        
        if ( ! hash_equals( $expected_signature, $signature_decoded ) ) {
            return false;
        }
        
        // Decode and verify payload
        $payload_decoded = json_decode( base64_decode( strtr( $payload, '-_', '+/' ) ), true );
        
        // Check expiration
        if ( ! isset( $payload_decoded['exp'] ) || time() > $payload_decoded['exp'] ) {
            return false;
        }
        
        return $payload_decoded;
    }
    
    private static function base64url_encode( $data ) {
        return rtrim( strtr( base64_encode( $data ), '+/', '-_' ), '=' );
    }
}

// Register JWT authentication
add_filter( 'rest_authentication_errors', function( $result ) {
    if ( ! empty( $result ) ) {
        return $result;
    }
    
    // Get Authorization header
    $headers = getallheaders();
    if ( ! isset( $headers['Authorization'] ) ) {
        return $result;
    }
    
    $auth_header = $headers['Authorization'];
    
    if ( ! preg_match( '/^Bearer\s+(.+)$/i', $auth_header, $matches ) ) {
        return $result;
    }
    
    $token = $matches[1];
    $payload = WordPress_JWT_Auth::verify_token( $token );
    
    if ( ! $payload || ! isset( $payload['user_id'] ) ) {
        return new WP_Error(
            'jwt_auth_invalid_token',
            __( 'Invalid or expired token.' ),
            array( 'status' => 401 )
        );
    }
    
    // Set authenticated user
    wp_set_current_user( $payload['user_id'] );
    
    return true;
});

CORS Policies and Frontend Security

Configuring CORS Headers Securely

CORS controls how frontend applications can access your REST API. CORS stands for Cross-Origin Resource Sharing—it's a browser security mechanism that prevents websites from making requests to APIs on other domains without explicit permission.

Here's how it works: Your frontend is hosted at app.example.com. It needs to fetch data from your WordPress API at api.example.com. These are different origins (different subdomains), so the browser considers this a cross-origin request. By default, the browser blocks these requests to prevent attacks where one website could make requests to another website on behalf of users.

To allow this cross-origin request, your WordPress API must send CORS headers that tell the browser "yes, app.example.com is allowed to make requests to my API." The configuration must be specific—you can't use wildcard CORS policies in production because that allows any website to request your API. A wildcard policy would allow an attacker's website to make requests to your API on behalf of your users' browsers, potentially reading sensitive data or making unwanted modifications.

The proper approach is to whitelist specific, trusted origins. If you know your frontend runs only on app.example.com and mobile.example.com, configure CORS to allow only those origins. Add origins to the whitelist only when you explicitly need cross-origin access. Never use wildcards. Regularly audit your CORS configuration to ensure it still matches your actual deployments.

// Secure CORS configuration for headless WordPress
add_action( 'rest_api_init', function() {
    // Only allow specific frontend origins
    $allowed_origins = array(
        'https://app.example.com',
        'https://www.example.com',
        'https://mobile-app.example.com',
    );
    
    // Development: add localhost only when needed
    if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
        $allowed_origins[] = 'http://localhost:3000';
        $allowed_origins[] = 'http://localhost:8000';
    }
    
    add_filter( 'rest_pre_serve_request', function( $served, $result, $request ) use ( $allowed_origins ) {
        $origin = $_SERVER['HTTP_ORIGIN'] ?? '';
        
        // Check if origin is allowed
        if ( in_array( $origin, $allowed_origins, true ) ) {
            header( 'Access-Control-Allow-Origin: ' . $origin );
            header( 'Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS' );
            header( 'Access-Control-Allow-Headers: Content-Type, Authorization, X-Requested-With' );
            header( 'Access-Control-Allow-Credentials: true' );
            header( 'Access-Control-Max-Age: 86400' );
        } else {
            // Origin not allowed - no CORS headers sent
            error_log( 'Unauthorized CORS request from origin: ' . $origin );
        }
        
        return $served;
    }, 10, 3 );
});

// Handle preflight requests
add_action( 'init', function() {
    if ( 'OPTIONS' === $_SERVER['REQUEST_METHOD'] ) {
        header( 'Access-Control-Allow-Origin: ' . ( $_SERVER['HTTP_ORIGIN'] ?? '' ) );
        header( 'Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS' );
        header( 'Access-Control-Allow-Headers: Content-Type, Authorization' );
        header( 'Access-Control-Max-Age: 86400' );
        exit( 0 );
    }
});

Frontend Security Best Practices

Frontend applications accessing WordPress REST API must implement security practices. The frontend is running in users' browsers—an environment you don't fully control. Users can inspect network requests, read localStorage data, examine JavaScript source code, and manipulate requests. This means your frontend security strategy must assume that users are exploring and potentially attacking your application.

The first principle of frontend security is to never store sensitive secrets in the browser. This includes API keys, passwords, or credentials. The browser is an untrusted environment. Any JavaScript you ship to browsers can be read. Any data in localStorage can be accessed. Any secrets stored client-side can be compromised.

Instead, authenticate users from the backend. When a user logs in, the server verifies their credentials and issues a secure, HttpOnly cookie containing authentication information. The browser automatically includes this cookie with every request, but JavaScript cannot read it. This prevents XSS (cross-site scripting) attacks from stealing authentication credentials.

The code example shows a secure React implementation that never stores tokens in JavaScript-accessible storage. Instead, it relies on server-set cookies for authentication. The frontend implements proper error handling, validates responses, and handles authentication state correctly. Token management happens entirely on the server side, where it's secure.

// Frontend security for WordPress headless architecture

// Never store sensitive tokens in localStorage
// Use secure, HttpOnly cookies instead

// Secure token handling in React example
function useWordPressAuth() {
    const [token, setToken] = useState( null );
    
    const login = async ( username, password ) => {
        const response = await fetch( '/api/auth/login', {
            method: 'POST',
            credentials: 'include', // Include cookies
            headers: {
                'Content-Type': 'application/json',
            },
            body: JSON.stringify( { username, password } ),
        });
        
        if ( ! response.ok ) {
            throw new Error( 'Login failed' );
        }
        
        // Server sets HttpOnly cookie, no token in memory
        setToken( true );
    };
    
    const logout = async () => {
        await fetch( '/api/auth/logout', {
            method: 'POST',
            credentials: 'include',
        });
        setToken( false );
    };
    
    return { token, login, logout };
}

// Always validate API responses
async function fetchFromWordPress( endpoint ) {
    const response = await fetch( `${API_URL}${endpoint}`, {
        credentials: 'include',
        headers: {
            'Authorization': `Bearer ${getSecureToken()}`,
        },
    });
    
    if ( ! response.ok ) {
        if ( 401 === response.status ) {
            // Redirect to login
            window.location.href = '/login';
        }
        throw new Error( 'API request failed' );
    }
    
    // Validate response structure
    const data = await response.json();
    
    if ( ! data || typeof data !== 'object' ) {
        throw new Error( 'Invalid API response' );
    }
    
    return data;
}

Frontend-Backend Separation Security

Securing Data Flow Between Services

When WordPress backend and frontend run on separate infrastructure, security practices become critical:

// Backend: Sign API responses to prevent tampering
function sign_api_response( $data ) {
    $signature = hash_hmac(
        'sha256',
        wp_json_encode( $data ),
        WORDPRESS_API_SIGNING_KEY,
        true
    );
    
    return array(
        'data' => $data,
        'signature' => base64_encode( $signature ),
        'timestamp' => time(),
    );
}

// Frontend: Verify response signatures
function verifyApiResponse( response ) {
    const { data, signature, timestamp } = response;
    
    // Check timestamp (prevent replay attacks)
    const maxAge = 300; // 5 minutes
    if ( Date.now() / 1000 - timestamp > maxAge ) {
        throw new Error( 'Response too old' );
    }
    
    // Verify signature
    const expectedSignature = hmacSha256(
        JSON.stringify( data ),
        FRONTEND_API_SIGNING_KEY
    );
    
    if ( signature !== expectedSignature ) {
        throw new Error( 'Response signature invalid' );
    }
    
    return data;
}

Monitoring API Usage

Monitor your REST API for suspicious patterns:

// Log REST API activity for monitoring
add_action( 'rest_api_init', function() {
    add_filter( 'rest_pre_dispatch', function( $dispatch, $request ) {
        $log_entry = array(
            'timestamp' => current_time( 'mysql', true ),
            'method' => $request->get_method(),
            'route' => $request->get_route(),
            'user_id' => get_current_user_id(),
            'ip_address' => $_SERVER['REMOTE_ADDR'],
            'origin' => $_SERVER['HTTP_ORIGIN'] ?? 'unknown',
        );
        
        // Log to syslog for monitoring
        error_log( wp_json_encode( $log_entry ) );
        
        return $dispatch;
    }, 10, 2 );
});

Additional Resources

For a comprehensive view of how WP HealthKit approaches plugin analysis, explore our 17 verification layers or browse the plugin directory to see real audit scores. Ready to check your own plugin? Run a free audit now.

Frequently Asked Questions

Should I completely disable the WordPress REST API for headless WordPress?

No, you need the REST API for your headless frontend to function. Instead, disable unnecessary endpoints, require authentication for sensitive operations, implement rate limiting, and carefully control what data endpoints expose. Use WP HealthKit to identify which endpoints are exposed and whether they should be.

How do I securely transmit JWT tokens between frontend and backend?

Use secure, HttpOnly cookies to store JWT tokens instead of localStorage. This prevents JavaScript attacks from accessing tokens. Never transmit tokens in URLs or query parameters. Always use HTTPS to protect tokens in transit.

Cookie-based authentication is appropriate for same-origin requests but problematic for cross-origin scenarios. If your frontend and WordPress backend are on different domains, JWT authentication is more suitable. If they share the domain, cookie authentication is acceptable and offers some security advantages.

How do I prevent CORS attacks against my headless WordPress?

Implement strict CORS policies that only allow specific, whitelisted origins. Never use wildcard CORS policies in production. Validate referer headers and implement CSRF tokens even for cross-origin requests. WP HealthKit can audit your CORS configuration for vulnerabilities.

What data should I prevent exposing through the REST API?

Never expose database credentials, WordPress salts/keys, user email addresses (for non-admin users), internal system information, or sensitive metadata through the REST API. Audit what your REST API endpoints return and implement field-level access controls.

How frequently should I rotate JWT tokens?

Implement short-lived JWT tokens (1-2 hours expiration) with refresh token rotation. This limits damage if a token is compromised. Implement token rotation automatically when users log out or perform sensitive operations.

Conclusion

WordPress headless architecture enables modern web development but requires rethinking security from WordPress's traditional perspective. Your REST API becomes your security perimeter, CORS policies control cross-origin access, and authentication mechanisms must work without traditional session cookies.

Implement defense-in-depth security through REST API hardening, JWT authentication, strict CORS policies, and careful input validation. Monitor API activity to detect suspicious patterns. Use tools like WP HealthKit to automatically audit your REST API security configuration and identify vulnerabilities.

Your WordPress headless security depends on understanding the unique challenges of decoupled architecture. Start by disabling unnecessary REST API endpoints, implementing authentication for sensitive operations, and configuring strict CORS policies. Test your security configuration thoroughly before deploying to production. Continuously monitor and audit your REST API as new vulnerabilities emerge.

Secure Your Headless WordPress Now

WP HealthKit audits your WordPress REST API configuration to identify security vulnerabilities, exposed endpoints, authentication weaknesses, and CORS misconfigurations. Get actionable recommendations to strengthen your decoupled WordPress architecture.

Audit REST API Security


Related Articles:

Ready to audit your plugin?

WP HealthKit checks for all the issues in this article and 40+ more across 49 verification layers.

Comments

WordPress Headless Security: Decoupled CMS Safety Guide | WP HealthKit