Sécurité WordPress : mes pratiques et références
J’utilise souvent les mêmes éléments, et je m’y perds un peu alors autant tout centraliser ici, comme un wiki.
Et que cela serve à d’autres si jamais.

Sommaire
Introduction – Fichier wp-config.php – Fichier .htaccess – Headers de sécurité HTTP – Blocage du PHP dans les uploads – Extensions – Thèmes – Suppression de l’admin #1 et sécurisation des utilisateurs – Protection des fichiers sensibles – WP CRON – Déplacement du fichier wp-config.php – Base de données originale et personnalisée – Restriction de l’API REST – Vérification d’intégrité par checksums – Emails et DNS – Règles de base
Introduction
WordPress est l’un des gestionnaires de contenu les plus utilisés, et à fortiori les plus ciblés par les pirates informatiques. Pourtant, certaines précautions ne sont pas très chronophages et vous mettent à l’abri pendant pas mal de temps des soucis d’intrusion.
A travers ce guide, je vous partage une partie de mes protocoles de sécurisation WP. Si vous souhaitez que je réalise un audit de votre site ou que je le sécurise à votre place, faites-le moi savoir.
Contenu par défaut des fichiers sensibles WordPress
Je travaille depuis 2011 sur WordPress, pour des projets personnels, collaboratifs ou professionnels. A force de tripoter, casser et refaire, j’ai appris certaines choses, une forme d’expérience par l’expérimentation.
Certains fichiers sont sensibles et demandent à être bichonnés, je veux bien entendu parler du wp-config.php et du fichier .htaccess à la racine du site.
Fichier wp-config.php
Comme je suis un peu maniaque et que j’apprécie de m’y retrouver, je reprends à chaque fois le contenu du fichier wp-config.php afin de le clarifier.
- Augmentation de la mémoire PHP
- Affichage clair des éléments relatifs à la base de données
- Mise-à-jour du SALT en cas d’infection
- Options de debug
- Blocage de l’édition de fichiers
- Blocage du wp-cron.php
<?php
// PHP
define( 'WP_MEMORY_LIMIT', '512M' );
define( 'WP_MAX_MEMORY_LIMIT', '512M' );
// Cache
define( 'WP_CACHE', false);
// DB
define('DB_NAME', 'XXXXX');
define('DB_USER', 'XXXXX');
define('DB_PASSWORD', 'XXXXX');
define('DB_HOST', 'XXXXX');
define('DB_CHARSET', 'utf8');
define('DB_COLLATE', '');
$table_prefix = 'XXXXX_';
// SALT
define('AUTH_KEY', ';W_+|:9?2.ZONuF4bzSuCOeXBUpHp=-T~EmX]%9PF5&R+1v4YMj{sn-ki[+y<[&@');
define('SECURE_AUTH_KEY', '!R<#MA2zT^1ceWQR6:uI6P+]KJ[P!IPCrpB+>>03lP)FdHd^(V-,6^|L}PMc+yV7');
define('LOGGED_IN_KEY', 'p5U$qno,Q>=B-}}V|wf6F.czvdcK#}>OfEAf0aC5V^G/;e{.|>X/o~}LnA#TG$(C');
define('NONCE_KEY', 'QTEc8(B~Q*G5Re/qV)+/.B/v7{Ic(g#rIvIi1Z=X|AvXw=scCY%-XvC(@U$K0)9O');
define('AUTH_SALT', 'fsbDi+^u;jcI+C|68.,`CtyF#k)zpi_NHtU|v{eYHzD*ehQu;YID02qGc-2=k)8(');
define('SECURE_AUTH_SALT', ',j=s0<SCRQc6+^$5ZVAM)G1>R>O(TBi@:n/BuEPzdY;KbP+in-a/mNm9-h|#fM%6');
define('LOGGED_IN_SALT', 'e/A=Nx7dZ)vYk5cW]a]1aM-29r~+<dzj7r#E[|Brd2Cu+%]A3@ww&lcwX2iw@]-l');
define('NONCE_SALT', 'EzqOaJtsn<~U>;J/$i!;{/uGVHbK?SJBYTf H|`sNb3Zxe_lUr0! Bc-5,xDlV$d');
// Debug
define('WP_DEBUG', false);
define('WP_DEBUG_LOG', false);
define('WP_DEBUG_DISPLAY', false);
// Sécurité
define('DISALLOW_FILE_EDIT', true);
define('FORCE_SSL_ADMIN', true);
define('DISABLE_WP_CRON', true);
// WP
if ( !defined('ABSPATH') ) define('ABSPATH', dirname(__FILE__) . '/');
require_once(ABSPATH . 'wp-settings.php'); Fichier .htaccess
J’ajoute dans ce fichier .htaccess, présent à la racine du site, plusieurs éléments, notamment pour réduire la surface d’attaque potentielle :
- Redirection automatique vers HTTPS
- Désactivation du listing du contenu des dossiers
- Protection des fichiers sensibles type wp-config.php wp-cron.php xmlrpc.php
- Protection des fichiers commençant par un .
- Protection du fichier readme
# Symlinks et désactivation du listing des répertoires
Options +FollowSymLinks -Indexes
# Masquage des informations serveur
ServerSignature Off
# Protection des fichiers sensibles WordPress
<Files "wp-config.php">
Require all denied
</Files>
<Files "xmlrpc.php">
Require all denied
</Files>
<Files "wp-cron.php">
Require all denied
</Files>
# Protection des fichiers .htaccess et .htpasswd
<FilesMatch "^\.ht">
Require all denied
</FilesMatch>
# Protection readme, license, changelog
<FilesMatch "(?i)^(readme\.(html|txt|md)|changelog\.txt|licen[cs]e(\.txt)?)$">
Require all denied
</FilesMatch>
# Protection des fichiers de sauvegarde et logs
<FilesMatch "\.(sql|bak|log|tar|gz|zip|old)$">
Require all denied
</FilesMatch>
# Règles de réécriture
<IfModule mod_rewrite.c>
RewriteEngine On
# Redirection HTTPS (proxy/CDN + accès direct)
RewriteCond %{HTTP:X-Forwarded-Proto} !https
RewriteCond %{HTTPS} off
RewriteRule ^(.*)$ https://%{HTTP_HOST}/$1 [R=301,L]
# Masquage des pages auteur (?author=N)
RewriteCond %{QUERY_STRING} ^author=[0-9]+
RewriteRule .* - [F,L]
# Blocage injections XSS et base64 via query string
RewriteCond %{QUERY_STRING} (<|%3C).*script.*(>|%3E) [NC,OR]
RewriteCond %{QUERY_STRING} base64_encode.*\(.*\) [NC]
RewriteRule .* - [F,L]
</IfModule> Headers de sécurité HTTP
Toujours dans le .htaccess racine, j’ajoute une poignée de headers HTTP. Cinq lignes pour un gros gain sur n’importe quel audit de sécurité, et une protection réelle contre le clickjacking et le MIME sniffing.
<IfModule mod_headers.c>
# Empêche le navigateur de "deviner" les types MIME
Header always set X-Content-Type-Options "nosniff"
# Interdit l'affichage du site dans une iframe tierce (clickjacking)
Header always set X-Frame-Options "SAMEORIGIN"
# Ne transmet l'URL référente qu'en navigation interne ou même origine
Header always set Referrer-Policy "strict-origin-when-cross-origin"
# Coupe l'accès aux API navigateur inutiles pour un site classique
Header always set Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=()"
# HSTS : force HTTPS pendant 1 an, sous-domaines inclus
Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains"
</IfModule> Attention au HSTS : ne l’activez qu’une fois certain que tout votre domaine, sous-domaines compris, est servi en HTTPS. Une fois en cache dans les navigateurs, c’est quasi irréversible.
Je ne mets volontairement pas de Content-Security-Policy générique ici : une CSP utile se construit site par site (thème, extensions, polices, analytics), sinon elle casse des choses ou ne protège rien.
Blocage de l’exécution PHP dans les uploads
Le dossier /wp-content/uploads/ est la cible numéro un des webshells : un fichier piégé déposé via un formulaire ou une faille d’extension, puis exécuté à distance.
La parade est simple : un second fichier .htaccess, à créer directement dans le dossier uploads, qui interdit toute exécution PHP.
# Blocage de l'exécution PHP dans les uploads
<FilesMatch "\.(php|php3|php4|php5|php7|phps|phtml|pht)$">
Require all denied
</FilesMatch>
# Ceinture et bretelles si mod_php est utilisé
<IfModule mod_php.c>
php_flag engine off
</IfModule> Le fichier malveillant peut toujours être déposé, mais il devient inerte : il ne s’exécutera jamais.
Sécurisation de base d’une instance WordPress
Voici une liste non exhaustive de ce que je réalise pour prévenir les attaques potentielles sur les sites WP que je gère : extensions, thèmes, administrateurs, fichiers sensibles, base de données…
Extensions
Parmi les extensions que j’utilise fréquemment, il y a de manière non exhaustive :
- Wordfence security : scan avec fréquence variable, firewall, 2FA disponible dès la version gratuite
- WPS Hide login ou équivalent : déplacement de l’URL de connexion pour la personnaliser et faciliter sa mémorisation par des néophytes
- Cloudflare : WAF et protection captcha (Turnstile)
- SEOPress ou équivalent : basiques pour le référencement naturel
- Smush ou équivalent : compression des images, voire conversion au format .webp
- UpdraftPlus ou équivalent : sauvegarde locale du site (FTP + BD)
- Redirection : gestion des redirections, possiblement automatiquement
- Broken Link Checker : contrôle des liens morts et notification mail, option bulk disponible
De manière générale, je recommande de regarder si une extension est pérenne (mise à jour fréquemment), fiable (avis utilisateurs) et pertinente (sans possibilité de passer par un mu-plugin ou un snippet dans functions.php) avant de valider son installation. On se préserve ainsi d’extensions inutiles, nombreuses voire sans réelle valeur ajoutée, et on réduit les coûts de maintenance.
Thèmes
Je ne laisse que deux thèmes disponibles : le thème actif et un thème de fallback de la famille Twenty.
Cela limite la maintenance (car un thème pas à jour, même désactivé, est une brèche potentielle) et le poids du site.
D’autre part, en cas de bug, je renomme le fichier du thème actif pour que le thème Twenty prenne le relais, me permettant ainsi de voir si le thème actif est la source du problème.
Suppression de l’admin #1 et sécurisation des Utilisateurs administrateurs
Je supprime systématiquement le premier utilisateur admin WordPress (admin 1) pour des raisons de sécurité. C’est le premier et souvent seul utilisateur ciblé par des scripts malveillants.
Par la suite, je limite au maximum le nombre d’administrateurs sur un site, car peu utilisent réellement toutes les fonctionnalités de ce rôle utilisateur.
Enfin, je force pour que chaque administrateur ait une connexion via 2FA via Wordfence (double authentification) et un mot de passe solide.
J’étends d’ailleurs cette 2FA aux comptes éditeurs : un compte éditeur compromis suffit pour de l’injection de contenu et du spam SEO, sans qu’aucune alerte « admin » ne se déclenche. Wordfence permet d’imposer la double authentification par rôle, autant en profiter.
Protection des fichiers sensibles wp-config.php xmlrpc.php via .htaccess
Grâce à un fichier .htaccess optimisé, je m’assure que l’accès aux fichiers wp-config.php et xmlrpc.php est interdit
<Files "wp-config.php">
Require all denied
</Files>
<Files "xmlrpc.php">
Require all denied
</Files> Le cas particulier de wp-cron.php
Je n’utilise pas wp-cron.php, accessible publiquement par défaut, car il est exploitable pour des attaques DDoS ou des tâches malveillantes. Cependant, la tâche est assez ardue, voilà pourquoi je fais ce mémo.
Le principe de ma méthode est de bloquer l’accès public au fichier wp-cron.php via le .htaccess, puis de le remplacer par un pont déclenchant les tâches via l’API interne de WordPress tout en protégeant ce fichier par un token secret.
Cette version v2 du pont apporte trois protections par rapport à ma première mouture : le token transite par un header HTTP (il n’apparaît donc plus dans les access logs du serveur), la comparaison utilise hash_equals() (résistante aux attaques temporelles), et un verrou empêche deux exécutions simultanées.
Voici les 5 étapes à réaliser pour désactiver le WP CRON et créer un CRON maison en déclenchant son utilisation via une tâche CRON générée par votre hébergement.
- Désactivation du CRON dans wp-config.php
define('DISABLE_WP_CRON', true); - Désactivation de l’accès au fichier wp-cron.php via .htaccess
<Files "wp-cron.php">
Require all denied
</Files> - Création du pont à nommer comme vous le souhaitez, par exemple run-cron.php, et à ajouter à la racine de votre site WordPress.
<?php
/**
* run-cron.php v2.1 : pont sécurisé pour exécuter les tâches WP-Cron.
* Remplace l'appel direct à wp-cron.php (bloqué via .htaccess).
*
* v2.1 : token accepté en header X-Cron-Key (recommandé, invisible des
* access logs) OU en paramètre GET ?key= (fallback pour les planificateurs
* mutu qui n'acceptent qu'une URL simple, type Infomaniak).
* Dans les deux cas : hash_equals, verrou anti-chevauchement, allowlist IP.
*
* Appel recommandé (commande cron avec curl) :
* curl -s -H "X-Cron-Key: VOTRE_TOKEN" https://votre-domaine.com/run-cron.php
*
* Fallback URL simple :
* https://votre-domaine.com/run-cron.php?key=VOTRE_TOKEN
*
* Génération du token : openssl rand -hex 32
*/
// ---------------------------------------------------------------------------
// Configuration
// ---------------------------------------------------------------------------
// Token alphanumérique de 40 à 60 caractères (majuscules, minuscules, chiffres)
const CRON_SECRET = 'VOTRE_TOKEN_ALPHANUMERIQUE_ICI';
// Allowlist IP optionnelle : laisser vide pour désactiver.
const CRON_ALLOWED_IPS = [];
// ---------------------------------------------------------------------------
// 1. Contrôles d'accès
// ---------------------------------------------------------------------------
// Header prioritaire, GET en fallback
$provided = $_SERVER['HTTP_X_CRON_KEY'] ?? ($_GET['key'] ?? '');
if ($provided === '' || !hash_equals(CRON_SECRET, $provided)) {
http_response_code(403);
exit('Forbidden');
}
if (CRON_ALLOWED_IPS !== [] && !in_array($_SERVER['REMOTE_ADDR'] ?? '', CRON_ALLOWED_IPS, true)) {
http_response_code(403);
exit('Forbidden');
}
// ---------------------------------------------------------------------------
// 2. Verrou anti-chevauchement
// ---------------------------------------------------------------------------
$lock = fopen(__DIR__ . '/.run-cron.lock', 'c');
if ($lock === false || !flock($lock, LOCK_EX | LOCK_NB)) {
http_response_code(429);
exit("Une exécution est déjà en cours.\n");
}
// ---------------------------------------------------------------------------
// 3. Chargement de WordPress et exécution des tâches dues
// ---------------------------------------------------------------------------
define('WP_USE_THEMES', false);
require_once __DIR__ . '/wp-load.php';
$crons = _get_cron_array();
$gmt_time = microtime(true);
$count = 0;
if (!empty($crons)) {
foreach ($crons as $timestamp => $cronhooks) {
if ($timestamp > $gmt_time) {
break; // tableau trié : tout ce qui suit est futur
}
foreach ($cronhooks as $hook => $keys) {
foreach ($keys as $v) {
if (!empty($v['schedule'])) {
wp_reschedule_event($timestamp, $v['schedule'], $hook, $v['args']);
}
wp_unschedule_event($timestamp, $hook, $v['args']);
do_action_ref_array($hook, $v['args']);
$count++;
}
}
}
}
flock($lock, LOCK_UN);
fclose($lock);
echo $count > 0
? "{$count} tâche(s) cron exécutée(s).\n"
: "Aucune tâche en attente.\n";
- Configuration d’un token secret de 40 à 60 caractères, incluant des lettres majuscules et minuscules et des chiffres (pas de caractères spéciaux). Ajoutez ce token dans le contenu du fichier run-cron.php à l’emplacement indiqué. En ligne de commande : openssl rand -hex 32
- Création d’une tâche CRON depuis votre hébergement en indiquant notamment :
- Commande (si votre hébergeur permet une commande plutôt qu’une URL) : curl -s -H « X-Cron-Key: VOTRE_TOKEN » https://votre-domaine.com/run-cron.php
- Si le planificateur n’accepte qu’une URL simple sans header, adaptez le pont pour accepter aussi un paramètre GET, en sachant que le token apparaîtra alors dans vos access logs
- Fréquence : 15 minutes à 1 heure en fonction des hébergements
- Sécurité complémentaire : authentification HTTP (digest ou basic) si possible et disponible
Une fois les fichiers wp-config.php, .htaccess et run-cron.php mis en place comme il se doit, vous pouvez alors tester que le wp-cron.php est inaccessible et que le run-cron.php fait bien son travail.
- L’URL https://votre-domaine.com/wp-cron.php doit donner une erreur 403 : accès interdit.
- La commande curl -s -H « X-Cron-Key: VOTRE_TOKEN » https://votre-domaine.com/run-cron.php doit afficher X tâche(s) cron exécutée(s) ou Aucune tâche en attente.
Variante avec accès SSH : sur un hébergement disposant d’un crontab, le pont HTTP devient inutile. Un cron en ligne de commande via WP-CLI fait le travail sans aucune exposition web ni token à gérer :
*/15 * * * * cd /chemin/vers/wordpress && wp cron event run --due-now --quiet Déplacement du fichier wp-config.php
Tout en l’ayant personnalisé, je remonte le wp-config.php d’un cran dans l’arborescence WordPress.
En effet, wp-config.php est par défaut dans le dossier racine, ce qui pourrait être compromettant en cas d’intrusion. Vous pouvez le remonter d’un niveau sans problématiques majeures.
Il faudra peut-être le remettre à sa place par défaut temporairement dans certains cas (extension de gestion de cache qui veut l’utiliser pour écrire des éléments).
Base de données originale et personnalisée
Premièrement, je ne donne pas tous les droits à l’utilisateur de la base, car il n’en a pas besoin et cela restreint le périmètre d’attaque.
Les éléments à garder sont :
- Select, Insert, Update, Delete
- Index, Alter, Create, Drop (pour les migrations de plugins/thèmes)
- References, Lock Tables
Je personnalise ensuite la base de données de plusieurs manières, l’idée étant qu’il n’y ait pas de pattern.
- Nom de la base et nom d’utilisateur différents, chose possible sur des hébergements type O2switch mais impossibles chez Infomaniak ou OVH
- Mot de passe unique et complexe, généré par des outils comme Bitwarden
- Préfixe de la base personnalisé
Restriction de l’API REST
Je parlais auparavant de désactiver l’API REST, j’ai affiné ma position : la désactivation complète casse Gutenberg, certains formulaires et des outils de gestion. La bonne approche est de bloquer ce qui fuite (l’énumération des utilisateurs) et de réduire la découverte, sans rien casser.
Un mu-plugin à déposer dans /wp-content/mu-plugins/ suffit :
<?php
/**
* Plugin Name: REST Hardening
* Description: Restreint l'API REST sans la désactiver : bloque l'énumération
* des utilisateurs pour les visiteurs non connectés et réduit la découverte.
* Compatible Gutenberg et formulaires.
*/
defined('ABSPATH') or die();
/**
* 1. Blocage de l'énumération des utilisateurs via REST pour les non-connectés.
* Couvre /wp-json/wp/v2/users et la variante ?rest_route=/wp/v2/users.
*/
add_filter('rest_authentication_errors', function ($result) {
if (!empty($result)) {
return $result;
}
if (is_user_logged_in()) {
return $result;
}
$request_uri = $_SERVER['REQUEST_URI'] ?? '';
if (preg_match('#/wp/v2/users\b#i', $request_uri)) {
return new WP_Error(
'rest_user_enumeration_blocked',
__('Authentification requise.'),
['status' => 401]
);
}
return $result;
});
/**
* 2. Retrait des utilisateurs du sitemap XML natif (autre vecteur
* d'énumération depuis WP 5.5).
*/
add_filter('wp_sitemaps_add_provider', function ($provider, $name) {
return ($name === 'users') ? false : $provider;
}, 10, 2);
/**
* 3. Réduction de la découverte : retrait des liens REST du <head>,
* des headers HTTP et du flux RSD. Réduit le bruit des scanners.
*/
remove_action('wp_head', 'rest_output_link_wp_head');
remove_action('template_redirect', 'rest_output_link_header', 11);
remove_action('wp_head', 'rsd_link'); Combiné au blocage des pages auteur ?author=N déjà présent dans le .htaccess, les deux vecteurs d’énumération des utilisateurs sont fermés.
Vérification d’intégrité par checksums
Wordfence scanne, mais son détecteur vit dans WordPress : si WP est compromis, le détecteur peut l’être aussi. J’ajoute donc une vérification indépendante par checksums via WP-CLI, en cron quotidien, qui compare chaque fichier du cœur (et des extensions du répertoire officiel) aux empreintes publiées par wordpress.org :
# Cœur WordPress : tout fichier modifié ou ajouté est signalé
wp core verify-checksums --path=/chemin/vers/wordpress
# Extensions du répertoire officiel
wp plugin verify-checksums --all --path=/chemin/vers/wordpress Deux principes complémentaires que j’applique :
- Surveiller aussi les gardiens : conserver une empreinte (sha256) de wp-config.php, .htaccess, run-cron.php et du dossier mu-plugins, et alerter si elle change. Un attaquant compétent neutralise d’abord la surveillance : si les fichiers qui portent la sécurité bougent sans action de votre part, c’est le signal prioritaire.
- Vérifier que les protections tiennent : contrôler périodiquement que DISABLE_WP_CRON est toujours actif (wp config get DISABLE_WP_CRON), qu’aucun fichier PHP n’est apparu dans uploads…
A noter : les extensions premium ou maison sortent en avertissement sur verify-checksums (pas d’empreintes publiques), à filtrer selon vos sites.
Emails et DNS : SPF, DKIM, DMARC
Quand je gère le DNS d’un domaine, trois enregistrements fiabilisent les emails du site (formulaires, notifications, alertes de monitoring) et empêchent l’usurpation de l’expéditeur :
- SPF : déclare qui a le droit d’envoyer pour le domaine. v=spf1 include:spf.votre-hebergeur.tld -all
- DKIM : signature cryptographique des messages, activée chez l’hébergeur qui fournit l’enregistrement à publier.
- DMARC : politique en cas d’échec SPF/DKIM, avec rapports. v=DMARC1; p=quarantine; rua=mailto:dmarc@votre-domaine.tld (commencer par p=none en observation, durcir ensuite)
Sans ces trois-là, les mails du site finissent en spam et n’importe qui peut envoyer des emails « de votre part ».
Des règles de base
A cette liste s’ajoutent des règles de logique et de personnalisation en fonction des outils utilisés :
- Limitation du nombre de connexions infructueuses, possiblement limitation par IP ou par pays
- Masquage de la version de WordPress
- Mises-à-jour régulières pour une maintenance préventive efficace
- Thèmes et extensions non utilisés, désactivés ou abandonnés à supprimer
- Restriction de l’API REST (et non plus désactivation, voir la section dédiée)
- Blocage de l’énumération des utilisateurs (paramètre Wordfence, mu-plugin REST et règle ?author=N du .htaccess)
- Vérification d’intégrité indépendante par checksums…
Conclusion
Aucun site n’est infaillible et aucune sécurisation ne sera définitive sur le long terme, il s’agit avant tout d’avoir une base solide et de limiter la surface d’attaque.
Avec ces quelques éléments, vous devriez déjà être plus tranquilles. Au besoin, contactez-moi.
