Skip to main content
Login Join
Snippet · PHP

Rate-Limit Your Custom REST API Endpoints

Shared by Jainil Nagar · June 10, 2026 · @rest_api_init

43 views
4 upvotes
Back to Snippets

The existing REST API snippets on the site only cover disabling or locking down the API entirely. This covers a completely different need – rate limiting your own custom endpoints per IP using transients, without a plugin.

/**
 * Rate-limit a custom REST API endpoint.
 *
 * Demonstrates the pattern on a /wp-json/wpfolks/v1/contact endpoint.
 * Apply wpfolks_rest_rate_limit() to any custom route's permission_callback.
 *
 * Defaults: 10 requests per 60 seconds per IP.
 * Override via constants in wp-config.php:
 *   define( 'WPFOLKS_REST_RATE_LIMIT',  10 );
 *   define( 'WPFOLKS_REST_RATE_WINDOW', 60 );
 */

// ─── Configuration Defaults ──────────────────────────────────────────────────

defined( 'WPFOLKS_REST_RATE_LIMIT'  ) || define( 'WPFOLKS_REST_RATE_LIMIT',  10 );
defined( 'WPFOLKS_REST_RATE_WINDOW' ) || define( 'WPFOLKS_REST_RATE_WINDOW', 60 );

// ─── Register a rate-limited custom endpoint ──────────────────────────────────

add_action( 'rest_api_init', 'wpfolks_register_contact_endpoint' );
function wpfolks_register_contact_endpoint(): void {
    register_rest_route( 'wpfolks/v1', '/contact', [
        'methods'             => WP_REST_Server::CREATABLE, // POST only
        'callback'            => 'wpfolks_handle_contact_request',
        'permission_callback' => 'wpfolks_check_contact_permissions', // Security & Rate limit combined
        'args'                => [
            'email' => [
                'required'          => true,
                'sanitize_callback' => 'sanitize_email',
                'validate_callback' => fn( $v ) => is_email( $v ),
            ],
            'message' => [
                'required'          => true,
                'sanitize_callback' => 'sanitize_textarea_field',
            ],
        ],
    ] );
}

// ─── Permissions & Security Router ───────────────────────────────────────────

function wpfolks_check_contact_permissions( WP_REST_Request $request ): true|WP_Error {
    // Verify CSRF Nonce for logged-in users to prevent cross-site request forgery
    $nonce = $request->get_header( 'X-WP-Nonce' );
    if ( is_user_logged_in() && ! wp_verify_nonce( $nonce, 'wp_rest' ) ) {
        return new WP_Error( 
            'rest_forbidden', 
            __( 'Invalid security token.', 'textdomain' ), 
            [ 'status' => 403 ] 
        );
    }

    // Run the rate limit check
    return wpfolks_rest_rate_limit();
}

// ─── Rate limit logic ─────────────────────────

function wpfolks_rest_rate_limit(): true|WP_Error {
    $ip  = wpfolks_login_get_ip(); // Reuse the safe IP resolver from snippet #1
    $key = 'wpfolks_rest_rl_' . md5( $ip );

    $hits = (int) get_transient( $key );

    if ( $hits >= WPFOLKS_REST_RATE_LIMIT ) {
        return new WP_Error(
            'rate_limited',
            __( 'Too many requests. Please slow down.', 'textdomain' ),
            [ 'status' => 429 ]
        );
    }

    // Update hits count and reset window expiration cleanly
    set_transient( $key, $hits + 1, WPFOLKS_REST_RATE_WINDOW );

    return true;
}

// ─── Safe IP Resolver ─────────────────────────────────────────────────────────

function wpfolks_login_get_ip(): string {
    // If you use Cloudflare, look for their official header first
    if ( ! empty( $_SERVER['HTTP_CF_CONNECTING_IP'] ) ) {
        $cf_ip = trim( $_SERVER['HTTP_CF_CONNECTING_IP'] );
        if ( filter_var( $cf_ip, FILTER_VALIDATE_IP ) ) {
            return $cf_ip;
        }
    }

    // Direct connection fallback (safest default)
    $remote_ip = $_SERVER['REMOTE_ADDR'] ?? '';
    if ( filter_var( $remote_ip, FILTER_VALIDATE_IP ) ) {
        return $remote_ip;
    }

    return '127.0.0.1';
}


// ─── Endpoint handler ──────────────────────────────────────────────

function wpfolks_handle_contact_request( WP_REST_Request $request ): WP_REST_Response {
    $email   = $request->get_param( 'email' );
    $message = $request->get_param( 'message' );

    // Handle your contact logic here — e.g. wp_mail() to admin
    wp_mail(
        get_option( 'admin_email' ),
        __( 'New Contact Form Submission', 'textdomain' ),
        sprintf( "From: %snn%s", $email, $message )
    );

    return new WP_REST_Response(
        [ 'success' => true, 'message' => __( 'Message received. Thank you!', 'textdomain' ) ],
        200
    );
}
Know a different way to do this? Add your approach as a variation so folks can compare them side by side.
Submit a variation

0 comments