Here is some code I wrote to add, remove, and change user names and passwords in .htpasswd with PHP:

function adduser($user, $pass) {

    try {

        $htpasswd = '.htpasswd';

        //$hash = '{SHA}'.crypt($pass, base64_encode($pass));

        $hash = crypt_apr1_md5($pass); //APR1-MD5

        $contents = $user . ':' . $hash;

        $lines = explode(PHP_EOL, file_get_contents($htpasswd)); // get .htpasswd

        print('<h4>input:</h4><pre>'.print_r(implode(PHP_EOL, $lines),true).'</pre>');

        $exists = false;

        foreach($lines as $line){

            $existing_user = explode( ':', $line );

            if ($existing_user[0] == $user) { //checks if user exists

                $contents = str_replace($line, $contents, $lines); //changes password for user

                $contents = implode(PHP_EOL, $contents);

                $exists = true;

                if ($pass == '') { // removes user if password is empty

                    $contents = str_replace($line, '', $lines); //removes user

                    $contents = array_filter($contents); // cleans empty space in array

                    $contents = implode(PHP_EOL, $contents);

                    $exists = true;




        if ($exists == false) {

            $contents = implode(PHP_EOL, $lines) . PHP_EOL . $contents;

        file_put_contents($htpasswd, $contents);


    }catch(Exception $e) {

        echo '<h3>fail: </h3>' . $e->getMessage();



    adduser($_GET['user'], $_GET['pass']);

    echo '<h3>success</h3>';


    $htpasswd = '.htpasswd';

    $lines = explode(PHP_EOL, file_get_contents($htpasswd)); // get .htpasswd

    print('<h4>.htpasswd:</h4><pre>'.print_r(implode(PHP_EOL, $lines),true).'</pre>');

    echo '<h3>no user set</h3>';

I would love to streamline it and clean it up.


2 Answers 2


Here is my version based on two personal preferences: I hate scroll bars and I hate repititions. Hence I prefer everything typed once and also I like my code being fully visible in the default code area on Stack Overflow.

  • for this reason I removed that double spacing which just hurts my eyes
  • also I removed the try catch which is a cargo cult code that makes no sense. I was never able to understand what's the point in writing a try catch that's the only job is to echo the error message when without a try catch PHP will do exactly the same - echo the error message
  • also I removed the code repetitions such as mentioning the filename in a dozen places
  • also I changed the algorithm, to make it add a user line only once
  • I also removed that uncertainty when we can't make our mind whether we are working with an array or with a text
  • I also changed the function name as it doesn't only add a user
  • and some other improvements such as following the HTTP guidelines and the ability to choose the hashing algorithm without using inline comments

here it goes

$filename = ".htpasswd";
if (isset($_POST['user'])) {
    manage_htpasswd($_POST['user'], $_POST['pass'], $filename);
    header("Location: ".$_SERVER['REQUEST_URI']);
function manage_htpasswd($user, $pass, $filename, $algo = 'crypt_apr1_md5')
    $lines = file($filename);
    foreach ($lines as $i => $line) {
        $existing_user = explode(':', $line);
        if ($existing_user[0] === $user) {
    if ($pass) {
        $lines[] = "$user:" . $algo($pass) . PHP_EOL;
    file_put_contents($filename, $lines);
<form method="post">
    User: <input type="text" name="user"><br>
    Pass:<input type="text" name="pass"><br>
    <input type="submit">
<h4><?= $filename ?>:</h4>
<?= file_get_contents($filename) ?>
  • \$\begingroup\$ So instead of updating a row, you delete it then append it to the end of the file. Fair enough. \$\endgroup\$ Commented Nov 30, 2020 at 4:08
  • 1
    \$\begingroup\$ @mickmackusa yeah I was thinking how to get rid of that found/not found business :) \$\endgroup\$ Commented Nov 30, 2020 at 4:10
  • \$\begingroup\$ I think I like your non-regex versus my regex technique. Perhaps use strstr() with a true param instead of exploding to create $existing_user from an array (this way you don't even need to declare the variable). Your snippet is still making iterated function calls, but it does return early -- looks pretty clean to me and built with flexibility in mind. \$\endgroup\$ Commented Nov 30, 2020 at 4:15
  • 1
    \$\begingroup\$ @tony wait. indeed it does that. let me check \$\endgroup\$ Commented Dec 3, 2020 at 6:31
  • 1
    \$\begingroup\$ @tony I beg my pardon. yes, my bad. there must be a PHP_EOL. So, when we are moving from a text to an array, every line in this array holds a new line symbol. when file_put_contents converts an array back to text, it just glues all the lines together. if a line holds a new line symbol, then the next one occurs on the new line. But once the line doesn't have a new line at the end, then the next line is glued to it. So every line should have a new line at the end. Means we have to add it to the new line. \$\endgroup\$ Commented Dec 3, 2020 at 6:37

For the sake of your code and mine, I hope that the usernames cannot contain any colons since that is the delimiting character between usernames and passwords!

I have a bias toward regex because I have a fair handle on it and I enjoy the utility and brevity that it affords my scripts. I also don't (personally) enjoy all of the imploding and exploding going on in your script.

The search pattern is the same for cases of deleting and updating -- only the replacement text is changed. My search pattern will look for an optional leading newline character/sequence with \R, then search for an identical match of the username followed by a colon, then match the remainder of the line of text. This line-consuming pattern means that if replacing with an empty string, then there will be no blank line in the file; alternatively, if updating, then a leading EOL character/sequence will be prepended (don't worry, I ltrim() later).

The single preg_replace() call will record the number of replacements that it makes. The number will be either 0 or 1 since the fourth parameter limits the replacements to 1 anyhow. If there were no replacements made, then logically we know that a new line is to be appended to the end of the file.

At the end of the custom function, I am going the extra step of returning the action that was successfully undertaken. The will give better information in the output.

isset() can receive multiple arguments, so I added the pass element as well since it is expected with the submission.

I am using printf() to output the mix of literal and dynamic text -- I find that it helps to make the code more readable.

Untested Code:

function editHtpasswordRow(string $user, string $pass): string
    $file = '.htpasswd';
    if ($pass === '') {
        $newRow = '';
        $action = 'Delete';
    } else {
        $newRow = PHP_EOL . $user . ':' . crypt_apr1_md5($pass);
        $action = 'Update';

    $content = preg_replace(
        '/\R?^' . preg_quote($user, '/') . ':.*/mu',

    if (!$count && $newRow) {
        $content .= PHP_EOL . $newRow;
        $action = 'Insert';
    file_put_contents($file, ltrim($content));
    return $action;

if (isset($_POST['user'], $_POST['pass'])) {
        '<h3>%s of %s was successful</h3>',
        editHtpasswordRow($_POST['user'], $_POST['pass']), 
} else {
    echo '<h4>Fetched .htpasswd content:</h4><pre>' . file_get_contents('.htpasswd') . '</pre>';

Edit: I missed something that YourCommonSense spotted... You should be using $_POST when writing to the filesystem. $_GET is for reading and $_POST is for writing. I'll update my snippet now, +1 his post, and advise you to use his html form.


Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.