Si vous avez déjà vu un 401, 403 ou 500 sur /wp-json/ alors que “tout marche” dans l’admin, vous avez probablement un mélange de trois systèmes qui se contredisent : l’auth WordPress (cookies / application passwords / JWT), le serveur (Nginx/Apache/PHP-FPM) et un intermédiaire (cache, CDN, WAF).

Le problème

Les erreurs REST API en production se manifestent souvent avec des réponses courtes, peu parlantes. Voici trois exemples réalistes que je vois dans des logs et dans la console navigateur.

# 401 typique : authentification manquante/invalide
curl -i https://example.com/wp-json/wp/v2/users/me

HTTP/2 401
content-type: application/json; charset=UTF-8

{"code":"rest_not_logged_in","message":"Vous n’êtes pas connecté.","data":{"status":401}}
# 403 typique : droits insuffisants OU blocage en amont (WAF)
curl -i https://example.com/wp-json/wp/v2/posts/123

HTTP/2 403
content-type: application/json; charset=UTF-8

{"code":"rest_forbidden","message":"Désolé, vous n’avez pas l’autorisation de faire cela.","data":{"status":403}}
# 500 typique : fatal PHP pendant bootstrap REST
curl -i https://example.com/wp-json/wp/v2/settings

HTTP/2 500
content-type: text/html; charset=UTF-8

<html>... (ou parfois JSON vide) ...

Où ça apparaît :

  • Front-end : blocs Gutenberg, recherche, filtres produits, formulaires, tout ce qui appelle wp.apiFetch ou admin-ajax.php migré vers REST.
  • Admin : éditeur de site, éditeur de blocs, écran “Réglages”, médias, etc. Beaucoup d’écrans modernes reposent sur REST.
  • Cron / intégrations : Zapier/Make, scripts maison, apps mobiles, headless, Elementor/Divi qui “prévisualisent” via endpoints custom.

Circonstances typiques :

  • Après une mise à jour WordPress 6.9.x, un plugin ajoute un rest_authentication_errors trop agressif.
  • Après activation d’un plugin de sécurité/cache/CDN (ou une règle WAF côté hébergeur).
  • Après migration HTTPS, changement de domaine, ou durcissement des cookies (SameSite).
  • Après passage à PHP 8.1+ où un vieux snippet déclenche un fatal (500).

À qui s’adresse ce guide : si vous déployez en production, manipulez des hooks REST, ou intégrez WordPress à un front JS / app, vous repartirez avec une méthode de diagnostic reproductible, et des correctifs “avant/après” prêts à coller dans un mu-plugin.

Résumé rapide

  • 401 : WordPress ne vous considère pas authentifié (cookies non envoyés, nonce absent, Application Password mal utilisée, proxy qui supprime Authorization).
  • 403 : vous êtes authentifié mais pas autorisé (capabilities) ou un WAF/CDN bloque avant même WordPress.
  • 500 : fatal PHP / erreur serveur / timeout PHP-FPM, souvent déclenché par un endpoint custom, un hook global, ou une réponse non sérialisable.
  • Diagnostiquez d’abord le code HTTP est généré : WordPress (JSON avec code) vs serveur/WAF (HTML, page “Access denied”).
  • En production, logguez proprement : WP_DEBUG_LOG, logs PHP-FPM, et un trace ID par requête REST.
  • Testez avec curl -i et comparez : sans cookies, avec cookies, avec X-WP-Nonce, avec Authorization: Basic.

Les symptômes

Voici les signaux qui reviennent le plus souvent sur WordPress 6.9.4 en prod.

  • Dans la console navigateur : GET https://example.com/wp-json/... 401 (Unauthorized) ou 403 (Forbidden).
  • Dans Gutenberg : “La réponse n’est pas une réponse JSON valide” (souvent parce qu’un 403/500 renvoie HTML).
  • Dans Elementor / Divi 5 : la prévisualisation ou l’édition inline échoue, parfois uniquement pour les rôles non-admin.
  • Dans un front headless : les endpoints publics (/wp/v2/posts) marchent, mais les endpoints protégés (/wp/v2/users/me, endpoints custom) renvoient 401/403.
  • En prod uniquement : en local tout passe, mais en prod un CDN/WAF bloque /wp-json ou supprime l’en-tête Authorization.
  • 500 aléatoire : parfois uniquement sur certains endpoints, ou uniquement quand il y a beaucoup de trafic (timeout, mémoire).
  • Conflit plugin/thème : un plugin de sécurité ajoute des règles globales qui impactent REST, ou un thème ajoute un template_redirect qui fait un wp_redirect() sur /wp-json/.

Symptôme “signature” : si la réponse contient un JSON WordPress avec un champ code (ex: rest_not_logged_in), vous êtes probablement dans WordPress. Si c’est une page HTML “Forbidden”/“Access denied”, vous êtes en amont (WAF, Nginx, Apache, CDN).

Pourquoi ça arrive

Version simple (pour se repérer vite)

La REST API WordPress est un routeur HTTP qui applique deux couches : authentification (êtes-vous quelqu’un ?) puis autorisation (avez-vous le droit ?). En production, une troisième couche s’ajoute : l’infrastructure (cache, WAF, proxy, règles serveur), qui peut bloquer ou modifier la requête avant qu’elle n’arrive à WordPress.

Version technique (ce qui se passe en coulisses)

Sur WordPress 6.9.4, une requête REST passe typiquement par :

  • Bootstrap WordPress (wp-load.php), puis initialisation REST.
  • Résolution de route par WP_REST_Server.
  • Exécution des callbacks + permission callbacks.
  • Filtre global rest_authentication_errors (fréquent dans les plugins de sécurité) qui peut court-circuiter en 401/403.
  • Normalisation de la réponse via rest_ensure_response().

Les causes, du plus fréquent au plus rare :

  1. Nonce/cookies absents côté navigateur (401), souvent à cause de SameSite, d’un sous-domaine, ou d’un appel cross-origin.
  2. En-tête Authorization supprimé par Nginx/Apache/fastcgi (401), classique sur Nginx mal configuré ou certains CDNs.
  3. Permission callback trop stricte (403) sur un endpoint custom, ou vérification de capability incorrecte.
  4. WAF/CDN bloque /wp-json (403) ou bloque certains patterns (ex: ?_fields=, users), surtout sur des règles “bot protection”.
  5. Cache qui met en cache une réponse 401/403 et la sert à tout le monde.
  6. Fatal PHP dans un plugin, un mu-plugin, ou un snippet (500) déclenché uniquement sur REST.
  7. Timeout/mémoire (500/502/504) sur endpoints lourds (search, settings, custom endpoints non paginés).
  8. Rewrite rules / permaliens cassés (parfois 404, mais j’ai déjà vu des 500 via loops de redirection).

Prérequis avant de commencer

  • Sauvegarde (fichiers + base) et, si possible, un staging. Ne testez pas des snippets REST “au hasard” en prod.
  • Versions : WordPress 6.9.4, PHP 8.1+ (idéalement 8.2/8.3 si votre stack le permet), et extensions cURL/JSON actives.
  • Outils :
    • Query Monitor (voir erreurs PHP, hooks, requêtes, REST).
    • Health Check & Troubleshooting (désactiver plugins pour votre session sans impacter les visiteurs).
    • WP-CLI (si vous avez accès SSH) : wp plugin list, wp option get, etc.
  • Logs :
    • wp-content/debug.log via WP_DEBUG_LOG (temporairement).
    • Logs serveur : Nginx/Apache, PHP-FPM, et logs WAF/CDN si disponibles.

Docs officielles utiles pendant le diagnostic :

Solution 1 : authentification, cookies et nonces (401/403 côté WP)

Quand la réponse est un JSON WordPress du type rest_not_logged_in (401), ou rest_cookie_invalid_nonce (403), le problème vient presque toujours de l’auth “cookie + nonce” ou de la manière dont vous appelez l’API depuis le navigateur.

Diagnostic rapide avec curl

Comparez ces trois appels. Ils vous disent immédiatement si vous êtes sur un problème de cookies/nonce.

# 1) Sans authentification : attendu 401 sur un endpoint protégé
curl -i https://example.com/wp-json/wp/v2/users/me

# 2) Avec cookie de session (copié depuis votre navigateur) : devrait passer
curl -i --cookie "wordpress_logged_in_...=..." https://example.com/wp-json/wp/v2/users/me

# 3) Avec Application Password (Basic Auth) : devrait passer (si autorisé)
curl -i -u "login:APPLICATION_PASSWORD" https://example.com/wp-json/wp/v2/users/me

Si (2) passe mais pas vos appels JS, c’est quasi certain : nonce absent ou cookies non envoyés (CORS/credentials).

Cas fréquent : fetch sans credentials (AVANT) vs avec credentials + nonce (APRÈS)

J’ai souvent vu ce bug sur des thèmes custom et des widgets Elementor qui font un fetch() “propre” mais oublient les cookies. Résultat : WordPress ne voit pas votre session, donc 401.

AVANT (cassé)

// Appel REST depuis le front, mais sans cookies ni nonce
fetch('/wp-json/wp/v2/users/me', {
  method: 'GET',
  headers: {
    'Accept': 'application/json'
  }
}).then(r => r.json()).then(console.log);

APRÈS (corrigé)

<?php
// functions.php (ou mieux : un plugin / mu-plugin)
// On expose un nonce REST et l'URL de l'API au script front.
add_action('wp_enqueue_scripts', function () {
    wp_enqueue_script(
        'mon-front-rest',
        get_stylesheet_directory_uri() . '/assets/js/mon-front-rest.js',
        array(),
        '1.0.0',
        true
    );

    wp_localize_script('mon-front-rest', 'MonREST', array(
        'root'  => esc_url_raw(rest_url()),
        'nonce' => wp_create_nonce('wp_rest'),
    ));
});
// mon-front-rest.js
// Correct : envoi des cookies + nonce REST
fetch(`${MonREST.root}wp/v2/users/me`, {
  method: 'GET',
  credentials: 'same-origin',
  headers: {
    'Accept': 'application/json',
    'X-WP-Nonce': MonREST.nonce
  }
})
  .then(async (r) => {
    const body = await r.json().catch(() => ({}));
    if (!r.ok) throw new Error(`${r.status} ${JSON.stringify(body)}`);
    return body;
  })
  .then(console.log)
  .catch(console.error);

Pourquoi ça corrige :

  • credentials: 'same-origin' force l’envoi des cookies de session WordPress sur le même domaine.
  • X-WP-Nonce satisfait la protection CSRF côté REST quand vous utilisez l’auth cookie.

Détails utiles :

  • Si vous êtes en sous-domaine (app.example.comwww.example.com), ce n’est plus “same-origin”. Il faut une stratégie CORS (voir Solution 2) ou passer par une auth token (Application Password / OAuth / JWT selon votre contexte).
  • Si vous avez un plugin de cache qui minifie/concatène, vérifiez que votre script est bien chargé. Un mauvais wp_enqueue_script donne parfois l’illusion d’un 401 “mystérieux” alors que le code nonce ne s’exécute jamais.

Cas fréquent : hook d’auth global trop agressif (403) + correctif

Beaucoup de snippets “sécurité” sur Internet ajoutent un blocage global sur /wp-json/. Sur WordPress 6.9.4, ça casse Gutenberg, l’éditeur de site et parfois l’admin entière.

AVANT (cassé)

<?php
// Mauvais snippet : bloque toute REST si non connecté.
// Effet secondaire : Gutenberg et l'admin moderne cassent.
add_filter('rest_authentication_errors', function ($result) {
    if (!is_user_logged_in()) {
        return new WP_Error(
            'rest_forbidden',
            'REST désactivée pour les visiteurs.',
            array('status' => 403)
        );
    }
    return $result;
});

APRÈS (corrigé, ciblé)

<?php
// Correctif : ne bloquez que VOS endpoints sensibles.
// Laissez le core et les endpoints publics fonctionner.
add_filter('rest_authentication_errors', function ($result) {
    // Si une autre auth a déjà échoué/réussi, on n'écrase pas.
    if (!empty($result)) {
        return $result;
    }

    // Requête REST en cours
    if (!defined('REST_REQUEST') || !REST_REQUEST) {
        return $result;
    }

    $request_uri = isset($_SERVER['REQUEST_URI']) ? (string) $_SERVER['REQUEST_URI'] : '';

    // Exemple : on protège uniquement /wp-json/monplugin/v1/
    if (strpos($request_uri, '/wp-json/monplugin/v1/') !== false) {
        if (!is_user_logged_in()) {
            return new WP_Error(
                'rest_not_logged_in',
                'Connexion requise pour cet endpoint.',
                array('status' => 401)
            );
        }
    }

    return $result;
}, 20);

Pourquoi ça corrige :

  • Vous respectez le pattern “ne pas écraser $result” : un autre plugin (ou le core) peut déjà avoir décidé.
  • Vous ciblez un namespace précis au lieu de “tout REST”.
  • Vous évitez de casser Gutenberg (qui dépend de multiples endpoints, parfois appelés même pour des visiteurs).

Edge case : Application Password OK en local, 401 en prod (Authorization supprimé)

Si vos appels Basic Auth (Application Password) renvoient 401 en prod mais pas en local, suspectez un proxy/CDN/Nginx qui supprime l’en-tête Authorization. C’est un classique.

Vérification :

curl -i -u "login:APPLICATION_PASSWORD" https://example.com/wp-json/wp/v2/users/me

Si WordPress répond rest_not_logged_in malgré le Basic Auth, l’en-tête n’arrive probablement pas jusqu’à PHP. Côté serveur, la correction dépend de la stack (voir Solution 2 pour les pistes Nginx/Apache/CDN).

Solution 2 : permissions, CORS et WAF/CDN (403 avant WordPress)

Un 403 n’est pas toujours “WordPress refuse”. En prod, j’ai vu des 403 générés par Cloudflare, Sucuri, ModSecurity, des règles Nginx, ou même des plugins de sécurité qui bloquent certains endpoints (/users, /settings).

Étape 1 : savoir si le 403 vient de WordPress ou d’un WAF

Deux indices simples :

  • 403 WordPress : JSON avec {"code":"rest_forbidden", ...} et header content-type: application/json.
  • 403 WAF/CDN : HTML, challenge, ou page “Access denied”, parfois avec des headers spécifiques (ex: server: cloudflare).
curl -i https://example.com/wp-json/wp/v2/posts?per_page=1

Si vous voyez du HTML, vous êtes probablement bloqué avant WordPress.

Cas fréquent : permission_callback incorrect (AVANT) vs capability correcte (APRÈS)

Sur des endpoints custom, je vois souvent un permission_callback qui teste “admin” au lieu de tester une capability précise, ou qui oublie le cas “lecture publique”. Résultat : 403 pour des rôles légitimes, ou pire : endpoint trop ouvert.

AVANT (cassé)

<?php
add_action('rest_api_init', function () {
    register_rest_route('monplugin/v1', '/rapport', array(
        'methods'  => 'GET',
        'callback' => function () {
            return array('ok' => true);
        },
        'permission_callback' => function () {
            // Trop strict et souvent faux : "admin" n'est pas une capability.
            return current_user_can('admin');
        },
    ));
});

APRÈS (corrigé)

<?php
add_action('rest_api_init', function () {
    register_rest_route('monplugin/v1', '/rapport', array(
        'methods'  => 'GET',
        'callback' => function (WP_REST_Request $request) {
            // Réponse sérialisable
            return rest_ensure_response(array(
                'ok' => true,
                'user' => get_current_user_id(),
            ));
        },
        'permission_callback' => function (WP_REST_Request $request) {
            // Choisissez une capability réelle, adaptée à votre cas.
            // Exemple : seuls les éditeurs+ peuvent voir un rapport.
            return current_user_can('edit_others_posts');
        },
    ));
});

Pourquoi ça corrige :

  • current_user_can('admin') ne correspond pas à une capability standard. Utilisez des caps (manage_options, edit_posts, etc.).
  • Vous retournez une réponse REST normalisée avec rest_ensure_response(), ce qui évite des comportements bizarres sur certains clients.

Cas fréquent : CORS mal géré (403/401 côté navigateur, tout marche en curl)

Si curl fonctionne mais le navigateur échoue, vous êtes souvent sur :

  • préflight OPTIONS bloqué (WAF ou serveur),
  • absence de Access-Control-Allow-Credentials,
  • cookies non envoyés car SameSite et cross-site.

J’évite de “coller du CORS” globalement sur WordPress, mais pour un front headless maîtrisé, vous pouvez autoriser un domaine précis et gérer le préflight. Exemple minimal, à mettre en mu-plugin et à restreindre à votre besoin.

<?php
/**
 * Plugin Name: MU - CORS REST contrôlé
 * Description: Autorise un front headless spécifique à appeler REST avec cookies.
 */

// Risque sécurité : un CORS trop large expose des endpoints.
// Restreignez strictement l'origine.
add_action('rest_api_init', function () {
    add_filter('rest_pre_serve_request', function ($served, $result, $request, $server) {
        $origin = isset($_SERVER['HTTP_ORIGIN']) ? (string) $_SERVER['HTTP_ORIGIN'] : '';

        $allowed_origins = array(
            'https://app.example.com',
        );

        if (in_array($origin, $allowed_origins, true)) {
            header('Access-Control-Allow-Origin: ' . $origin);
            header('Vary: Origin');
            header('Access-Control-Allow-Credentials: true');
            header('Access-Control-Allow-Headers: Authorization, Content-Type, X-WP-Nonce');
            header('Access-Control-Allow-Methods: GET, POST, PUT, PATCH, DELETE, OPTIONS');
        }

        return $served;
    }, 10, 4);
});

// Gérer le préflight OPTIONS proprement
add_action('init', function () {
    if (isset($_SERVER['REQUEST_METHOD'], $_SERVER['REQUEST_URI'])
        && $_SERVER['REQUEST_METHOD'] === 'OPTIONS'
        && strpos((string) $_SERVER['REQUEST_URI'], '/wp-json/') === 0
    ) {
        status_header(204);
        exit;
    }
});

Notes :

  • Ce snippet est volontairement strict sur l’origine. N’utilisez pas * avec Allow-Credentials.
  • Si un WAF bloque OPTIONS, votre code ne sera jamais exécuté. Dans ce cas, la correction est côté WAF/serveur.

Cas fréquent : WAF/CDN bloque /wp-json (403) ou met un challenge

Sur Cloudflare et consorts, les règles “Bot Fight Mode” / “Managed Challenge” peuvent casser REST, surtout pour des clients sans navigateur (cron, app mobile). La bonne approche : autoriser explicitement /wp-json/* ou au moins vos namespaces, et ne pas challenger les requêtes authentifiées.

Ce que je fais en pratique :

  • Créer une règle WAF “skip” pour /wp-json/wp/v2/* (lecture) et pour /wp-json/monplugin/v1/* (si vous maîtrisez l’auth).
  • Laisser les règles actives sur /wp-login.php et /xmlrpc.php.
  • Désactiver la mise en cache CDN sur les endpoints REST authentifiés (sinon vous cachez des 401/403).

Chaque WAF a son UI, donc je ne vous donne pas une recette unique. Le point clé : un 403 HTML n’est pas déboguable dans WordPress. Il faut les logs WAF.

Solution 3 : erreurs 500 (fatal PHP, REST qui casse, et debug en production)

Un 500 REST est rarement “la REST API est down”. C’est presque toujours un fatal PHP, une exception non gérée, une sortie prématurée, ou un objet non sérialisable renvoyé par votre callback.

Étape 1 : distinguer 500 WordPress vs 500 serveur

  • 500 + HTML : souvent serveur (fatal avant output JSON, ou error page Nginx/Apache).
  • 500 + JSON WordPress : parfois une WP_Error avec status 500 (plus rare), ou une erreur levée dans un handler.

Test :

curl -i https://example.com/wp-json/

Si /wp-json/ lui-même renvoie 500, vous avez un problème global (fatal au bootstrap REST, mu-plugin, plugin, autoload, etc.).

AVANT : callback REST qui renvoie un objet non sérialisable (fatal ou JSON invalide)

Je l’ai vu sur des endpoints custom qui renvoient directement une ressource (ex: un objet PDO, une ressource cURL) ou un objet avec référence circulaire. En PHP 8.1+, certaines erreurs sont plus visibles, et vous finissez avec 500.

<?php
add_action('rest_api_init', function () {
    register_rest_route('monplugin/v1', '/debug', array(
        'methods'  => 'GET',
        'callback' => function () {
            // Mauvais : une ressource n'est pas sérialisable en JSON
            $handle = fopen('php://temp', 'r+');
            return array('handle' => $handle);
        },
        'permission_callback' => '__return_true',
    ));
});

APRÈS : réponse sérialisable + gestion d’erreur propre

<?php
add_action('rest_api_init', function () {
    register_rest_route('monplugin/v1', '/debug', array(
        'methods'  => 'GET',
        'callback' => function () {
            try {
                $handle = fopen('php://temp', 'r+');
                if ($handle === false) {
                    return new WP_Error(
                        'monplugin_io_error',
                        'Impossible d’ouvrir un flux temporaire.',
                        array('status' => 500)
                    );
                }

                // On retourne uniquement des scalaires/tableaux sérialisables
                return rest_ensure_response(array(
                    'ok' => true,
                    'php' => PHP_VERSION,
                ));
            } catch (Throwable $e) {
                // Ne divulguez pas l'exception en prod : logguez-la.
                error_log('[monplugin] REST /debug: ' . $e->getMessage());

                return new WP_Error(
                    'monplugin_unexpected_error',
                    'Erreur interne.',
                    array('status' => 500)
                );
            }
        },
        'permission_callback' => '__return_true',
    ));
});

Pourquoi ça corrige :

  • Vous renvoyez une structure JSON sérialisable à coup sûr.
  • Vous capturez Throwable (utile en prod, où une Error PHP n’est pas une Exception).
  • Vous logguez côté serveur sans exposer de détails sensibles au client.

Activer des logs en production sans tout casser

Sur un site à trafic, activer WP_DEBUG en permanence est rarement une bonne idée. Par contre, j’active souvent uniquement le log, et je garde l’affichage off.

<?php
// wp-config.php (exemple : à adapter à votre politique)
// Risque : le debug.log peut contenir des infos sensibles. Protégez-le.
define('WP_DEBUG', true);
define('WP_DEBUG_DISPLAY', false);
define('WP_DEBUG_LOG', true);

// Évitez de logger les notices sur un site très verbeux, si besoin (optionnel)
// @ini_set('display_errors', '0');

Si vous avez accès PHP-FPM, privilégiez aussi le log côté PHP (souvent plus fiable que debug.log). Référence : php.net error logging.

Créer un “trace ID” par requête REST (pratique en prod)

Quand un 500 arrive, le plus dur est de relier “ce 500 vu par un client” à “cette ligne dans les logs”. J’ajoute souvent un identifiant de corrélation renvoyé dans un header, puis je le loggue côté serveur.

<?php
/**
 * Plugin Name: MU - REST Trace ID
 * Description: Ajoute un identifiant de trace aux réponses REST et logs.
 */

add_filter('rest_pre_serve_request', function ($served, $result, $request, $server) {
    $trace_id = wp_generate_uuid4();

    // Header de debug : utile pour corréler avec les logs
    header('X-WP-REST-Trace: ' . $trace_id);

    // Log minimal (évitez de logger des données sensibles)
    $route = method_exists($request, 'get_route') ? $request->get_route() : 'unknown';
    $method = method_exists($request, 'get_method') ? $request->get_method() : 'unknown';
    error_log(sprintf('[rest-trace] %s %s trace=%s', $method, $route, $trace_id));

    return $served;
}, 10, 4);

Edge case : si votre 500 arrive avant que WordPress serve la réponse (fatal très tôt), ce header ne sortira pas. Dans ce cas, seuls les logs serveur/PHP-FPM vous sauveront.

Vérifications après correction

Je valide toujours sur trois niveaux : endpoint racine, endpoint public, endpoint protégé.

  1. Racine REST

    curl -i https://example.com/wp-json/

    Attendu : 200 + JSON qui liste les namespaces.

  2. Endpoint public

    curl -i "https://example.com/wp-json/wp/v2/posts?per_page=1"

    Attendu : 200 + JSON, et des headers de pagination (X-WP-Total).

  3. Endpoint protégé (au choix)

    curl -i -u "login:APPLICATION_PASSWORD" https://example.com/wp-json/wp/v2/users/me

    Attendu : 200 + vos infos utilisateur.

Côté navigateur :

  • Ouvrez DevTools → Network → filtrez wp-json → vérifiez Request Headers (cookies, X-WP-Nonce) et Response (JSON vs HTML).
  • Si vous utilisez Divi 5 / Elementor / Avada : testez l’éditeur + la prévisualisation. Ces outils déclenchent souvent des appels REST spécifiques, parfois avec des paramètres qui font réagir un WAF.

Si ça ne marche toujours pas

Procédure que j’applique sur des incidents prod (ordre important, du plus discriminant au plus rapide).

1) Isoler “WordPress” vs “infrastructure”

  • Réponse HTML sur /wp-json/ → regardez d’abord WAF/CDN/Nginx/Apache.
  • Réponse JSON WordPress → regardez d’abord hooks REST, nonces, capabilities.

2) Tester sans navigateur

  • Testez avec curl -i depuis une machine externe.
  • Testez depuis le serveur (SSH) si possible, pour contourner le CDN.

3) Désactiver les conflits sans casser le site (Health Check)

Avec Health Check, activez le mode dépannage et :

  • désactivez les plugins de sécurité/cache pour votre session,
  • repassez sur un thème par défaut temporairement (si possible),
  • re-testez /wp-json/.

4) Vérifier les hooks REST suspects

Recherchez dans votre code (thème, mu-plugins, plugins maison) :

  • rest_authentication_errors
  • rest_pre_dispatch
  • rest_pre_serve_request
  • template_redirect (redirections qui touchent /wp-json)
  • init avec des exit; conditionnels trop larges

5) Vérifier cache et règles CDN

  • Videz le cache plugin (WP Rocket, LiteSpeed Cache, etc.).
  • Purgez le CDN.
  • Désactivez temporairement la mise en cache sur /wp-json/* (au moins pour les endpoints authentifiés).

6) Logs PHP et erreurs fatales

  • Regardez wp-content/debug.log (si activé).
  • Regardez les logs PHP-FPM (souvent www-error.log), et les logs Nginx/Apache.
  • Si vous voyez des erreurs “Allowed memory size exhausted” : augmentez la mémoire et optimisez l’endpoint (pagination, champs).

7) Réinitialiser les permaliens (quand ça sent la réécriture)

Allez dans Réglages → Permaliens → Enregistrer (sans changer). Ce n’est pas magique, mais j’ai déjà vu des migrations qui laissent des règles incohérentes et provoquent des comportements étranges autour de /wp-json/.

Pièges et erreurs courantes

Symptôme Cause probable Vérification Solution
401 JSON rest_not_logged_in Cookies non envoyés (fetch sans credentials) DevTools → Request Headers : pas de Cookie Ajouter credentials: 'same-origin' (ou gérer CORS)
403 JSON rest_cookie_invalid_nonce Nonce manquant/expiré Header X-WP-Nonce absent Générer nonce via wp_create_nonce('wp_rest') et l’envoyer
403 HTML “Access denied” WAF/CDN bloque /wp-json curl -i renvoie HTML + headers CDN Créer une règle WAF allow/skip sur /wp-json/*
500 sur un endpoint custom Fatal PHP / retour non sérialisable Logs PHP-FPM / debug.log Retourner scalaires/tableaux + rest_ensure_response(), try/catch
Tout marche en local, 401 en prod avec Application Password Authorization supprimé par proxy WP renvoie rest_not_logged_in malgré -u Configurer serveur/CDN pour forward Authorization
Erreur après avoir collé un snippet Code collé au mauvais endroit / parenthèse manquante Fatal “unexpected token” dans logs Mettre en mu-plugin, valider syntaxe, rollback via FTP

Erreurs humaines que je vois tout le temps

  • Copier le code au mauvais endroit : un snippet REST dans un constructeur de page (Divi/Elementor) au lieu d’un plugin → fatal, 500.
  • Oublier un point-virgule dans un mu-plugin → site down, REST down. En prod, ayez un accès SFTP/SSH prêt.
  • Utiliser un hook inadapté : enregistrer une route REST hors de rest_api_init → route non disponible, comportements intermittents.
  • Confusion action/filtre : retourner une valeur dans une action, ou ne pas retourner dans un filtre.
  • Tester sur production sans sauvegarde : un snippet “anti REST” casse Gutenberg et vous bloque l’édition.
  • Code d’un ancien tutoriel : snippet de 2017 qui bloque REST globalement ou utilise une capability inexistante.

Variante / alternative

Méthode sans code : plugins et réglages

  • Query Monitor : inspecter les erreurs PHP et les requêtes REST (très utile pour les 500). Plugin.
  • Health Check : isoler un conflit sans impacter les visiteurs. Plugin.
  • Plugins de sécurité : cherchez une option “Disable REST API” ou “Restrict REST API” et désactivez temporairement. Si ça corrige, remplacez par une restriction ciblée (namespace) plutôt qu’un blocage global.

Méthode avancée : mu-plugin “kill switch” pour restaurer REST en incident

Quand vous êtes en incident et que Gutenberg est cassé, je déploie parfois un mu-plugin temporaire qui neutralise les blocages trop agressifs (le temps de trouver le vrai coupable). Attention : c’est un outil d’urgence, pas une solution finale.

<?php
/**
 * Plugin Name: MU - REST Emergency Bypass (temporaire)
 * Description: Neutralise certains blocages REST. À retirer après incident.
 */

// Risque sécurité : ne laissez pas ça en place.
// L'objectif est de restaurer l'admin pour corriger proprement.
add_filter('rest_authentication_errors', function ($result) {
    if (!defined('REST_REQUEST') || !REST_REQUEST) {
        return $result;
    }

    // Ne pas transformer un échec explicite (WP_Error) en succès.
    // On ne fait rien si un plugin a déjà renvoyé une erreur.
    if (is_wp_error($result)) {
        return $result;
    }

    // Exemple : on laisse passer les endpoints core nécessaires à l'admin.
    // (À adapter : ce n'est pas une liste exhaustive.)
    return $result;
}, 999);

Dans la vraie vie, je couple ça avec Health Check pour identifier le plugin fautif, puis je retire ce mu-plugin immédiatement.

Éviter ce problème à l’avenir

  • Ne bloquez jamais REST globalement via rest_authentication_errors. Protégez par namespace/route, et laissez le core respirer.
  • Standardisez l’auth :
    • Front même domaine : cookies + X-WP-Nonce.
    • Intégrations serveur à serveur : Application Password (ou OAuth si vous en avez besoin), et assurez le forward de Authorization.
  • Ajoutez des tests curl dans votre runbook de déploiement (les 3 appels : racine, public, protégé).
  • Trace ID sur REST en prod : vous gagnez un temps fou quand un 500 arrive sous charge.
  • Contrôlez le cache : ne cachez pas les endpoints authentifiés, et évitez de mettre en cache des 401/403.
  • Endpoints performants : pagination obligatoire, limitation des champs, et timeouts maîtrisés. Un endpoint “tout en un” finit en 500/504 tôt ou tard.

Pour les sites avec Divi 5 / Elementor / Avada : je recommande de tester systématiquement l’éditeur après toute modification WAF/CDN. Ce sont souvent les premiers à révéler un blocage REST, parce qu’ils déclenchent beaucoup d’appels asynchrones.

Ressources

Questions fréquentes

Pourquoi j’ai un 401 sur /wp-json/wp/v2/users/me alors que je suis connecté dans l’admin ?

Votre appel JS n’envoie probablement pas les cookies (il manque credentials) ou n’envoie pas X-WP-Nonce. En admin, WordPress gère ça pour vous ; sur le front, c’est à vous de le faire.

Pourquoi curl marche mais le navigateur échoue avec 401/403 ?

Curl n’applique pas CORS. Le navigateur, si. Vous avez soit un préflight OPTIONS bloqué, soit des cookies refusés (cross-site), soit un manque de headers CORS (Allow-Credentials, Allow-Origin).

J’ai un 403 mais pas de JSON, juste une page HTML. Je cherche où dans WordPress ça bloque.

Vous perdez du temps : ce 403 vient d’un WAF/CDN/serveur. Cherchez les logs WAF, désactivez temporairement les règles sur /wp-json/*, ou testez en contournant le CDN.

Est-ce une bonne idée de “désactiver la REST API pour la sécurité” ?

En pratique, non, pas globalement. Vous cassez Gutenberg, l’éditeur de site, et beaucoup de plugins modernes. Protégez les endpoints sensibles par permissions/capabilities et, si besoin, restreignez des namespaces spécifiques.

Comment diagnostiquer un 500 REST sans afficher les erreurs en production ?

Activez le logging (sans display), consultez les logs PHP-FPM, et ajoutez un trace ID dans les headers REST pour corréler requête et logs. Si vous avez un APM (New Relic, Datadog), c’est encore mieux.

Pourquoi Gutenberg affiche “La réponse n’est pas une réponse JSON valide” ?

Parce qu’il attend du JSON et reçoit souvent du HTML (403 WAF, 500 serveur, notice PHP qui fuit). Ouvrez l’onglet Network et regardez la réponse brute.

Mon endpoint custom renvoie 403 alors que je suis admin.

Votre permission_callback teste peut-être une capability inexistante (ex: admin) ou vous êtes sur un contexte où l’utilisateur courant n’est pas celui attendu (cron, Application Password d’un autre compte, requête sans cookies).

Je peux appeler REST avec Application Password, mais pas via fetch sur le front. Normal ?

Oui. Application Password est une auth serveur-à-serveur (header Authorization). Sur le front, vous devriez éviter d’exposer des secrets et préférer cookies + nonce, ou un backend intermédiaire.

Un plugin de cache peut-il provoquer des 401/403 REST ?

Oui, s’il met en cache des réponses REST (y compris des 401/403) et les sert à d’autres visiteurs. Désactivez le cache sur /wp-json/* au moins pour les endpoints authentifiés.

Divi 5 / Elementor / Avada peuvent-ils “casser” la REST API ?

Indirectement, oui : ils augmentent le volume d’appels REST en édition et révèlent des blocages WAF, des nonces invalides, ou des règles de sécurité trop strictes. Si l’éditeur ne charge plus, testez immédiatement /wp-json/ et regardez la console.