Protect Multiple Paths with .htaccess

Introduction

This page shows a step-by-step procedure to help you adapt a PHP script and .htaccess file so that multiple paths (like /admin, /abc, and wp-login.php) are protected by IP address. We assume:

  1. You have a PHP script that manages IP addresses and writes them to .htaccess.
  2. You have an existing .htaccess file where you want to apply the new rules.

1. Identify Your Protected Paths

Decide what you need to protect:

  • Specific filenames (e.g. wp-login.php, admin, abc).
    OR
  • Entire subdirectories (e.g. /admin/, /abc/).

This choice determines which Apache directives you’ll use in .htaccess.


2. Decide Which Apache Directive to Use

Option A: Protect Specific Filenames

Use <FilesMatch> in your .htaccess, for example:

<FilesMatch "^(wp-login\.php|admin|abc)$">
Order Deny,Allow
Deny from all
Allow from 1.2.3.4
Allow from ...
</FilesMatch>
  • ^(wp-login\.php|admin|abc)$ means:
    • Match wp-login.php
    • OR a file literally named admin
    • OR a file literally named abc
  • Deny from all blocks everyone by default.
  • Allow from 1.2.3.4 permits the IP 1.2.3.4. You can add more Allow from ... lines.

Option B: Protect Entire Directories

If you need to protect all files in /admin/ or /abc/, you generally cannot do that with <FilesMatch> alone. Instead, you might need:

  • Main Apache config (on VPS/Dedicated):
    <Directory "/var/www/html/admin">
    Require ip 1.2.3.4
    </Directory>
  • Apache 2.4+ <If> directive in .htaccess (if your hosting allows):
    <If "%{REQUEST_URI} =~ m#^/(admin|abc)/# || %{REQUEST_URI} == '/wp-login.php'">
      Order Deny,Allow
      Deny from all
      Allow from 1.2.3.4
      Allow from ...
    </If>

For simplicity in this tutorial, we’ll show <FilesMatch> for multiple filenames.


3. Edit the .htaccess File

Locate your .htaccess (often in public_html/ or www/) and add or modify a block like:

<FilesMatch "^(wp-login\.php|admin|abc)$">
Order Deny,Allow
Deny from all
Allow from 1.2.3.4
</FilesMatch>

If you already have <Files wp-login.php> ... </Files> in your file, remove or replace that with <FilesMatch>. Make sure AllowOverride is enabled so that .htaccess rules take effect.


4. Modify the PHP Script

Your script might currently have two functions named something like:

  • parseWpLoginBlock($content)
  • updateWpLoginBlock($content, $newIPs)

Change these to handle <FilesMatch> instead of <Files>. For example:

function parseFilesMatchBlock($content) {
    $lines = explode("\n", $content);
    $startIndex = -1;
    $endIndex   = -1;
    $ips        = [];

    // Look for <FilesMatch "^(wp-login\.php|admin|abc)$"> and </FilesMatch>
    for ($i = 0; $i < count($lines); $i++) {
        $trimmed = trim($lines[$i]);
        if (stripos($trimmed, '<FilesMatch "') !== false 
            && stripos($trimmed, '^(wp-login') !== false) {
            $startIndex = $i;
        }
        if (stripos($trimmed, '</FilesMatch>') !== false && $startIndex !== -1) {
            $endIndex = $i;
            break;
        }
    }

    // If found, parse lines for "Allow from"
    if ($startIndex !== -1 && $endIndex !== -1) {
        for ($j = $startIndex; $j <= $endIndex; $j++) {
            if (stripos(trim($lines[$j]), 'Allow from') === 0) {
                $parts = preg_split('/\s+/', trim($lines[$j]));
                if (isset($parts[2])) {
                    $ips[] = $parts[2];
                }
            }
        }
    }

    return [
        'startIndex' => $startIndex,
        'endIndex'   => $endIndex,
        'ips'        => $ips
    ];
}
function updateFilesMatchBlock($content, $newIPs) {
    $lines = explode("\n", $content);
    $blockInfo = parseFilesMatchBlock($content);
    $startIndex = $blockInfo['startIndex'];
    $endIndex   = $blockInfo['endIndex'];

    if ($startIndex === -1 || $endIndex === -1) {
        return $content; // No matching block found, do nothing
    }

    $rebuiltBlock = [];
    for ($i = $startIndex; $i <= $endIndex; $i++) {
        $trimmed = trim($lines[$i]);
        // Skip old "Allow from" lines
        if (stripos($trimmed, 'Allow from') === 0) {
            continue;
        }
        $rebuiltBlock[] = $lines[$i];

        // Right after "Deny from all", re-inject new "Allow from" lines
        if (stripos($trimmed, 'Deny from all') === 0) {
            foreach ($newIPs as $ip) {
                $rebuiltBlock[] = "Allow from " . $ip;
            }
        }
    }

    // Replace that block segment in the array
    $blockLength = ($endIndex - $startIndex) + 1;
    array_splice($lines, $startIndex, $blockLength, $rebuiltBlock);

    return implode("\n", $lines);
}

Anywhere in your script that calls the old functions (parseWpLoginBlock, updateWpLoginBlock) should be updated to call parseFilesMatchBlock and updateFilesMatchBlock instead.


5. Testing

  1. Update the IP addresses via your PHP form or interface.
  2. Save or “Update IPs.”
  3. Check your .htaccess to confirm the lines under <FilesMatch "^(wp-login\.php|admin|abc)$"> are updated with the new IPs.
  4. Verify that an unauthorized IP cannot access those protected filenames.

6. Troubleshooting Tips

  • 500 Internal Server Error: Make sure your syntax is correct for your Apache version. On Apache 2.4, you might need to use Require all denied and Require ip <IP> instead of Order Deny,Allow.
  • .htaccess not applied: Confirm AllowOverride All is enabled in your Apache config for that directory.
  • Regex: Ensure your <FilesMatch> pattern exactly matches the filenames you want to protect. For example, admin is not the same as admin.php.

Full Example Script

Below is a complete revised script that demonstrates how to adapt your original code to manage IPs for <FilesMatch "^(wp-login\.php|admin|abc)$">. Update the $htaccessPath and $STATIC_PASSWORD_HASH to match your environment.

<?php
session_start();

/**************************************************************
 *                   CONFIGURATION
 **************************************************************/
// Store the hashed password here (Replace this with your own!)
$STATIC_PASSWORD_HASH = '$2y$10$EXAMPLE_ONLY_ReplaceWithRealHash';

// Path to the .htaccess file in your public_html folder
$htaccessPath = '/home/username/public_html/.htaccess';  // <-- Adjust this path

/**************************************************************
 *             SIMPLE PASSWORD PROTECTION CHECK
 **************************************************************/
if (!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) {
    if (isset($_POST['password'])) {
        // Use password_verify() instead of direct comparison
        if (password_verify($_POST['password'], $STATIC_PASSWORD_HASH)) {
            $_SESSION['authenticated'] = true;
            header("Location: " . $_SERVER['PHP_SELF']);
            exit;
        } else {
            $loginError = "Invalid password.";
        }
    }

    // Display a simple login form (Bootstrap 5)
    ?>
    <!DOCTYPE html>
    <html lang="en">
    <head>
      <meta charset="UTF-8" />
      <title>Login Required</title>
      <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
    </head>
    <body class="bg-light">
      <div class="container py-5">
        <div class="row justify-content-center">
          <div class="col-12 col-md-6 col-lg-4">
            <div class="card">
              <div class="card-body">
                <h1 class="h5 text-center mb-4">Protected Access</h1>
                <?php if (!empty($loginError)): ?>
                  <div class="alert alert-danger"><?php echo htmlspecialchars($loginError); ?></div>
                <?php endif; ?>
                <form method="post">
                  <div class="mb-3">
                    <label for="password" class="form-label">Password</label>
                    <input type="password" name="password" id="password" class="form-control" required>
                  </div>
                  <button type="submit" class="btn btn-primary w-100">Login</button>
                </form>
              </div>
            </div>  
          </div>
        </div>
      </div>
    </body>
    </html>
    <?php
    exit;
}

/**************************************************************
 *  HELPER FUNCTIONS TO READ & UPDATE THE <FilesMatch> BLOCK
 **************************************************************/

/**
 * Extract IP lines from the <FilesMatch "^(wp-login\.php|admin|abc)$"> block.
 *
 * @param string $content The entire .htaccess content
 * @return array {
 *    'startIndex' => int,
 *    'endIndex'   => int,
 *    'ips'        => string[]
 * }
 */
function parseFilesMatchBlock($content) {
    $lines = explode("\n", $content);
    $startIndex = -1;
    $endIndex   = -1;
    $ips        = [];

    // Identify <FilesMatch> start and </FilesMatch> end
    for ($i = 0; $i < count($lines); $i++) {
        $trimmed = trim($lines[$i]);
        // Look for <FilesMatch "^(wp-login\.php|admin|abc)$">
        if (stripos($trimmed, '<FilesMatch "') !== false 
            && stripos($trimmed, '^(wp-login') !== false) {
            $startIndex = $i;
        }
        // Look for </FilesMatch>
        if (stripos($trimmed, '</FilesMatch>') !== false && $startIndex !== -1) {
            $endIndex = $i;
            break;
        }
    }

    // Parse lines between startIndex and endIndex for "Allow from"
    if ($startIndex !== -1 && $endIndex !== -1) {
        for ($j = $startIndex; $j <= $endIndex; $j++) {
            if (stripos(trim($lines[$j]), 'Allow from') === 0) {
                $parts = preg_split('/\s+/', trim($lines[$j]));
                if (isset($parts[2])) {
                    $ips[] = $parts[2];
                }
            }
        }
    }

    return [
        'startIndex' => $startIndex,
        'endIndex'   => $endIndex,
        'ips'        => $ips
    ];
}

/**
 * Replace the IPs in the <FilesMatch> block with new IPs.
 *
 * @param string $content The entire .htaccess content
 * @param array  $newIPs  Array of new IP strings
 * @return string Updated .htaccess content
 */
function updateFilesMatchBlock($content, $newIPs) {
    $lines = explode("\n", $content);
    $blockInfo = parseFilesMatchBlock($content);
    $startIndex = $blockInfo['startIndex'];
    $endIndex   = $blockInfo['endIndex'];

    // If we cannot find the block, do nothing
    if ($startIndex === -1 || $endIndex === -1) {
        return $content;
    }

    // Rebuild the block lines
    $rebuiltBlock = [];
    for ($i = $startIndex; $i <= $endIndex; $i++) {
        $trimmed = trim($lines[$i]);

        // Skip existing "Allow from" lines (we'll re-inject new ones)
        if (stripos($trimmed, 'Allow from') === 0) {
            continue;
        }
        $rebuiltBlock[] = $lines[$i];

        // Right after "Deny from all", insert new "Allow from" lines
        if (stripos($trimmed, 'Deny from all') === 0) {
            foreach ($newIPs as $ip) {
                $rebuiltBlock[] = "Allow from " . $ip;
            }
        }
    }

    // Replace old lines in $lines with the rebuilt block
    $blockLength = ($endIndex - $startIndex) + 1;
    array_splice($lines, $startIndex, $blockLength, $rebuiltBlock);

    return implode("\n", $lines);
}

/**************************************************************
 *    PROCESS FORM SUBMISSION (SAVE NEW IP ADDRESSES)
 **************************************************************/
if (isset($_POST['save_ips']) && isset($_POST['ips'])) {
    // Clean the posted IPs, remove empty lines, etc.
    $newIPs = array_map('trim', $_POST['ips']);
    $newIPs = array_filter($newIPs, fn($ip) => $ip !== ''); // Remove empty

    // Read .htaccess content
    if (!file_exists($htaccessPath)) {
        die("Error: .htaccess not found at $htaccessPath");
    }
    $htaccessContent = file_get_contents($htaccessPath);

    // Update block
    $updatedContent = updateFilesMatchBlock($htaccessContent, $newIPs);

    // Save changes
    file_put_contents($htaccessPath, $updatedContent);

    // Redirect or confirm success
    header("Location: " . $_SERVER['PHP_SELF'] . "?updated=1");
    exit;
}

/**************************************************************
 *        READ CURRENT IPs AND DISPLAY HTML FORM
 **************************************************************/
$htaccessContent = file_get_contents($htaccessPath);
$blockInfo = parseFilesMatchBlock($htaccessContent);
$currentIPs = $blockInfo['ips'];

?>
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>Manage Protected Paths IPs</title>
  <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
</head>
<body class="bg-light">
<div class="container py-4">
  <h1 class="mb-4">Manage Allowed IPs for <code>FilesMatch</code> <small>(wp-login.php, admin, abc)</small></h1>

  <?php if (isset($_GET['updated'])): ?>
    <div class="alert alert-success">IP addresses have been updated successfully!</div>
  <?php endif; ?>

  <form method="post">
    <input type="hidden" name="save_ips" value="1" />

    <table class="table table-bordered bg-white">
      <thead>
        <tr>
          <th>#</th>
          <th>IP Address</th>
        </tr>
      </thead>
      <tbody>
      <?php 
        if (!empty($currentIPs)) {
          foreach ($currentIPs as $index => $ip) {
      ?>
              <tr>
                <td><?php echo ($index + 1); ?></td>
                <td>
                  <input type="text" name="ips[]" value="<?php echo htmlspecialchars($ip); ?>" class="form-control">
                </td>
              </tr>
      <?php
          }
        } else {
          // If no IPs exist, show one empty row
      ?>
          <tr>
            <td>1</td>
            <td>
              <input type="text" name="ips[]" value="" class="form-control" placeholder="e.g. 1.2.3.4">
            </td>
          </tr>
      <?php
        }
      ?>
      </tbody>
    </table>

    <div class="mb-3">
      <button type="button" class="btn btn-secondary" onclick="addRow()">+ Add another IP</button>
    </div>

    <button type="submit" class="btn btn-primary">Save IPs</button>
  </form>
</div>

<script>
function addRow() {
    const tableBody = document.querySelector('table tbody');
    const rowCount = tableBody.rows.length;
    const newRow = tableBody.insertRow();

    let cellIndex = newRow.insertCell(0);
    cellIndex.innerHTML = rowCount + 1;

    let cellIP = newRow.insertCell(1);
    cellIP.innerHTML = '<input type="text" name="ips[]" value="" class="form-control" placeholder="e.g. 1.2.3.4">';
}
</script>

</body>
</html>

Conclusion

By following these steps, you will:

  1. Add or update a <FilesMatch> block (or <If>/<Directory>, depending on your real needs) in .htaccess.
  2. Update your PHP script to parse and update <FilesMatch "^(wp-login\.php|admin|abc)$">.
  3. Manage which IPs can access those paths from a single PHP-based interface.

Copy/paste the above into your Grav site, and you’re set!