=== Login Armor ===
Contributors: wpformation
Donate link: https://wpformation.com
Tags: login security, hide login, brute force, limit login, activity log
Requires at least: 6.8
Tested up to: 7.0
Stable tag: 2.3.0
Requires PHP: 8.1
License: GPLv2 or later
License URI: https://www.gnu.org/licenses/gpl-2.0.html

Eleven security modules plus an AI briefing: hide login, brute force, 2FA, password policy, sessions, hardening, audit log. No upsells, GPL.

== Description ==

**Eleven security modules. One lightweight plugin. Zero compromise.**

Login Armor is a complete WordPress security stack built for agencies, freelancers and pros who deliver audit-ready sites. No premium tier, no bundled marketing dashboard, no telemetry. Every module runs locally, ships with safe defaults, and stays out of your way.

Stop juggling Wordfence's bloat, Solid Security's upsells, and Limit Login Attempts' gaps. Login Armor delivers eleven independent modules in about one megabyte, with the discipline of an enterprise plugin and the licensing of free software.

= Why Login Armor =

* **No upsells, ever.** No "premium" tier, no "Pro" buttons greyed out in your admin. Every feature is GPL.
* **No external services to sign up for.** No API keys, no remote dashboards, no third-party telemetry. The only outbound calls are opt-in and fire only when you turn them on: Have I Been Pwned (breach and password checks), Slack, Discord or a webhook (notifications), the keyless ipwho.is API (IP geolocation), and your own WordPress 7 AI connector (security briefing).
* **Built to be invisible.** Sub-megabyte ZIP, lazy-loaded modules, indexed queries. The plugin's footprint stays under 2 ms on a normal login flow.
* **Multisite-aware, PHP 8.1-native.** Network-activate on a fleet, configure per-site, manage from the shell with a complete WP-CLI command suite.
* **Production-grade defaults.** Every toggle ships with the value an experienced admin would pick anyway. Zero-config gets you 80 percent of the protection.

= Eleven independent modules =

**1. Hide Login** - Replace `wp-login.php` with a custom slug. Anyone hitting the old URL gets a 404 from your theme - no leakage that WordPress is even installed. Compatible with multisite, password-protected posts, reverse proxies, and password recovery flows. The branded pre-activation modal lets you pick or generate the slug before flipping the switch, and emails it to you so you can't lock yourself out.

**2. Brute Force Protection** - Cascading lockouts after repeated failed logins. Locked attackers see a branded 429 landing page with a live countdown. Repeated lockouts escalate to a 24-hour ban. Lostpassword, register, XML-RPC and the REST users endpoint are all gated when an IP is locked, so attackers can't pivot. Subnet blocking handles distributed attacks. Trusted X-Forwarded-For for sites behind Cloudflare or a load balancer. Every lockout also surfaces as an incident on the Incidents tab.

**3. Hardening** - Fifteen one-click toggles across surface reduction, credential hardening, request filtering, and account monitoring. Disable XML-RPC and its pingback amplification vector, the theme/plugin file editor, the WordPress version exposure (including `?ver=` on assets, even for WP 6.5+ ES modules), application passwords, author enumeration, and more. Block reserved usernames with Unicode-confusable detection. Add an invisible login honeypot. Block PHP execution in uploads and directory listing via atomic-write `.htaccess` rules. Get alerted whenever an account is created with, or promoted to, the administrator role.

**4. Two-Factor Authentication** - Enterprise-grade 2FA in three flavours: TOTP via any authenticator app (Google Authenticator, Authy, 1Password, Bitwarden), one-time codes by email, and printable backup codes. Trusted devices remembered for thirty days so you only verify once per browser. A recovery flow lets a user reset their second factor by email when the authenticator is lost, without a support ticket. Per-role enforcement, configurable grace period, and a session-aware logout.

**5. Detection and Incidents** - A real-time detection engine groups raw events into six attack patterns: brute force, credential stuffing, distributed scan, post-compromise activity, lockout cascade, and protocol abuse. Each incident has a drill-down view with timeline, source IPs, target users, severity, user-agent fingerprint, and one-click resolution actions (reset password, block subnet, mark resolved).

**6. Activity Log** - Compliance-ready audit trail of admin actions: plugin installs, settings changes, role updates, user creation, content publishing, theme switches, 2FA enrollment events. Tamper-evident hash chain. Filter, search and export to CSV with configurable retention, plus optional signed webhook forwarding to a SIEM. Seven logger domains, all togglable independently.

**7. Login Page Security Headers** - Content-Security-Policy, X-Frame-Options, Permissions-Policy, Referrer-Policy and X-Content-Type-Options on `wp-login.php` and the lockout page. Two presets (standard and strict) with an optional CSP report-uri. The baseline (non-CSP) headers can optionally be extended site-wide.

**8. Breach Check** - Detect users logging in with a password that appears in public data breach corpora, using privacy-preserving k-anonymity lookups against Have I Been Pwned. Only the first 5 hex characters of a SHA-1 prefix leave the server, the password and full hash never travel. Optional opt-in email lookup against XposedOrNot. Fail-soft: a HIBP outage never blocks login.

**9. Password Policy** - Enforce strong, unique passwords at every point WordPress lets one be set: registration, profile update, and lost-password reset. Require a minimum length and character classes (lowercase, uppercase, number, symbol), forbid the username inside the password, and optionally reject passwords found in public breaches (the same privacy-preserving Have I Been Pwned lookup). Optional expiration nudges users to rotate a stale password without ever locking them out.

**10. Session Management** - Log out accounts after a configurable idle timeout, measured on real page loads so a forgotten open tab still expires. Cap the maximum lifetime of any login regardless of "remember me", and optionally restrict each account to a single active device - logging in elsewhere ends every other session. A one-click "sign out all other devices" action covers a lost or shared laptop.

**11. IP Geolocation** - Show the country of origin next to the attacking IPs on the Incidents and Events tabs, so you can see at a glance where attacks come from. Lookups are lazy (only when you open the screen), cached for thirty days, and capped per page load. Private and reserved ranges are never sent out. Keyless ipwho.is by default; a `login_armor_geoip_lookup` filter lets developers swap in an offline database for zero external calls.

= AI Security Briefing (optional) =

A new, strictly optional analysis layer built on the **WordPress 7 native AI Client**. From the Overview, one click turns the last thirty days of real activity into a plain-language **security briefing**: a verdict on your site, the IP picture (which sources attack you, which are not blocked yet), and a short list of prioritised actions. On an incident, "Explain with AI" does the same for a single attack.

* **Privacy-first.** Minimised mode is the default: only anonymised signals leave your site, never an IP address or username in clear. Deep mode (real IPs and event details) is an explicit, never-automatic opt-in.
* **No API key, no lock-in.** Login Armor stores no key and calls no service of its own. It uses the AI connector you configured in your own WordPress, so the choice of provider and the cost stay yours.
* **Useful with or without AI.** The briefing always leads with a deterministic facts snapshot - login activity, legitimate sign-ins, incidents, 2FA, hardening, admin changes, and site health (PHP, MySQL, WordPress, pending updates). Without an AI connector, those facts are still shown; only the written verdict needs AI.

= Plus =

* **Security score** - a weighted 0-100 read of your posture, with a one-click "next best action" that names the module to enable next and the exact points it adds. The scored set is the defensive modules plus hardening; observability features (IP Geolocation, Notifications, the AI assistant) are deliberately not scored.
* **Conflict detection** - a notice warns when another login-focused security plugin (Wordfence, Solid Security, Sucuri, All-In-One Security, SecuPress and more) is active, so you do not double-configure the same protection and lock yourself out.
* **Notifications** by email, Slack, Discord, or generic webhook with built-in SSRF-safe URL validation, severity threshold and rate limiting.
* **WP-CLI command suite** - `wp login-armor status`, `reset-slug`, `unblock`, `whitelist`, `incidents`, `purge-logs`, `activity`, `2fa`. Full scripted operations and emergency recovery from the shell.
* **Dashboard widget** - at-a-glance protection status from any admin page, with a 14-day sparkline and the six headline metrics.

= Built by =

Login Armor is built and maintained by Fabrice Ducarme of [WPFormation](https://wpformation.com/login-armor/), a French WordPress expert obsessed with sites that are clean, fast, and audit-ready. We use this plugin on every site we ship.

* [Login Armor overview and how it works](https://wpformation.com/login-armor/)
* [WordPress security guides](https://wpformation.com/securite-wordpress/) on WPFormation
* [WordPress vulnerability watch](https://wpformation.com/outils/veille-securite/) - WPFormation's security monitoring tool

GPL forever. PHP 8.1+. WordPress 6.8+. Zero dependencies.

---

**Onze modules de sécurité. Une seule extension légère. Zéro compromis.**

Login Armor est une stack complète de sécurité WordPress conçue pour les agences, les freelances et les pros qui livrent des sites prêts à passer un audit. Pas de version premium, pas de tableau de bord marketing intégré, pas de télémétrie. Chaque module tourne en local, embarque des réglages par défaut sécurisés, et reste discret.

Fini de jongler entre la lourdeur de Wordfence, les fenêtres d'upsell de Solid Security et les angles morts de Limit Login Attempts. Login Armor regroupe onze modules indépendants en environ un méga-octet, avec la rigueur d'une extension entreprise et la licence d'un logiciel libre.

= Pourquoi Login Armor =

* **Aucun upsell, jamais.** Pas de niveau « premium », pas de boutons « Pro » grisés dans votre admin. Tout est en GPL.
* **Aucun service externe à activer.** Pas de clé API, pas de tableau distant, pas de télémétrie tierce. Les intégrations optionnelles (Have I Been Pwned pour la détection de fuites, Slack, Discord ou webhook pour les notifications, l'API sans clé ipwho.is pour la géolocalisation IP, et le client IA natif de WordPress 7 pour le briefing de sécurité) ne se déclenchent que si vous les activez explicitement.
* **Conçu pour être invisible.** ZIP de moins d'un méga, modules chargés à la demande, requêtes indexées. L'empreinte sur un flux de connexion normal reste sous 2 ms.
* **Compatible multisite, natif PHP 8.1.** Activation réseau possible sur une flotte, configuration par site, pilotage depuis la ligne de commande via une suite WP-CLI complète.
* **Réglages par défaut prêts pour la production.** Chaque bascule arrive avec la valeur qu'un admin expérimenté choisirait. Sans configuration, vous avez déjà 80 % de la protection.

= Onze modules indépendants =

**1. Masquer la connexion** : remplace `wp-login.php` par une URL personnalisée. Toute tentative sur l'ancienne URL renvoie une 404 du thème, sans révéler la présence de WordPress. Compatible multisite, articles protégés par mot de passe, reverse proxies, et flux de récupération de mot de passe. La modale de pré-activation vous laisse choisir ou générer le slug avant d'activer le module, et vous l'envoie par e-mail pour éviter tout verrouillage.

**2. Protection contre la force brute** : verrouillages en cascade après plusieurs échecs. Les attaquants verrouillés voient une page 429 brandée avec un compte à rebours en direct. Les verrouillages répétés montent à un bannissement de 24 h. Les pages lostpassword, register, XML-RPC et l'endpoint REST users sont également bloqués pour les IPs verrouillées, pour empêcher le pivot. Blocage de sous-réseaux pour les attaques distribuées. Support de X-Forwarded-For pour les sites derrière Cloudflare ou un load balancer. Chaque verrouillage apparaît désormais aussi comme un incident.

**3. Renforcement** : quinze bascules en un clic, regroupées en réduction de surface, durcissement des identifiants, filtrage des requêtes et surveillance des comptes. Désactivation de XML-RPC et de son vecteur d'amplification pingback, de l'éditeur de fichiers thème/extension, de l'exposition de la version WordPress (y compris le `?ver=` sur les assets, même les modules ES de WP 6.5+), des mots de passe applicatifs, de l'énumération des auteurs. Blocage des identifiants réservés avec détection des homoglyphes Unicode. Pot de miel invisible sur le formulaire de connexion. Blocage de l'exécution PHP dans `wp-content/uploads/` et désactivation du listing de répertoires via des règles `.htaccess` écrites en mode atomique. Alerte dès qu'un compte est créé avec, ou promu à, le rôle administrateur.

**4. Authentification à deux facteurs** : 2FA prête pour la production avec trois méthodes : TOTP via n'importe quelle application authenticator (Google Authenticator, Authy, 1Password, Bitwarden), codes à usage unique par e-mail, codes de secours imprimables. Appareils de confiance mémorisés pendant trente jours, vous ne validez qu'une fois par navigateur. Une procédure de récupération laisse l'utilisateur réinitialiser son second facteur par e-mail en cas de perte, sans ouvrir de ticket. Application par rôle, période de grâce configurable, et déconnexion qui ferme proprement les sessions actives.

**5. Détection et incidents** : un moteur en temps réel regroupe les événements bruts en six patterns d'attaque : force brute, credential stuffing, scan distribué, activité post-compromission, cascade de verrouillages et abus protocolaires. Chaque incident dispose d'une vue détaillée : chronologie, IPs sources, comptes cibles, sévérité, empreinte user-agent et actions de résolution en un clic (réinitialisation de mot de passe, blocage de sous-réseau, marquage résolu).

**6. Journal d'activité** : piste d'audit conforme des actions admin : installations d'extensions, modifications de réglages, changements de rôle, créations d'utilisateurs, publications de contenu, changements de thème, événements 2FA. Chaîne de hachage inviolable. Filtrage, recherche et export CSV avec rétention configurable, plus un transfert webhook signé optionnel vers un SIEM. Sept domaines de loggers, activables indépendamment.

**7. En-têtes de sécurité de la page de connexion** : Content-Security-Policy, X-Frame-Options, Permissions-Policy, Referrer-Policy et X-Content-Type-Options sur `wp-login.php` et la page de verrouillage. Deux préréglages (standard et strict) avec une option de CSP report-uri. Les en-têtes de base (hors CSP) peuvent aussi être appliqués à tout le site, en option.

**8. Détection de fuites** : repère les utilisateurs qui se connectent avec un mot de passe présent dans des fuites publiques, via des recherches préservant la vie privée (k-anonymat) sur Have I Been Pwned. Seuls les 5 premiers caractères hexa d'un préfixe SHA-1 quittent votre serveur ; le mot de passe et le hachage complet ne sortent jamais. Vérification e-mail optionnelle (opt-in, désactivée par défaut) via XposedOrNot. Fail-soft : une coupure de HIBP ne bloque jamais la connexion.

**9. Politique de mot de passe** : impose des mots de passe forts et uniques à chaque endroit où WordPress en définit un : inscription, mise à jour du profil et réinitialisation. Exige une longueur minimale et des classes de caractères (minuscule, majuscule, chiffre, symbole), interdit l'identifiant à l'intérieur du mot de passe, et rejette en option les mots de passe présents dans des fuites publiques (la même recherche k-anonymat Have I Been Pwned). Une expiration optionnelle invite à renouveler un mot de passe ancien, sans jamais verrouiller personne dehors.

**10. Gestion des sessions** : déconnecte les comptes après un délai d'inactivité configurable, mesuré sur les vrais chargements de page pour qu'un onglet oublié expire quand même. Plafonne la durée de vie de toute connexion indépendamment de « se souvenir de moi », et limite en option chaque compte à un seul appareil actif : une connexion ailleurs met fin à toutes les autres sessions. Une action « déconnecter tous les autres appareils » en un clic couvre un portable perdu ou partagé.

**11. Géolocalisation IP** : affiche le pays d'origine à côté des IP attaquantes dans les onglets Incidents et Événements, pour voir d'un coup d'œil d'où viennent les attaques. Recherches paresseuses (seulement à l'ouverture de l'écran), mises en cache trente jours et plafonnées par chargement de page. Les plages privées et réservées ne sont jamais envoyées. ipwho.is sans clé par défaut ; un filtre `login_armor_geoip_lookup` permet aux développeurs de brancher une base hors ligne pour zéro appel externe.

= Briefing de sécurité IA (optionnel) =

Une nouvelle couche d'analyse, strictement optionnelle, bâtie sur le **client IA natif de WordPress 7**. Depuis la vue d'ensemble, un clic transforme les trente derniers jours d'activité réelle en un **briefing de sécurité** en langage clair : un verdict sur votre site, le panorama des adresses IP (lesquelles vous attaquent, lesquelles ne sont pas encore bloquées) et une courte liste d'actions prioritaires. Sur un incident, « Expliquer avec l'IA » fait de même pour une attaque précise.

* **La confidentialité d'abord.** Le mode minimisé est celui par défaut : seuls des signaux anonymisés quittent votre site, jamais une adresse IP ni un identifiant en clair. Le mode approfondi (IP réelles et détails d'événements) est un choix explicite, jamais activé à votre place.
* **Aucune clé API, aucun verrouillage.** Login Armor ne stocke aucune clé et n'appelle aucun service propre. Il s'appuie sur le connecteur IA que vous avez configuré dans votre propre WordPress : le choix du fournisseur et le coût restent les vôtres.
* **Utile avec ou sans IA.** Le briefing s'ouvre toujours sur un instantané de faits déterministes : activité de connexion, connexions légitimes, incidents, 2FA, renforcement, changements admin et santé du site (PHP, MySQL, WordPress, mises à jour en attente). Sans connecteur IA, ces faits restent affichés ; seul le verdict rédigé nécessite l'IA.

= En plus =

* **Score de sécurité** : une lecture pondérée de 0 à 100 de votre posture, avec une action prioritaire en un clic qui nomme le module à activer ensuite et les points exacts qu'il rapporte. L'ensemble noté regroupe les modules défensifs et le renforcement ; les fonctions d'observabilité (Géolocalisation IP, Notifications, Assistant IA) ne sont volontairement pas notées.
* **Détection de conflits** : un avertissement signale qu'une autre extension de sécurité axée connexion (Wordfence, Solid Security, Sucuri, All-In-One Security, SecuPress et d'autres) est active, pour éviter de configurer deux fois la même protection et de vous verrouiller dehors.
* **Notifications** par e-mail, Slack, Discord ou webhook générique, avec validation d'URL anti-SSRF intégrée, seuil de sévérité et rate limiting.
* **Suite WP-CLI** complète : `wp login-armor status`, `reset-slug`, `unblock`, `whitelist`, `incidents`, `purge-logs`, `activity`, `2fa`. Opérations scriptées et récupération d'urgence depuis la ligne de commande.
* **Widget Tableau de bord** : statut de protection en un coup d'œil depuis n'importe quelle page admin, avec sparkline 14 jours et six métriques clés.

= Conçu par =

Login Armor est conçu et maintenu par Fabrice Ducarme de [WPFormation](https://wpformation.com/login-armor/), un expert WordPress français obsédé par les sites propres, rapides et prêts pour l'audit. On utilise cette extension sur chaque site qu'on livre.

* [Présentation et fonctionnement de Login Armor](https://wpformation.com/login-armor/)
* [Guides de sécurité WordPress](https://wpformation.com/securite-wordpress/) sur WPFormation
* [Veille des vulnérabilités WordPress](https://wpformation.com/outils/veille-securite/) : l'outil de veille sécurité de WPFormation

GPL pour toujours. PHP 8.1+. WordPress 6.8+. Zéro dépendance.

== Installation ==

1. Upload the `login-armor` directory to `/wp-content/plugins/`
2. Activate the plugin through the 'Plugins' menu in WordPress
3. Go to LoginArmor in the admin menu to configure

For multisite: Network Activate the plugin to apply it across all sites.

= Setting up Hide Login =

1. Go to LoginArmor > Settings > Hide Login section
2. Enter your desired login slug (e.g., `my-login`)
3. Save settings
4. **Bookmark your new login URL**: you will need it to access your admin

= Recovering access =

If you forget your custom login URL:

* Use the recovery email feature (configurable in settings)
* Connect to your database and delete the `login_armor_hide_slug` row from the `wp_options` table
* Use WP-CLI: `wp option delete login_armor_hide_slug`

== Frequently Asked Questions ==

= Will it lock me out of my own site? =

No. Hide Login always sends a one-time recovery URL to the admin email. If you lose the slug, check your inbox. The plugin also honors `wp-cli` fallback so you can reset anything from SSH.

= Does it slow my site down? =

No. Everything is lazy-loaded and indexed. On a normal login flow the extra SQL cost is under 2 ms.

= Is it compatible with Cloudflare / reverse proxies? =

Yes. IP detection honors trusted `X-Forwarded-For` headers; you pick the header in Settings.

= Does it work with multisite? =

Yes, subdomain and subfolder. Each site has its own modules, logs, and thresholds.

= Can I use LoginArmor alongside Wordfence / iThemes Security / Solid Security? =

Yes, but disable overlapping modules on one side to avoid double lockouts.

= Where is the data stored? =

Three custom tables in your own database: events, incidents, activity. Nothing leaves your server.

= How do I migrate my configuration? =

Settings are plain WordPress options. Export/import via WP-CLI or any standard options-sync tool.

= Is there a pro version? =

Not currently. LoginArmor is fully free and open source. GPL forever.

= Where can I report bugs or request features? =

Support forum: [wordpress.org/support/plugin/login-armor/](https://wordpress.org/support/plugin/login-armor/).

== Screenshots ==

1. Quick tour of all eight modules - Hide Login, Hardening, 2FA setup with QR code, Incidents drill-down, Activity Log, Events, and Overview dashboard.
2. Overview dashboard - health cards, security pulse, live event tail, threat banner that surfaces active attacks.
3. Incidents - real-time pattern detection grouped by attack class with severity and one-click resolution.
4. Incident drill-down - full timeline, user-agent fingerprint, suggested actions, escalation flag.
5. Events - complete login attempts log with filters and CSV export.
6. Activity Log - admin action audit trail across seven domains, filterable and exportable.
7. Settings - modular configuration with live security score and a sticky save bar.
8. Hide Login pre-activation modal - pick or generate the secret URL and email it to yourself before flipping the switch.
9. Hardening - thirteen one-click toggles grouped by surface reduction, credential hardening, and request filtering.
10. Two-factor authentication setup - QR code for any authenticator app, copy-paste fallback, and live verification.
11. Breach Check - fully transparent k-anonymity lookups, separate password and email toggles, opt-in email check disabled by default.

== External Services ==

= AI Security Briefing (optional) =

The AI Security Briefing and the "Explain with AI" incident analysis are powered by the **WordPress 7 native AI Client** (`wp_ai_client_prompt()`). When the administrator clicks the analysis button, LoginArmor asks WordPress to send a prompt to the **AI connector that the administrator configured in their own WordPress** (for example OpenAI, Anthropic or Google, depending on the connector). LoginArmor itself stores no API key and contacts no endpoint directly: the request, the provider and the cost are owned by the site's own AI connector.

Data sent: a text prompt describing the security situation. In **minimised mode (the default)**, only anonymised, non-identifying signals are included (counts, categories, severities, role buckets) - no IP address and no username in clear. In **deep mode** (an explicit, off-by-default opt-in), the prompt additionally includes real IP addresses and event details so the analysis can name specific sources. No data is ever sent unless the administrator clicks the analysis button.

This feature is inactive unless WordPress 7 (or the AI Building Blocks feature plugin) is present with a configured, approved AI connector. The applicable terms and privacy policy are those of the AI provider the administrator chose for their connector; please refer to that provider's documentation.

= Webhook Notifications (optional) =

When explicitly enabled and configured by the administrator in LoginArmor > Settings > Notifications, the plugin sends incident data to third-party services via webhooks.

Data sent: incident type, severity level, IP address, target username, event count, and site URL.

No data is sent unless the administrator actively enables and configures a notification channel.

* **Slack** - [Terms of Service](https://slack.com/terms-of-service) | [Privacy Policy](https://slack.com/privacy-policy)
* **Discord** - [Terms of Service](https://discord.com/terms) | [Privacy Policy](https://discord.com/privacy)
* **Custom Webhook URL** - User-configured endpoint (administrator's responsibility)

= Gravatar (Automattic) =

The Activity Log tab uses WordPress core's `get_avatar()` function to display user avatars. WordPress may send a hashed email address to [Gravatar](https://gravatar.com/) servers to retrieve avatar images. This is controlled by Settings > Discussion > Avatars.

* **Gravatar** - [Automattic Terms of Service](https://automattic.com/tos/) | [Privacy Policy](https://automattic.com/privacy/)

= Breach Check - Have I Been Pwned (optional) =

When the administrator explicitly enables the **Breach Check** module (LoginArmor > Settings > Breach Check), LoginArmor queries the public Have I Been Pwned Pwned Passwords API on each successful login and on password changes to detect user passwords that appear in public data breach corpora.

Data sent: the **first 5 hex characters** of a SHA-1 hash of the password (k-anonymity lookup). The full password and its full hash never leave the server. The API cannot determine which password is being checked - it only sees a 5-character prefix that is mathematically shared with ~500-900 other candidate hashes.

The same privacy-preserving lookup is also used by the **Password Policy** module when its "block compromised passwords" rule is enabled: a new password is checked the moment it is set (registration, profile update, password reset) and rejected if it appears in the corpus. Identical k-anonymity model - only a 5-character hash prefix is sent - and identical fail-soft behaviour: if the API is unreachable, the password is allowed rather than blocking the reset.

No API key or account is required. The endpoint is free and public.

* **Have I Been Pwned** - [Pwned Passwords privacy statement](https://haveibeenpwned.com/Privacy) | [Acceptable Use Policy](https://haveibeenpwned.com/AcceptableUse)

= Breach Check - XposedOrNot (optional email sub-toggle) =

When the administrator additionally enables the **Email check** sub-toggle inside the Breach Check module (off by default), LoginArmor queries the public XposedOrNot check-email API on new user creation and on email change to detect email addresses that appear in publicly disclosed data breaches.

Data sent: the user's email address (URL-encoded) and a plugin-identifying User-Agent string. This is unavoidable for the lookup - there is no k-anonymity variant for email breach checks. Because this call transmits an email address to a third party, it is opt-in and off by default.

No API key or account is required. The endpoint is free and public.

* **XposedOrNot** - [xposedornot.com](https://xposedornot.com/) | [Privacy Policy](https://xposedornot.com/privacy.html)

= IP Geolocation - ipwho.is (optional) =

When the administrator explicitly enables the **IP Geolocation** module (LoginArmor > Settings > IP Geolocation), LoginArmor resolves the IP addresses it already displays (attacking IPs on the Incidents and Events tabs) to a country, so the admin can see where attacks originate.

Data sent: the public IP address being displayed, sent to the keyless ipwho.is HTTPS API, which returns the country. The lookup happens lazily - only when an administrator opens a screen that lists IPs - and each result is cached for 30 days, so a given IP is sent at most once per month. **Private and reserved IP ranges are never sent.** No API key or account is required.

Developers can short-circuit the lookup with the `login_armor_geoip_lookup` filter (e.g. to use a bundled MaxMind/IP2Location database), in which case no request is sent to ipwho.is at all.

* **ipwho.is** - [ipwho.is](https://ipwho.is/) | [Documentation](https://ipwhois.io/documentation)

== Changelog ==

= 2.3.0 =

Feature release - account-security hardening.

* New - **Password Policy** module. Enforce a minimum length and character-class requirements (lowercase, uppercase, number, symbol), forbid the username inside the password, and optionally reject passwords found in public data breaches (reusing the privacy-preserving Have I Been Pwned k-anonymity lookup - see External Services). Optional password expiration nags users to change a stale password without ever locking them out. Validation runs at every point WordPress lets a password be set: registration, profile update, and lost-password reset.
* New - **Session Management** module. Log out accounts after a configurable idle timeout (measured on real page loads, so a forgotten open tab still expires), cap the maximum lifetime of any login regardless of "remember me", and optionally restrict each account to a single active device. A "sign out all other devices" action revokes every session except the current one.
* New - **IP Geolocation** (optional, opt-in). Show the country of origin (ISO code plus localized country name) next to the IP addresses on your Incidents and Events tabs, so you can see at a glance where attacks come from. Lookups are lazy (only when you open the screen), cached for 30 days, and capped per page load. Private and reserved IP ranges are never sent out. Uses the keyless public ipwho.is API by default; a `login_armor_geoip_lookup` filter lets developers swap in an offline database for zero external calls. See External Services.
* New - Hardening: **Disable pingbacks** toggle neutralizes the XML-RPC pingback methods and removes the X-Pingback header (a DDoS amplification vector) without having to disable XML-RPC entirely.
* New - Hardening: **Alert on new administrator** toggle emails the site admin and fires an action hook whenever an account is created with, or promoted to, the administrator role — a common sign of compromise. Detection only; it never blocks the operation.
* New - **Conflict detection**. On Login Armor screens, a notice warns when another login-focused security plugin (Wordfence, Solid Security, Sucuri, AIOS, SecuPress, WPS Hide Login) is active, so you don't double-configure the same protection and lock yourself out.
* Improvement - The **security score now accounts for the Password Policy and Session Management modules**. Weights were rebalanced (still capped at 100, GeoIP stays informational and unscored), so enabling these new modules raises your score — and existing sites may see their score shift to reflect the wider set of available protections.
* Improvement - **Every IP lockout now creates an incident.** Previously a brute-force that tripped the lockout (4 failures) could stay invisible on the Incidents tab, because the incident pattern needed 5 windowed failures and the lockout counter lives in a separate table. A lockout is now decisive evidence on its own — the blocked IP always surfaces as a brute-force incident.
* New - Login Page Protection Headers can now **apply the baseline (non-CSP) security headers site-wide** as an opt-in, extending X-Content-Type-Options, Referrer-Policy, Permissions-Policy, X-Frame-Options (SAMEORIGIN), and HSTS to the whole front-end. The CSP stays login-only by design.

= 2.2.0 =

Feature release - the new AI Security Briefing.

* New - **AI Security Briefing** on the Overview. One click turns the last 30 days of real activity into a plain-language verdict on your site, the picture of which IPs attack you (and which are not blocked yet), and a short list of prioritised actions. Built on the WordPress 7 native AI Client: it uses the AI connector you configured in your own WordPress, stores no API key, runs only when you click, and is cached.
* New - The briefing always leads with a **deterministic facts snapshot**: login activity, legitimate sign-ins by role, incidents, 2FA, hardening, security score, admin changes (content, plugins, themes, users), and site health (PHP, MySQL, WordPress, table prefix, pending updates, HTTPS). Those facts show with or without AI - with no connector you still get the facts plus an invitation to enable the assistant.
* New - **"Explain with AI"** on an incident, for a plain-language analysis of a single attack.
* Privacy - Minimised mode is the default: only anonymised signals leave your site, never an IP address or username in clear. Deep mode (real IPs and event details) is an explicit opt-in, confirmed through the Settings save bar and never enabled for you. This external-service call is disclosed under External Services above.

= 2.1.26 =

Patch — makes email/backup two-factor verification work even when the browser does not send the verification cookie.

* Fix - On some browsers the two-factor verification cookie was not sent back when submitting the emailed code, which bounced the user to the login page with "Your verification session expired" — even though the code and the session were valid (reproduced only on the reporter's Chrome; the same flow worked in Firefox and in automated Chrome against the same site). The verification screen now also carries the session token in the submitted form, so verification succeeds whether or not the cookie comes back. Security is unchanged: the token is validated server-side, the cookie stays the preferred channel, it travels in the form body (not the URL, so it is not logged or leaked via the referrer), and a nonce is still required whenever the signed cookie is absent.

= 2.1.25 =

Patch — fixes email two-factor verification being rejected in some browsers.

* Fix - Verifying the email (or backup) code could bounce you straight back to the login page with "Your verification session expired", even right after the code arrived — seen in Chrome (including Incognito) while Firefox worked. The verification form is now sent uncached and its submission is authenticated by the signed, same-site verification cookie, so a browser re-using a cached form no longer breaks login. Security is unchanged: the verification cookie is HMAC-signed, HttpOnly and SameSite=Strict.
* Fix - After entering a wrong code, the screen now keeps the method you were using (for example Email) instead of switching you back to the authenticator app.

= 2.1.24 =

Patch — fixes a fatal error during authenticator-app setup on some hosts.

* Fix - On hosts whose configuration does not define the WordPress AUTH_KEY security key (seen on some Infomaniak installs), the "Set up Authenticator App" button failed with a server error (shown as "network error"). Two-factor secret encryption no longer depends on that specific key being present and now works on those hosts too. Sites that already have the key are unaffected and existing authenticator setups keep working.

= 2.1.23 =

Patch — fixes for the two-factor login screen, reported by a user.

* Fix - The "use a different method" links on the 2FA login screen now actually switch method instead of re-showing the same one. Switching to Email sends a fresh code.
* Fix - When a verification session expires or you enter too many wrong codes, the login page now explains why instead of bouncing you back with no message.
* Fix - The "Set up Authenticator App" button on your profile now shows an error if setup cannot start (for example a mail or security-plugin problem) instead of appearing to do nothing.

= 2.1.22 =

Patch — the security score now counts the modules that are on by default.

* Fix - The Security Score header could read low (for example "4 of 8 modules active", score 44) on installs where Brute Force Protection and Detection had never been toggled, even though both are on by default and were actively protecting the site. The score now reads each module's real enabled-state through a single source of truth shared with the dashboard module pills, so the header text, the score number and the module list always agree. Scoring and display only — no change to what the modules actually do.

= 2.1.21 =

Patch — cleaner user-agent labels in the Events and live dashboard tables.

* Fix - Service and unknown user-agents were truncated at 30 characters mid-token, leaving a dangling open parenthesis (e.g. "Jetpack by WordPress.com (Jetp"). Jetpack / WordPress.com clients now show a clean "Jetpack (WordPress.com)" / "WordPress.com" label, and any other long user-agent is trimmed at a word boundary with an ellipsis — no more cut-off strings.

= 2.1.20 =

Migration-aware integrity, an XML-RPC blind-spot warning, and a complete French translation.

* Feature - **Security-key-aware Activity Log integrity.** After a site migration, a restored backup, or a manual `wp-config.php` key change, the security keys (`wp_salt`) that sign the activity log change — which previously surfaced as a red **"TAMPERED"** alarm even though nothing was altered. The plugin now recognises a key rotation and shows an amber **"KEYS CHANGED — not tampering"** advisory instead, with a one-click **"Re-baseline chain"** button (and a `wp login-armor activity reset-chain` CLI command) that re-signs the log under the current key and restores the integrity guarantee. No log row is ever deleted.
* Feature - **Hide Login ↔ XML-RPC blind-spot warning.** Hiding your login URL does not protect `xmlrpc.php`, a second authentication endpoint brute-force bots use to walk straight past Hide Login. When Hide Login is on, XML-RPC is still open, **and** an XML-RPC-vectored attack has actually been recorded in the last 30 days, the dashboard now shows an actionable warning with a one-click link to disable XML-RPC.
* Improvement - Completed the French (fr_FR) translation across the whole plugin (settings, notifications, sidebars, hardening, 2FA, rare error and confirmation strings), not only the previously translated visible tabs. Validated against the official Polyglots FR glossary.

= 2.1.19 =

Clarity, internationalization and a Detection-dashboard bug fix.

* Improvement - Incident attack patterns now show a clear, translatable label **and a one-line description** of what the attack is (e.g. "Distributed attack — the same attack spread across many IPs to slip under per-IP lockout") instead of a raw `RESERVED_USERNAME_PROBE`-style constant.
* Improvement - Activity Log action verbs that previously rendered as raw English (e.g. `INITIALIZED`, `USED`, `TRUSTED`, `REVOKED`, `RESET`) are now mapped to translatable labels.
* Improvement - Admin toast notifications (e.g. "All changes saved.") are now translatable via the site locale instead of being hard-coded English.
* Improvement - Completed the French (fr_FR) translation of the visible admin tabs (Incidents, Activity, Settings, Overview). Validated against the official Polyglots FR glossary (zero terminology/typography violations).
* Fix - The Activity Log integrity badge stayed on "UNVERIFIED" after a successful "Verify now", even though the chain was intact — a legacy CSS selector pointed the in-place update at the wrong element. The badge now flips to INTACT/TAMPERED correctly without a page reload.

= 2.1.18 =

Patch release fixing two UX issues in the 2.1.17 incident dashboard.

* Fix - Incident checkboxes now render on **every** incident card, not only active ones — so "Select all" and bulk resolve/ignore work even when your list is entirely resolved or ignored. `Incident::bulk_set_status()` now applies to every selected incident regardless of its current status (e.g. bulk-ignoring a pile of already-resolved incidents to declutter).
* Fix - The attack-vector pill is shown only for vectors that **bypass Hide Login** (XML-RPC, REST). Incidents recorded through the normal login form no longer display a "via login form" label, and legacy incidents created before 2.1.17 (which default to `login_form`) stay unlabelled instead of being mislabelled.

= 2.1.17 =

Feature release for the Detection dashboard, driven by real-world XML-RPC brute-force triage. Surfaces the attack entry point on every incident and lets you clear incidents in bulk instead of one at a time.

* Feature - Incidents now record and display the **attack vector** — the entry point each event came in on: XML-RPC, REST API, or the login form. A new `vector` column is added to the incidents table (one-shot dbDelta migration) and the Classifier stamps it from `XMLRPC_REQUEST` / `REST_REQUEST` at log time. Each incident card shows a "via XML-RPC" pill, highlighted when the vector bypasses Hide Login (XML-RPC / REST). Also exposed in `wp login-armor incidents list`. This makes it obvious when brute-force attempts reach your account through `xmlrpc.php` even though your login URL is hidden — close it with the `disable_xmlrpc` hardening toggle.
* Feature - **Bulk incident actions**: tick several incidents (with a "Select all" toggle) and mark them resolved or ignored in one click, instead of acting on each card individually. New `login_armor_incidents_bulk` AJAX endpoint and `wp login-armor incidents bulk-resolve --ids=…` / `bulk-ignore --ids=…` WP-CLI commands. Bulk actions only affect incidents that are still active.
* Internal - `Incident::bulk_set_status()` updates only `active` rows in a single prepared statement; the per-card and bulk paths share the same status whitelist.

= 2.1.16 =

Bug fix release addressing four issues raised by an external security review of V2.1.15 (anti-gravity audit, 2026-05-20). Two compatibility fixes for sites running plain permalinks, one audit-trail coverage gap on activity-log events that fire outside an authenticated session (2FA verification, frontend self-registration, password reset via Lost Password), and one coverage gap on third-party login forms.

* Fix - `HideLogin::get_login_url()` now branches on `permalink_structure`, returning `https://example.com/?<slug>` on plain permalinks instead of the always-rewritten `/<slug>/` form. Without this, 2FA redirects and admin-email login links produced a hard 404 on plain-permalinks installs. Mirrors the existing logic in `new_login_url()` so both helpers stay consistent.
* Fix - `Hardening::require_rest_authentication()` (restrict_rest_api toggle) now also inspects the `?rest_route=` query parameter — the routing form WordPress uses for REST requests when permalinks are plain. Previously the public-namespace allowlist (oEmbed, Contact Form 7, Site Health) only matched the `/wp-json/...` rewrite, so every legitimate anonymous REST call was rejected with `rest_forbidden` (HTTP 403) on plain permalinks. The `wp-json/` path detection is preserved for normal permalink installs.
* Fix - `ActivityLog\ActivityLogger::log()` accepts a new optional `?int $explicit_user_id` parameter. Specific loggers (TwoFactor, User) now use it to attribute an entry to a known target user even when the request runs in an anonymous context — fixing the silent drop of `2fa_verified`, `2fa_failed`, `2fa_backup_used`, `2fa_device_trusted`, `2fa_device_revoked` events (fire before `wp_set_auth_cookie()` during the verification flow) and `user_created` / `user_email_changed` / `user_password_changed` events triggered by frontend self-registration (WooCommerce my-account, native `?action=register`, MemberPress, etc.) or by the Lost Password reset flow. The default guard on `wp_get_current_user()->exists()` still applies when no explicit attribution is provided, so WP-Cron and other system contexts keep being filtered out.
* Fix - `Hardening\Honeypot` now also injects the honeypot field on WooCommerce native forms (`woocommerce_login_form`, `woocommerce_register_form`, `woocommerce_lostpassword_form`) and emits a small inline script on `wp_footer` that auto-injects the field into any frontend `<form>` matching the login/register/lost-password heuristic. Closes the coverage gap on third-party login forms (Elementor Pro Login, Divi Login Module, MemberPress, custom forms posting to `wp-login.php`). Pure-REST/AJAX login flows that build their request body in JS are not covered by this fallback — those still have to opt in via the existing `caught()` API.
* Internal - Documented inline rationale on every modified call site so future maintenance keeps the "why" alongside the "what".

= 2.1.15 =

Bug fix release. Resolves a fatal `TypeError` triggered when third-party plugins (such as WP Fastest Cache) call WordPress core URL builders (`get_site_url`, `wp_redirect`, etc.) with argument types that do not match the strict signatures previously declared by Login Armor's filter and action callbacks. WordPress core does not validate runtime types on filter callback arguments — so any caller passing a string `$blog_id` (which is allowed) would crash the admin page on Login Armor's `?int $blog_id` callback. Reported by a user after installing WP Fastest Cache on 2026-05-20.

* Fix - `HideLogin::filter_site_url()` no longer enforces `?int $blog_id`: all four parameters are now untyped, matching the convention used by WordPress core itself for filter callbacks. The strict `string` return type is preserved. This is the exact signature that crashed when WP Fastest Cache called `get_site_url('1', ...)` from its "Clear Specific Pages" UI.
* Fix - Same defensive relaxation applied to six other core WP filter/action callbacks of the same class: `HideLogin::filter_network_site_url`, `HideLogin::filter_wp_redirect` (was `int $status`), `HideLogin::filter_login_url` (was `bool $force_reauth`, falsy check switched from `false ===` to `empty()` for consistent handling of `0`, `'0'`, `''`, `null`), `LimitLogin::gate_allow_password_reset` (was `bool $allow, int $user_id`), `LimitLogin::gate_xmlrpc_enabled` (was `bool $enabled`), `ActivityLog UserLogger::on_user_deleted` (was `?int $reassign`).
* Internal - Return types remain strictly typed across all touched callbacks: Login Armor still satisfies the contract WordPress expects from filter returns (e.g. `site_url` returns a string). Only inbound parameters are relaxed. Strictly neutral on canonical WP calls.

= 2.1.14 =

Bug fix release. Fixes the `prevent_author_enum` hardening toggle which was over-broad: it blocked the legitimate `?author=N` filter in the WordPress admin Posts/Pages list ("All / Mine / <author>" links) in addition to the intended public enumeration vector. Reported by `jeantelli` on the WP.org support forum on 2026-05-19.

* Fix - `Hardening::block_author_query()` now early-returns when `is_admin() && current_user_can( 'edit_posts' )`, leaving the core admin author filter on `edit.php` functional for administrators, editors, authors, and contributors. The frontend enumeration vector (anonymous `/?author=N` requests, including admin-ajax.php without an authenticated user) remains fully blocked. Aligns with the existing `is_user_logged_in()` guard pattern used by the four other prevent_author_enum handlers (REST users endpoint, oEmbed user info, user sitemap, REST user prepare).
* Internal - Strictly neutral on the public enumeration block: identical 301-to-home response for anonymous visitors with `?author=N`, anonymous AJAX, and REST author filtering. Three-line change in a single method.

= 2.1.13 =

Bug fix release. Fixes a silent 2FA failure on installs whose permalink_structure does not end with a trailing slash (e.g. `/%postname%`). With 2FA enabled, the verification challenge after submitting login credentials would disappear and the user would land back on the login form with no error message. Reported by a user on 2026-05-11.

* Fix - `PendingCookie::get_path()` no longer appends a trailing slash to the Hide Login slug. The cookie was set with path `/<slug>/`, but the trailing-slash normalisation in `HideLogin::handle_loaded()` would 302 the verify URL to `/<slug>?login-armor-2fa=verify` (no trailing slash) on installs where permalink_structure does not end with `/`. RFC 6265 §5.1.4 path-match then refused to send the cookie on `/<slug>` because the cookie path `/<slug>/` is strictly longer than the request path. `maybe_render_verification` saw `token_data === false` and silently bounced to the login URL — exactly the "stuck on login page, no error" symptom. Setting the cookie path to `/<slug>` (no trailing slash) matches `/<slug>`, `/<slug>/`, and `/<slug>/...` per RFC 6265 while still rejecting `/<slug>XYZ`.
* Internal - Strictly neutral on installs with trailing-slash permalinks (the V2.1.0-V2.1.12 majority). No security or functional change for those installs.

= 2.1.12 =

Bug fix release. Fixes broken-CSS rendering on the login page when both apex and `www` hostnames route to the same WordPress without a server-level canonical 301 (common on shared hosting). Two complementary fixes.

* Fix - `HideLogin::intercept_request()` now canonicalises the request host before any URI rewriting. If the request lands on a hostname that matches neither `home_url()` nor `site_url()` (e.g. `example.com/<slug>` when `siteurl` is `https://www.example.com`), Hide Login issues a 301 to the canonical host preserving the full request URI. Closes the window where WP core's `redirect_canonical` (which runs later on `template_redirect`) was being short-circuited by Hide Login's `plugins_loaded` priority 9999 interception. Host-only comparison (not scheme/port) to avoid loop-redirects on misconfigured reverse-proxy setups. Skips WP-CLI, cron and AJAX defensively.
* Fix - `LoginHeaders::build_csp()` is now host-aware. Every CSP directive that governs cross-origin resource loads (`script-src`, `style-src`, `img-src`, `font-src` strict mode, `connect-src` strict mode, `default-src` strict mode, `form-action`) emits `'self'` PLUS the parsed origins of `home_url()` AND `site_url()`. Without this, the previous `'self'`-only policy blocked every `wp_enqueue_style()`-emitted CSS link whenever the document host differed from the asset host. Defence-in-depth alongside the canonical-host fix. `base-uri` stays `'self'` — cross-origin `<base>` is always a security hole.
* Feature - New filter `login_armor_canonical_host_redirect` to skip the V2.1.12 canonical-host 301 when returning false (proxy setups, dev environments, multisite configs with a third routing hostname). Receives `$http_host`, `$home_host`, `$site_host`.
* Internal - Strictly neutral on installs where `home_url()` and `site_url()` resolve to the same single canonical host. Bug reported by Alexandre Puy on `dumouriez.com` (Dynamixhost / Apache / Debian).

= 2.1.11 =

Bug fix release. Fixes a V2.1.9 regression on multisite + domain mapping setups (where sub-sites are mapped to external domains via WP MU Domain Mapping or native WP 4.5+).

* Fix - `HideLogin::build_login_url()` is now host-aware: chooses between `home_url()` and `site_url()` based on the request's `HTTP_HOST` header, instead of always returning `site_url()` (V2.1.9/V2.1.10 default). Resolves the case where a subsite mapped on an external domain would redirect immediately to `/wp-admin/` (404) when typing the slug, because `site_url()` pointed to the original network path while the request arrived on the mapped domain. Reported on the WP.org support forum by @graphandco. Standard installs (`siteurl == home`) and multisite headless setups (where the request arrives on the siteurl host) continue to work as in V2.1.9/V2.1.10.
* Fix - Slug detection in `intercept_request()` now matches against BOTH `home_url($slug, 'relative')` AND `site_url($slug, 'relative')` (instead of just one). Same dual-base matching extended to the `wp-login.php` and `wp-register.php` traps. No security change: relative paths only, no new hostnames accepted.
* Internal - Filter `login_armor_login_url_base` (introduced in V2.1.9) is unchanged and continues to wrap the final URL for advanced overrides (third-host scenarios, custom subdomain mapping plugins).

= 2.1.10 =

Cosmetic fix release. The 404 page served when an anonymous visitor hits `/wp-admin/` with Hide Login enabled now renders as a proper WordPress 404 instead of a half-bootstrapped theme page with a duplicated header.

* Fix - `block_access()` now routes through `serve_404_template()` so the response carries the proper `WP_Query::set_404()` state. Visible effect: body class `error404` is set, Yoast (or any SEO plugin) emits `<meta name="robots" content="noindex">`, the theme renders its real 404 template instead of a default page layout, and themes with sticky headers (Astra Pro, many FSE themes) no longer show a duplicated header. No security change, no functional change for the actual 404 status header (still 404).
* Internal - 30 lines of duplicated 404 rendering code removed from `block_access()`; both code paths now share `serve_404_template()`.

= 2.1.9 =

Bug fix release. Hide Login now uses `site_url()` instead of `home_url()` to build the rewritten login URL, matching what WordPress core does inside `wp_login_url()`.

* Fix - Hide Login URL base switched from `home_url()` to `site_url()` (14 callsites migrated to a new public static helper `HideLogin::build_login_url()`). Inherited from the WPS Hide Login fork, the previous `home_url()` base was invisible on the ~99 percent of installs where `siteurl == home`, but broke silently on multisite headless (siteurl on `admin.example.com`, home on `example.com`), WordPress installed in a subdirectory (`/wp/`), and reverse-proxy installs with `WP_HOME` not equal to `WP_SITEURL`. Reported on the WP.org support forum by @graphandco.
* Feature - New filter `login_armor_login_url_base` for exotic setups where neither `home_url()` nor `site_url()` matches the hostname that actually serves the login slug (third hostname behind a reverse-proxy, subdomain mapping plugin rewriting the admin URL, etc.). Receives `$url`, `$path`, `$scheme`, `$slug`.
* Internal - Strictly neutral on the standard install where `siteurl == home`. Fresh-install, multisite-headless and WP-in-subdir validation suite passed before tag.

= 2.1.8 =

Hygiene release issued from a full V2.1.7 audit. Three findings, all LOW severity, batched in a single update.

* Fix - `admin/views/tabs/settings.php` queries the V2.1.1 webhook queue table without an existence guard. On a fresh install where the Activity Log module was never enabled (table not created) or after `wp plugin install --force` (which doesn't re-fire activation), every Settings tab load emitted three DB warnings in `debug.log` (`Table 'X.wp_login_armor_webhook_queue' doesn't exist`). Non-fatal but log-polluting. Now wraps the SELECT in a `SHOW TABLES LIKE` guard and returns zero counts when the table is absent.
* Fix - `uninstall.php` cleanup list was missing the `login_armor_lockout_window` option (default 24h, used by `LimitLogin::trigger_lockout()` for escalation tracking). Plugin deletion previously left this single option behind. Now: zero residue.
* i18n - Five untranslated strings surfaced by `wp i18n make-pot` regen: the V2.1.1 Activity Log integrity badge `BROKEN`, the legacy-rows-not-covered amber notice (singular form), the Breach Check password-found message (singular), the Breach Check email-breach message (singular), and the plugin description meta. All translated to French in `languages/login-armor-fr_FR.po`, no em dash. `.mo` recompiled. `.pot` regenerated against the full source tree (1010 strings vs 990 in 2.1.7) to capture 20 strings that had been added to the code (V2.1.1 webhook + integrity UI) but never made it into the translation template.

= 2.1.7 =

Preventive release on the Email 2FA enrollment flow. Closes a self-lockout pattern reported by a user whose hosting silently dropped outgoing mail.

* Fix - Email 2FA enrollment no longer half-commits when `wp_mail()` fails. The `login_armor_2fa_method = email` user meta was previously written before the verification email was attempted, leaving a partially configured state behind on hosts where SMTP is broken (Wanadoo, mutualised hosts without SMTP relay, etc.). The order is now: send first, persist only on success.
* Feature - New pre-activation modal on the user profile page when a user clicks "Set up Email" 2FA. Forces a real `wp_mail()` round-trip with a Send-test-email button and a safety-net checkbox ("I have a second admin tab open") before the Enable button unlocks. Both gates must pass, eliminating the most common cause of admin-locked-out support tickets.
* Internal - New AJAX endpoint `login_armor_2fa_email_test` (nonce-protected) sends a one-shot test message without consuming the OTP cooldown.

= 2.1.6 =

Preventive release bundling V2.1.5 + post-tag cleanup findings. No bug observed in production — eliminates a latent V2.1.3-style fatal risk in the TwoFactor module and finishes the uninstall.php cleanup audit.

* Preventive - TwoFactor module now follows the always-require pattern (same as Activity Log V2.1.4 and BreachCheck). Class files are loaded unconditionally so any future hook callback that statically references TwoFactor classes survives fresh installs. Constructor and `register()` still gated by the enable option — zero-overhead contract preserved.
* Cleanup - `uninstall.php` now drops the V2.1.1 webhook queue table, deletes 14 leftover options (HSTS, login headers preset, activity auto-verify daily, V2.1.1 chain init flag + show-notice, 5 webhook), generalizes the transient SQL DELETE to `login_armor_*` (covering chain_verify_last and any future transient), and clears 3 V2.1.1 cron hooks (webhook dispatch + chain repair + chain auto-verify). Plugin deletion now leaves zero residual data.
* UX - Activity Log Integrity panel now surfaces "X rows before the integrity chain are not covered by Verify" when legacy or pre-init rows exist. The verify-chain coverage scope is now explicit rather than implicit.

= 2.1.4 =

Critical hotfix.

* Fix - Fatal error `Class "LoginArmor\ActivityLog\ActivityLog" not found` on every fresh install. The class file was loaded only when the Activity Log module was enabled, but the V2.1.1 chain initializer hooked at `init` priority 5 references the class unconditionally. On a fresh install (Activity Log option not yet set), every request to `wp-login.php` and the front end crashed with a 500. Existing sites that already had Activity Log enabled were unaffected. Fixed by always loading the class file (constructor still gated — zero-overhead contract preserved) and adding a defensive `class_exists` guard at the top of `maybe_initialize_activity_chain()`.

= 2.1.3 =

Critical hotfix.

* Fix - Hardening "Hide WordPress version" toggle was stripping the `?ver=` cache-buster from LoginArmor's own admin assets (admin.css, admin.js), in addition to WP core and 3rd-party plugin files. Combined with hosting providers that run a server-side static cache (LiteSpeed LSADC on o2switch PowerBoost, Cloudflare full-page cache, hosting CDNs) keyed on the canonical URL, every LoginArmor update past 2.1.0 was invisible to admins for up to a year of cache TTL — the browser kept fetching the old admin.css from the server-side cache because `admin.css` (no query) and `admin.css?ver=2.1.2` are different cache keys. Filter now whitelists `/plugins/login-armor/` paths so our own assets always carry their version-derived hash, while WP core and 3rd-party version disclosure are still stripped.
* Fix - Defense-in-depth `width="18" height="18"` HTML attributes on the Activity Log Integrity bar's shield SVG icon. Without these, if admin.css fails to reach the browser for any reason (CDN edge stale cache, content-blocker, proxy stripping CSS), the icon defaults to its intrinsic 300x150px and dominates the page layout. The CSS rule is still authoritative; HTML attrs are belt-and-suspenders.

= 2.1.2 =

Critical hotfix + UX polish.

* Fix - Settings tab fatal error on fresh installs that have not yet enabled the Activity Log module. The class `LoginArmor\ActivityLog\WebhookDispatcher` was referenced in the Settings tab without an explicit `require_once`, and the file is only loaded when Activity Log is on. Visiting the Settings tab on a default install crashed with `Class "LoginArmor\ActivityLog\WebhookDispatcher" not found`. Fixed by loading the file unconditionally before its first use.
* Fix - Save-confirmation toast (`Settings saved.`) was anchored top-right and overlapped the LoginArmor admin tabs nav, making the message unreadable behind the dark Réglages tab. Moved to bottom-right (Gutenberg snackbar convention), bumped z-index above sticky elements, and re-tuned the entrance animation to slide up from the bottom edge.

= 2.1.1 =

* Feature - Activity Log integrity: every row is HMAC-SHA256 signed and chained to the previous one. Detects any direct-SQL tampering, deletion or insertion. First-in-market for WP audit-log plugins.
* Feature - Signed webhook forwarding: optional async POST of every activity event to your SIEM, Slack, Datadog, Discord or any HTTPS receiver. `X-LoginArmor-Signature` HMAC header, adaptive retry policy, max 5 attempts.
* Feature - WP-CLI command `wp login-armor activity verify-chain` for scheduled audits and orphan repair (`--from`, `--to`, `--repair-orphans`, `--format`, `--verbose`).
* Feature - Admin UI: new compact "Activity Log Integrity" status bar in Activity tab + full Webhook configuration panel in Settings (URL, secret regenerate, send test event, queue stats).
* Feature - Login Page Security Headers (CSP + X-Frame + Referrer-Policy) now ON by default on fresh installs; REST public-namespace allowlist filterable via `login_armor_rest_public_namespaces`; auto-detection of 6 conflicting Hide Login plugins (Rename wp-login.php, WPS Hide Login, Defender, Solid Security, Wordfence, AIOS).
* Fix - Hardening: `mask_login_errors` no longer leaks remaining-attempt hint through LimitLogin filter (S-9), Honeypot switched to `<input type="hidden">` to survive theme stripping (S-22), User-Agent truncation cap reduced 500 -> 256 chars (S-19).

= 2.1.0 =

* Security (HIGH) - 2FA pending-verification token no longer travels in the URL. After the password step, the partially-authenticated session is held in a HttpOnly + SameSite=Strict cookie scoped to the login slug, signed with HMAC-SHA256 over `wp_salt('auth')`. Closes a leak surface that exposed the token via browser history, server access logs and the `Referer` header.
* Security (defense-in-depth) - The transient that backs the pending session is now keyed on `sha256(token)` instead of the clear token. The clear token never appears in `wp_options.option_name` either - DB-read attacks no longer yield a replayable token.
* Compat - URL token is still accepted as a fallback for one minor (V2.1.0). V2.2.0 will remove the fallback. Browsers that reject SameSite=Strict cookies fall through gracefully.
* Internal - One-shot upgrade hook purges any leftover V2.0.x 2FA pending transients from `wp_options` on first load. Idempotent.

= 2.0.5 =

Security audit pass. Five fixes identified by an internal Phase 1 + Phase 2 audit against 2.0.4, each double-checked on production before patching. No functional regression.

* Fix (HIGH) - REST author-enumeration scope. The Hardening "Disable author enumeration" toggle now also gates `/wp-json/oembed/1.0/embed`, the `_embed=1` fanout on `/wp/v2/posts`, and `wp-sitemap-users-N.xml`. The lockout-side REST gate also denies oEmbed for locked IPs.
* Fix (HIGH) - Optional HSTS header for the Login Headers module. Off by default; opt-in 180-day or 1-year-with-subdomains modes. Only emitted on HTTPS requests.
* Fix (MED) - IPv6 subnet derivation. `subnet_of()` and `Detection\Classifier::compute_subnet()` now produce a re-parseable canonical /64 from compressed IPv6 (`2001:db8::1` → `2001:db8:0:0::/64`). Subnet block rules entered against IPv6 attackers now match.
* Fix (MED) - Self-DoS via `0.0.0.0` placeholder. `record_failed_attempt()` and `check_lockout()` skip the literal placeholder returned by `get_client_ip()` when the configured proxy header is misconfigured. Prevents site-wide lockout on the shared placeholder.
* Fix (MED) - "Block PHP in uploads" toggle off no longer wipes user-authored .htaccess rules. Only the LoginArmor block (recognized by `# BEGIN/END LoginArmor` markers, with a legacy fallback) is stripped.

= 2.0.4 =

Real fix for the lockout 429 page never appearing on hosts with a public page cache fronting the Hide Login slug.

* Fix - `LockoutPage::render_on_trigger` now performs a 302 redirect to `/[hide-slug]/?_la_locked=<timestamp>`. The unique query string defeats every public page cache (LiteSpeed Cache, WP Rocket, Cloudflare full-page, hosting reverse-proxy). Verified live with Playwright Firefox 150 + httpx HTTP/2.
* Fix - Branded lockout page logo now appears (path was referencing a file that did not ship).

= 2.0.3 =

Same-day hotfix on top of 2.0.2 for HTTP/2 stream termination.

* Fix - `LoginArmor::flush_response_and_exit()` now mirrors WordPress core's `_default_wp_die_handler` exactly (no `fastcgi_finish_request`). Under LiteSpeed/LSAPI, calling `fastcgi_finish_request()` after a 4xx with a custom body left HTTP/2 streams without END_STREAM, hanging Firefox/Chromium. HTTP/2 lockout response now arrives complete in 1.3 s.

= 2.0.2 =

Critical fix for the lockout 429 page.

* Fix - The branded 429 lockout page now actually reaches the browser when the lockout is triggered. Root cause: PHP's default output buffer (`output_buffering=4096` on most managed hosts) was capturing the body. `LoginArmor::flush_response_and_exit()` now discards every parent buffer before writing the response. Same helper is used by Hide Login's 404 renderer and Hardening's XML-RPC + access-denied responses, so all three paths inherit the fix.

= 2.0.1 =

Post-launch patch.

* Fix - Branded 429 lockout landing page is now rendered on the lockout-triggering attempt (#N), not the next one. New action `login_armor_lockout_triggered` fires after the lockout marker is committed; LockoutPage subscribes and renders inline.
* Feature - Two new "Reset" buttons in Settings: "Reset all events & incidents" wipes the events feed, brute-force counters, and incidents in one click; "Reset all activity entries" wipes the admin-action audit trail. Both are confirmation-gated.
* Assets - WordPress.org banner and icon replaced with the navy-blue / yellow-padlock variant matching the in-admin icon.

= 2.0.0 =

First WordPress.org public release of the V2 line. Bundles eight independent security modules in a single sub-megabyte plugin: Hide Login (custom URL slug + branded lockout page), Brute Force Protection (cascading lockouts, subnet blocking, X-Forwarded-For), Hardening (13 one-click toggles), Two-Factor Authentication (TOTP + Email OTP + backup codes + trusted devices + recovery flow), Detection and Incidents (6 attack patterns), Activity Log (compliance-ready audit trail), Login Page Security Headers (CSP / X-Frame-Options / Permissions-Policy presets), and Breach Check (HIBP k-anonymity + opt-in XposedOrNot).

Pre-release security audit:

* Security - Breach Check email lookup is opt-in by default (only the password lookup is enabled when the module is activated).
* Security - Reserved-username blacklist folds Unicode-to-ASCII before comparison, so homoglyphs collapse onto the same blacklist entry.
* Security - REST API gating for unauthenticated users now matches public namespaces by route prefix on the parsed REST path; substring-match query-string bypass closed.
* Security - "Hide WordPress version" also strips `?ver=X.Y.Z` from script and stylesheet URLs at `script_loader_src` priority 9999.
* Security - .htaccess writes (Block PHP in uploads, Disable directory listing) are atomic via temp file + rename, with admin notice on flush failure.

== Upgrade Notice ==

= 2.3.0 =
Account-security release. New opt-in modules: Password Policy (length/complexity rules + reject breached passwords via privacy-preserving HIBP), Session Management (idle timeout, max lifetime, single active session), and IP Geolocation (country flags on incidents — opt-in, discloses an external service). Plus pingback disabling, new-administrator alerts, security-plugin conflict detection, and optional site-wide baseline headers. All new features are off by default; nothing changes until you enable it.

= 2.2.0 =
New feature: the AI Security Briefing turns your last 30 days of real activity into a plain-language verdict, an IP picture and prioritised actions, on top of a deterministic facts snapshot (login, incidents, 2FA, hardening, admin changes, site health). Optional and privacy-first: built on the WordPress 7 native AI Client, it uses your own AI connector, stores no API key, runs only on click, and defaults to anonymised signals (no IP or username in clear). Works with or without AI.

= 2.1.26 =
Fixes email/backup 2FA bouncing to "session expired" on browsers that don't return the verification cookie on submit (some Chrome setups; Firefox worked). The form now also carries the session token, so login works regardless. Recommended if Email 2FA is enabled. Security unchanged.

= 2.1.25 =
Fixes email/backup two-factor verification being rejected ("session expired") in some browsers, notably Chrome and Chrome Incognito, while Firefox worked. The verification form is now uncached and authenticated by the signed same-site verification cookie. Strongly recommended if Email two-factor is enabled. Security is unchanged.

= 2.1.24 =
Fixes a fatal error (HTTP 500 / "network error") during authenticator-app (TOTP) setup on hosts whose wp-config.php does not define AUTH_KEY, such as some Infomaniak installs. Recommended if Two-Factor is enabled. Existing setups are unaffected.

= 2.1.23 =
Fixes the two-factor login screen: the "use a different method" links now work (and email a fresh code when switching to Email), expired or locked sessions explain themselves instead of bouncing silently, and the authenticator-setup button reports errors. Recommended for everyone using 2FA.

= 2.1.22 =
Fixes a Security Score that under-counted active modules: Brute Force and Detection (on by default) are now scored correctly, so the header, the score number and the module list agree. Display and scoring only — recommended for all installs.

= 2.1.21 =
Cosmetic patch: cleaner user-agent labels in the Events table — Jetpack/WordPress.com clients are recognised, and long agents are trimmed at a word boundary with an ellipsis instead of a chopped-off string with a dangling parenthesis.

= 2.1.20 =
Migration-friendly integrity: a security-key change now shows an amber "Keys changed" advisory with one-click chain re-baseline instead of a false "TAMPERED" alarm. Adds an XML-RPC blind-spot warning when Hide Login is on but XML-RPC stays open. Completes the French translation.

= 2.1.19 =
Clearer attack-type labels + descriptions on incidents, French translation of the visible admin tabs, translatable toast notifications, and a fix for the Activity Log integrity badge staying "UNVERIFIED" after a successful verify. Recommended for all installs.

= 2.1.18 =
Patch. Fixes "Select all" / bulk actions when incidents are all resolved (checkboxes now on every card) and only labels the attack vector for XML-RPC/REST (no more misleading "via login form"). Recommended for 2.1.17 users.

= 2.1.17 =
Feature release. Incidents now show the attack vector (XML-RPC / REST / login form) — spot which attempts bypass your hidden login URL — plus bulk mark-resolved/ignore. Adds a vector column to the incidents table (auto migration). Recommended for all installs.

= 2.1.16 =
Bug fix release from an external audit. Fixes plain-permalinks compat (Hide Login URL, REST API allowlist), restores activity-log coverage for 2FA, frontend registration and password reset, and extends Honeypot to WooCommerce + frontend login forms. Recommended for all installs.

= 2.1.15 =
Bug fix. Resolves a fatal TypeError when third-party plugins (e.g. WP Fastest Cache) call WordPress URL builders with off-contract argument types. Strict type hints on 7 filter/action callbacks across HideLogin, LimitLogin and UserLogger relaxed to match WordPress core's own convention. Strictly neutral on canonical WP calls.

= 2.1.14 =
Bug fix. The prevent_author_enum hardening toggle no longer blocks the legitimate ?author=N filter in wp-admin Posts/Pages lists ("All / Mine / <author>" links). Public enumeration block unchanged. Three-line fix.

= 2.1.13 =
Bug fix. Silent 2FA failure on installs with permalink_structure without trailing slash (e.g. /%postname%) — the verify cookie path mismatched the request path after handle_loaded's normalisation. Fixed cookie path to omit trailing slash. Neutral on trailing-slash installs.

= 2.1.12 =
Bug fix. Hide Login rendered without CSS when both apex and www routed to the same WP (shared hosting). Two fixes: canonical-host 301 in Hide Login + host-aware CSP in Login Page Security Headers. Neutral on single-host installs. New filter login_armor_canonical_host_redirect for opt-out.

= 2.1.11 =
Bug fix release for multisite + domain mapping setups. Hide Login URL is now host-aware (picks home_url or site_url based on HTTP_HOST). Fixes a V2.1.9 regression where mapped subsites would redirect to /wp-admin/ (404) instead of serving the slug page. Standard installs and multisite headless continue to work.

= 2.1.10 =
Cosmetic fix. The 404 page served when an anonymous visitor hits `/wp-admin/` with Hide Login enabled now renders as a proper WordPress 404 (body class `error404`, SEO `noindex` meta, theme 404 template) instead of a half-bootstrapped page. No security or functional change.

= 2.1.9 =
Bug fix. Hide Login now builds the rewritten login URL from `site_url()` (matching `wp_login_url()` in WP core) instead of `home_url()`. Fixes silent breakage on multisite headless, WordPress in subdirectory, and reverse-proxy installs. Neutral on standard installs.

= 2.1.8 =
Hygiene release after a full V2.1.7 audit. Three LOW fixes batched: webhook stats query no longer warns on fresh installs, `lockout_window` option now cleaned on uninstall, five missing French translations added (Activity Log integrity badges, Breach Check messages). No code path change visible to end users.

= 2.1.7 =
Preventive: hardens the Email 2FA enrollment flow. Failed `wp_mail()` no longer leaves a half-committed 2FA state, and a new pre-activation modal forces a real test email + a safety-net check before the user can lock themselves out. Recommended for every install where Email-based 2FA is enabled.

= 2.1.6 =
Preventive release. Eliminates a latent V2.1.3-style fatal risk in the TwoFactor module. Finishes the uninstall.php cleanup (zero residual data). Surfaces Activity Log integrity coverage scope in admin UI. No new features, no DB migration.

= 2.1.4 =
Critical hotfix: 2.1.3 fatal-errored on every fresh install (Class "LoginArmor\ActivityLog\ActivityLog" not found). Sites with Activity Log already enabled were unaffected. Recommended for every install, urgent for new installs.

= 2.1.3 =
Critical hotfix: Hardening "Hide WP version" was stripping cache-buster from our own assets, so updates past 2.1.0 were invisible behind hosting CDNs (LiteSpeed LSADC, Cloudflare). Recommended for every install.

= 2.1.2 =
Critical hotfix: the Settings tab fatal-errored on every fresh install that had not yet enabled the Activity Log module (Class WebhookDispatcher not found). Recommended for every install.

= 2.1.1 =
Activity Log integrity: every row is HMAC-signed and chained, detects any tampering. Optional signed webhook forwarding (SIEM / Slack / Datadog / any HTTPS). New WP-CLI verify-chain. Bundles 6 hardening fixes. Migration automatic. Recommended for every install.

= 2.1.0 =
Security: 2FA pending token moved from URL query string to a signed HttpOnly + SameSite=Strict cookie. Closes URL-leak (browser history / Referer / access logs) and DB-leak (clear token no longer in wp_options). Recommended for every install with 2FA enabled.

= 2.0.5 =
Security audit pass: REST author-enum scope, optional HSTS, IPv6 subnet fix, 0.0.0.0 placeholder DoS skip, .htaccess admin-rules preservation. No regression. Recommended.

= 2.0.4 =
Real fix for the lockout 429 page on hosts with a public page cache (LiteSpeed Cache, WP Rocket, Cloudflare). Recommended after the 2.0.1-2.0.3 sequence.

= 2.0.3 =
Hotfix: HTTP/2 stream termination on LiteSpeed/LSAPI for the branded lockout page. Recommended.

= 2.0.2 =
Critical fix: 429 branded lockout page now reaches the browser. Recommended.

= 2.0.1 =
Branded 429 lockout page on the triggering attempt + Reset Stats UI + correct WP.org banner/icon. Recommended.

= 2.0.0 =
First WordPress.org release of the V2 line. Eight independent security modules. Recommended.
