<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Mourad Ilyes Mlik</title>
    <description>The latest articles on DEV Community by Mourad Ilyes Mlik (@mmitech).</description>
    <link>https://dev.to/mmitech</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3857242%2Fac656add-32df-4565-b66a-2497120288af.png</url>
      <title>DEV Community: Mourad Ilyes Mlik</title>
      <link>https://dev.to/mmitech</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/mmitech"/>
    <language>en</language>
    <item>
      <title>Locking Down WHMCS: Nginx, Fail2ban, and What Nobody Tells You</title>
      <dc:creator>Mourad Ilyes Mlik</dc:creator>
      <pubDate>Thu, 02 Apr 2026 09:16:16 +0000</pubDate>
      <link>https://dev.to/mmitech/locking-down-whmcs-nginx-fail2ban-and-what-nobody-tells-you-1kl9</link>
      <guid>https://dev.to/mmitech/locking-down-whmcs-nginx-fail2ban-and-what-nobody-tells-you-1kl9</guid>
      <description>&lt;p&gt;If you run a hosting business on WHMCS, your admin panel is one of the most targeted endpoints on your entire infrastructure. Credential stuffing, brute force attempts, script scanners probing for known path, it never stops.&lt;/p&gt;

&lt;p&gt;I run &lt;a href="https://mmitech.si" rel="noopener noreferrer"&gt;MMITech&lt;/a&gt;, a hosting provider in Slovenia, and our WHMCS instance sits behind Nginx serving seven language variants across multiple European markets. After watching the access logs for a while, I decided to properly harden the setup. Here's what I did and the gotchas I ran into along the way.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: Move the Admin Path
&lt;/h2&gt;

&lt;p&gt;WHMCS ships with &lt;code&gt;/admin&lt;/code&gt; as the default admin directory. Every scanner on the internet knows this. The first step is renaming it to something non-obvious.&lt;/p&gt;

&lt;p&gt;In WHMCS, rename the admin directory:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;mv&lt;/span&gt; /var/www/whmcs/admin /var/www/whmcs/your-secret-path
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then update &lt;code&gt;configuration.php&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$customadminpath&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'your-secret-path'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This doesn't make you secure it just reduces noise. Security through obscurity is not security, but it does cut down on 99% of automated scans hitting your admin login.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: Restrict Admin Access by IP in Nginx
&lt;/h2&gt;

&lt;p&gt;The real protection is restricting your admin path to known IP addresses at the web server level. Even if someone knows the path and has valid credentials, they can't reach it from an unauthorized IP.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;location&lt;/span&gt; &lt;span class="n"&gt;/your-secret-path/&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;allow&lt;/span&gt; &lt;span class="mf"&gt;203.0&lt;/span&gt;&lt;span class="s"&gt;.113.10&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;    &lt;span class="c1"&gt;# Your office IP&lt;/span&gt;
    &lt;span class="kn"&gt;allow&lt;/span&gt; &lt;span class="mf"&gt;198.51&lt;/span&gt;&lt;span class="s"&gt;.100.0/24&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;# VPN range&lt;/span&gt;
    &lt;span class="kn"&gt;deny&lt;/span&gt; &lt;span class="s"&gt;all&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="kn"&gt;try_files&lt;/span&gt; &lt;span class="nv"&gt;$uri&lt;/span&gt; &lt;span class="nv"&gt;$uri&lt;/span&gt;&lt;span class="n"&gt;/&lt;/span&gt; &lt;span class="n"&gt;/your-secret-path/index.php?&lt;/span&gt;&lt;span class="nv"&gt;$query_string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="kn"&gt;location&lt;/span&gt; &lt;span class="p"&gt;~&lt;/span&gt; &lt;span class="sr"&gt;\.php$&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kn"&gt;fastcgi_pass&lt;/span&gt; &lt;span class="s"&gt;unix:/run/php/php8.3-fpm.sock&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;fastcgi_param&lt;/span&gt; &lt;span class="s"&gt;SCRIPT_FILENAME&lt;/span&gt; &lt;span class="nv"&gt;$document_root$fastcgi_script_name&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;include&lt;/span&gt; &lt;span class="s"&gt;fastcgi_params&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After adding this, test from a non-whitelisted IP. You should get a 403 immediately. If you get a 200, your location block isn't matching, more on that below.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: Lock Down configuration.php
&lt;/h2&gt;

&lt;p&gt;WHMCS stores database credentials, API keys, and license info in &lt;code&gt;configuration.php&lt;/code&gt;. This file should never be served by the web server under any circumstances.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;location&lt;/span&gt; &lt;span class="p"&gt;~&lt;/span&gt;&lt;span class="sr"&gt;*&lt;/span&gt; &lt;span class="s"&gt;configuration&lt;/span&gt;&lt;span class="err"&gt;\&lt;/span&gt;&lt;span class="s"&gt;.php&lt;/span&gt;$ &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;deny&lt;/span&gt; &lt;span class="s"&gt;all&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;404&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Put this &lt;strong&gt;before&lt;/strong&gt; your general PHP location block. Nginx processes location blocks in a specific order, exact matches first, then regex. If your general &lt;code&gt;\.php$&lt;/code&gt; block catches it first, the deny never triggers.&lt;/p&gt;

&lt;p&gt;Test it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-I&lt;/span&gt; https://yourdomain.com/configuration.php
&lt;span class="c"&gt;# Should return 404&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 4: Fail2ban Jails for WHMCS
&lt;/h2&gt;

&lt;p&gt;This is where it gets interesting. WHMCS logs failed login attempts, but its log format doesn't match any of fail2ban's built-in filters. You need custom jails.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Nginx Log Format Problem
&lt;/h3&gt;

&lt;p&gt;If you use a non-standard Nginx log format (which many WHMCS setups do because of reverse proxies or load balancers), fail2ban's default &lt;code&gt;nginx-http-auth&lt;/code&gt; filter won't work. You need to write &lt;code&gt;failregex&lt;/code&gt; patterns that match your actual log format.&lt;/p&gt;

&lt;p&gt;Here's what our log lines look like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight apache"&gt;&lt;code&gt;203.0.113.55 - - [15/Mar/2026:14:22:31 +0100] "POST /dologin.php HTTP/1.1" 302 0 "https://example.com/login.php" "Mozilla/5.0..."
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A failed WHMCS login returns a 302 redirect back to the login page. A successful login also returns a 302 but redirects to the client area. The differentiator is the referer and the POST target.&lt;/p&gt;

&lt;h3&gt;
  
  
  Jail: WHMCS Client Login Brute Force
&lt;/h3&gt;

&lt;p&gt;Create &lt;code&gt;/etc/fail2ban/filter.d/whmcs-login.conf&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="nn"&gt;[Definition]&lt;/span&gt;
&lt;span class="py"&gt;failregex&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;^&amp;lt;HOST&amp;gt; .* "POST /dologin&lt;/span&gt;&lt;span class="se"&gt;\.&lt;/span&gt;&lt;span class="s"&gt;php HTTP/.*" 302&lt;/span&gt;
&lt;span class="py"&gt;ignoreregex&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the jail in &lt;code&gt;/etc/fail2ban/jail.local&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="nn"&gt;[whmcs-login]&lt;/span&gt;
&lt;span class="py"&gt;enabled&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;true&lt;/span&gt;
&lt;span class="py"&gt;port&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;http,https&lt;/span&gt;
&lt;span class="py"&gt;filter&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;whmcs-login&lt;/span&gt;
&lt;span class="py"&gt;logpath&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;/var/log/nginx/access.log&lt;/span&gt;
&lt;span class="py"&gt;maxretry&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;5&lt;/span&gt;
&lt;span class="py"&gt;findtime&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;300&lt;/span&gt;
&lt;span class="py"&gt;bantime&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;3600&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This bans any IP that makes 5 failed login attempts within 5 minutes for 1 hour.&lt;/p&gt;

&lt;h3&gt;
  
  
  Jail: Admin Login Brute Force
&lt;/h3&gt;

&lt;p&gt;Same concept but for the admin path, with a lower threshold:&lt;/p&gt;

&lt;p&gt;Create &lt;code&gt;/etc/fail2ban/filter.d/whmcs-admin.conf&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="nn"&gt;[Definition]&lt;/span&gt;
&lt;span class="py"&gt;failregex&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;^&amp;lt;HOST&amp;gt; .* "POST /your-secret-path/dologin&lt;/span&gt;&lt;span class="se"&gt;\.&lt;/span&gt;&lt;span class="s"&gt;php HTTP/.*" 302&lt;/span&gt;
&lt;span class="py"&gt;ignoreregex&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="nn"&gt;[whmcs-admin]&lt;/span&gt;
&lt;span class="py"&gt;enabled&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;true&lt;/span&gt;
&lt;span class="py"&gt;port&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;http,https&lt;/span&gt;
&lt;span class="py"&gt;filter&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;whmcs-admin&lt;/span&gt;
&lt;span class="py"&gt;logpath&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;/var/log/nginx/access.log&lt;/span&gt;
&lt;span class="py"&gt;maxretry&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;3&lt;/span&gt;
&lt;span class="py"&gt;findtime&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;300&lt;/span&gt;
&lt;span class="py"&gt;bantime&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;86400&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;3 failed attempts and you're banned for 24 hours. Aggressive, but nobody legitimate fails admin login 3 times in 5 minutes.&lt;/p&gt;

&lt;h3&gt;
  
  
  Jail: Script Scanners
&lt;/h3&gt;

&lt;p&gt;Bots constantly probe for paths like &lt;code&gt;/wp-login.php&lt;/code&gt;, &lt;code&gt;/xmlrpc.php&lt;/code&gt;, &lt;code&gt;/phpmyadmin&lt;/code&gt;, etc. These are instant giveaways that the request is malicious:&lt;/p&gt;

&lt;p&gt;Create &lt;code&gt;/etc/fail2ban/filter.d/nginx-script-unknown.conf&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="nn"&gt;[Definition]&lt;/span&gt;
&lt;span class="py"&gt;failregex&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;^&amp;lt;HOST&amp;gt; .* "(GET|POST) /(wp-login|wp-admin|xmlrpc|phpmyadmin|pma|myadmin|administrator|&lt;/span&gt;&lt;span class="se"&gt;\.&lt;/span&gt;&lt;span class="s"&gt;env|&lt;/span&gt;&lt;span class="se"&gt;\.&lt;/span&gt;&lt;span class="s"&gt;git).*" (404|403)&lt;/span&gt;
&lt;span class="py"&gt;ignoreregex&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="nn"&gt;[nginx-script-unknown]&lt;/span&gt;
&lt;span class="py"&gt;enabled&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;true&lt;/span&gt;
&lt;span class="py"&gt;port&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;http,https&lt;/span&gt;
&lt;span class="py"&gt;filter&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;nginx-script-unknown&lt;/span&gt;
&lt;span class="py"&gt;logpath&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;/var/log/nginx/access.log&lt;/span&gt;
&lt;span class="py"&gt;maxretry&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;2&lt;/span&gt;
&lt;span class="py"&gt;findtime&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;300&lt;/span&gt;
&lt;span class="py"&gt;bantime&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;86400&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Jail: Recidive (Repeat Offenders)
&lt;/h3&gt;

&lt;p&gt;The recidive jail catches IPs that keep getting banned and gives them a longer ban:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="nn"&gt;[recidive]&lt;/span&gt;
&lt;span class="py"&gt;enabled&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;true&lt;/span&gt;
&lt;span class="py"&gt;logpath&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;/var/log/fail2ban.log&lt;/span&gt;
&lt;span class="py"&gt;banaction&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;%(banaction_allports)s&lt;/span&gt;
&lt;span class="py"&gt;bantime&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;604800&lt;/span&gt;
&lt;span class="py"&gt;findtime&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;86400&lt;/span&gt;
&lt;span class="py"&gt;maxretry&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;3&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If an IP gets banned 3 times in 24 hours across any jail, they're blocked for a week on all ports.&lt;/p&gt;

&lt;h3&gt;
  
  
  Test Your Filters
&lt;/h3&gt;

&lt;p&gt;Always test before deploying:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;fail2ban-regex /var/log/nginx/access.log /etc/fail2ban/filter.d/whmcs-login.conf
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This shows you exactly how many lines match. If it returns 0 matches on a log that you know has failed login attempts, your regex is wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5: Security Headers (Watch the Inheritance)
&lt;/h2&gt;

&lt;p&gt;This is the gotcha that cost me the most time. Nginx has a counterintuitive behavior with &lt;code&gt;add_header&lt;/code&gt;: if you add &lt;strong&gt;any&lt;/strong&gt; &lt;code&gt;add_header&lt;/code&gt; directive in a child block, it &lt;strong&gt;completely overrides&lt;/strong&gt; all &lt;code&gt;add_header&lt;/code&gt; directives from the parent block.&lt;/p&gt;

&lt;p&gt;Example of what goes wrong:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;server&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;add_header&lt;/span&gt; &lt;span class="s"&gt;X-Frame-Options&lt;/span&gt; &lt;span class="s"&gt;"SAMEORIGIN"&lt;/span&gt; &lt;span class="s"&gt;always&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;add_header&lt;/span&gt; &lt;span class="s"&gt;X-Content-Type-Options&lt;/span&gt; &lt;span class="s"&gt;"nosniff"&lt;/span&gt; &lt;span class="s"&gt;always&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;add_header&lt;/span&gt; &lt;span class="s"&gt;X-XSS-Protection&lt;/span&gt; &lt;span class="s"&gt;"1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="kn"&gt;mode=block"&lt;/span&gt; &lt;span class="s"&gt;always&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="kn"&gt;location&lt;/span&gt; &lt;span class="n"&gt;/your-secret-path/&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kn"&gt;add_header&lt;/span&gt; &lt;span class="s"&gt;X-Robots-Tag&lt;/span&gt; &lt;span class="s"&gt;"noindex,&lt;/span&gt; &lt;span class="s"&gt;nofollow"&lt;/span&gt; &lt;span class="s"&gt;always&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="c1"&gt;# BUG: The three headers above are now GONE for this location&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The fix is to repeat all headers in every location block that adds its own headers. Or use the &lt;code&gt;ngx_http_headers_more_module&lt;/code&gt; which has &lt;code&gt;more_set_headers&lt;/code&gt; that doesn't have this inheritance problem.&lt;/p&gt;

&lt;p&gt;Our approach repeat the base headers using an include file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="c1"&gt;# /etc/nginx/snippets/security-headers.conf&lt;/span&gt;
&lt;span class="k"&gt;add_header&lt;/span&gt; &lt;span class="s"&gt;X-Frame-Options&lt;/span&gt; &lt;span class="s"&gt;"SAMEORIGIN"&lt;/span&gt; &lt;span class="s"&gt;always&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;add_header&lt;/span&gt; &lt;span class="s"&gt;X-Content-Type-Options&lt;/span&gt; &lt;span class="s"&gt;"nosniff"&lt;/span&gt; &lt;span class="s"&gt;always&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;add_header&lt;/span&gt; &lt;span class="s"&gt;X-XSS-Protection&lt;/span&gt; &lt;span class="s"&gt;"1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;mode=block"&lt;/span&gt; &lt;span class="s"&gt;always&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;add_header&lt;/span&gt; &lt;span class="s"&gt;Referrer-Policy&lt;/span&gt; &lt;span class="s"&gt;"strict-origin-when-cross-origin"&lt;/span&gt; &lt;span class="s"&gt;always&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;add_header&lt;/span&gt; &lt;span class="s"&gt;Permissions-Policy&lt;/span&gt; &lt;span class="s"&gt;"camera=(),&lt;/span&gt; &lt;span class="s"&gt;microphone=(),&lt;/span&gt; &lt;span class="s"&gt;geolocation=()"&lt;/span&gt; &lt;span class="s"&gt;always&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then include it everywhere:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;server&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;include&lt;/span&gt; &lt;span class="n"&gt;/etc/nginx/snippets/security-headers.conf&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="kn"&gt;location&lt;/span&gt; &lt;span class="n"&gt;/your-secret-path/&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kn"&gt;include&lt;/span&gt; &lt;span class="n"&gt;/etc/nginx/snippets/security-headers.conf&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;add_header&lt;/span&gt; &lt;span class="s"&gt;X-Robots-Tag&lt;/span&gt; &lt;span class="s"&gt;"noindex,&lt;/span&gt; &lt;span class="s"&gt;nofollow"&lt;/span&gt; &lt;span class="s"&gt;always&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Verify with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-I&lt;/span&gt; https://yourdomain.com/
curl &lt;span class="nt"&gt;-I&lt;/span&gt; https://yourdomain.com/your-secret-path/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both should show the full set of security headers.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 6: Rate Limiting the Login Endpoint
&lt;/h2&gt;

&lt;p&gt;On top of fail2ban, add Nginx-level rate limiting as a first line of defense:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="c1"&gt;# In the http block&lt;/span&gt;
&lt;span class="k"&gt;limit_req_zone&lt;/span&gt; &lt;span class="nv"&gt;$binary_remote_addr&lt;/span&gt; &lt;span class="s"&gt;zone=whmcs_login:10m&lt;/span&gt; &lt;span class="s"&gt;rate=5r/m&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;# In the server block&lt;/span&gt;
&lt;span class="k"&gt;location&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;/dologin.php&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;limit_req&lt;/span&gt; &lt;span class="s"&gt;zone=whmcs_login&lt;/span&gt; &lt;span class="s"&gt;burst=3&lt;/span&gt; &lt;span class="s"&gt;nodelay&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;limit_req_status&lt;/span&gt; &lt;span class="mi"&gt;429&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="kn"&gt;fastcgi_pass&lt;/span&gt; &lt;span class="s"&gt;unix:/run/php/php8.3-fpm.sock&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;fastcgi_param&lt;/span&gt; &lt;span class="s"&gt;SCRIPT_FILENAME&lt;/span&gt; &lt;span class="nv"&gt;$document_root$fastcgi_script_name&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;include&lt;/span&gt; &lt;span class="s"&gt;fastcgi_params&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This limits login attempts to 5 per minute per IP, with a burst allowance of 3. Anything beyond that gets a 429 response before it even hits PHP, saving server resources.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Result
&lt;/h2&gt;

&lt;p&gt;After deploying all of this, our daily failed login noise dropped from hundreds of attempts to effectively zero reaching the application. The combination works in layers:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Non-default admin path&lt;/strong&gt;: eliminates automated scanners&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;IP restriction on admin&lt;/strong&gt;: blocks everyone except authorized IPs&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rate limiting on login&lt;/strong&gt;: throttles brute force at the Nginx level&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fail2ban jails&lt;/strong&gt;: bans persistent offenders at the firewall level&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Recidive jail&lt;/strong&gt;: escalates bans for repeat offenders&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Security headers&lt;/strong&gt;: prevents clickjacking and XSS&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;configuration.php blocked&lt;/strong&gt;: protects credentials from direct access&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;None of these individually is sufficient. Together, they create enough friction that attackers move on to easier targets.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I'm Mourad, founder of &lt;a href="https://mmitech.si" rel="noopener noreferrer"&gt;MMITech&lt;/a&gt; a hosting provider based in Kranj, Slovenia. We run Cloud VPS on Proxmox/Ceph, AMD VPS on Ryzen 9/NVMe, Nextcloud storage, and dedicated servers. If you have questions about this setup or want to compare notes, drop a comment or reach out at &lt;a href="mailto:hello@mmitech.si"&gt;hello@mmitech.si&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>security</category>
      <category>nginx</category>
      <category>sysadmin</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
