nginx: making basic auth useful

basic auth doesn’t get the love it deserves. sure, it’s not as fancy as other authentication models out there, it’s basic auth, after all. but if you want to stand up some fast authentication to limit your staging site to the dev team or restrict api access to a few users, basic auth is a good choice. best of all, we can implement the whole thing in nginx so we don’t have to modify our site or api’s authentication.

in this article, we’ll be going over setting up basic auth in nginx and getting it to do useful, practical things like restrict by specific url locations, set access by username, design a way to assign roles to users, and pass usernames into our site’s code.

basic auth is so adequate for fast ad-hoc authentication right now

the flyover

this article is divided into two main parts: creating and managing user accounts with htpasswd, and configuring nginx with basic auth to restrict or allow access.

creating accounts with htpasswd

basic auth stores user accounts in a flat file as a list of name/password pairs. login credentials need to be added to this file manually. we are going to do this using the htpasswd utility.

installing apache2-utils to get htpasswd

the htpasswd command is part of the apache2-utils package. since we’re using nginx, it may seem odd to need a tool from that other webserver, but nginx’s basic auth feature is designed to mimic apache’s older implementation. if we really want to, we can manually create a hashed password using openssl passwd -apr1 or similar and then hand edit our flat file, but why bother when htpasswd is quick, easy and free?

every major distro has an apache2-utils package. on ubuntu-like systems we can install with:

sudo apt install apache2-utils

if, for some reason, we need to build from source, we can do that from a docker file

creating a new .htpasswd file

once we have htpasswd installed, we can use it to create our flat file and add a user. the format for the command to do this is:

sudo htpasswd -c </path/to/.htpasswd> <username>

here, we run htpasswd as root and pass it the -c switch to ‘create’ a new password file. the arguments are the full path to where we want our file to live and the username of the account we are creating.

this command is interactive: htpasswd will prompt us for the password we want to assign to our user. for example, to create an account for the user ‘molly’:

sudo htpasswd -c /etc/apache2/.htpasswd molly
New password: *******
Re-type new password: *******
Adding password for user molly

the traditional location for our flat file of user accounts is:

/etc/apache2/.htpasswd

we can put it anywhere we want, but we probably shouldn’t mess with tradition.

if we look at our newly-created .htpasswd file, we can see the account:

$ cat /etc/apache2/.htpasswd
molly:$apr1$LONuzAbJ$zZFjMjRDM6v3FkolReLfq0

each account lives on one line and consists of two fields: the username, in plaintext, and the hashed password. the fields are separated by a colon.

if we look at the password hash, we see that it starts with $apr1$. this indicates which hashing algorithm was used. in this case ‘apr1’, which is an apache-specific algorithm that uses md5. there are other, better, hashing choices we can use. we’ll go over that later.

adding accounts to htpasswd

the -c switch creates a new flat file. if you already have a file and run htpasswd -c it will delete your file and replace it. not good.

to add new accounts to the password file, we simply omit the -c switch.

sudo htpasswd </path/to/.htpasswd> <username>

note that if we want to create a user with a username that contains a space, we need to enclose the username in quotes. for instance:

sudo htpasswd /etc/apache2/.htpasswd "allison wolfe"

adding htpasswd accounts non-interactively

by default, htpasswd prompts us for a password. this is both boring and difficult to script.

if we want to add an account as one command, with the password as an argument, we can use the -b switch.

sudo htpasswd -b </path/to/.htpasswd> <username> <password>

deleting htpasswd accounts

if we want to delete an account, one option is to manually edit our .htpasswd file. the other option is to run htpasswd with the -D switch:

sudo htpasswd -D </path/to/.htpasswd> <username>

choosing a better hashing algorithm

the default hashing algorithm that htpasswd uses is ‘apr1’. this is an apache-specific implementation of md5 that does an iteration of hashes using a series of random salts. is it better than raw md5? yes. but that doesn’t mean it’s a good or secure choice.

you’re not using md5 to hash passwords, right?

a better option is bcrypt, which we can use by passing the -B switch:

sudo htpasswd -bB </path/to/.htpasswd> <username> <password>

if we want to make our bcrypt password even harder to brute-force, we can also add the -C argument to set the amount of computing time required to create the hash. the longer the computing time, the longer it takes an attacker to try every possible password.

sudo htpasswd -bB -C <time> </path/to/.htpasswd> <username> <password>

the -C argument takes an integer value for time. the minimum is 4, the default value is 5, and the maximum is 17.

if we use the maximum -C value, htpassword will take several seconds to run. for instance, this command took over twelve seconds on my machine:

sudo htpasswd -b -B -C 17 /etc/apache2/.htpasswd allison mysecretpassword

note: the password hash needs to be run every time a user authenticates. if we choose a long hashing time when we create a password it will affect user performance.

when we look at our htpasswd file after generating accounts with bcrypt hashing, we see that the algorithm field has changed:

molly:$2y$05$2k3HbYVvpiwW0.6D5zCFVOQK/RxjquivUGcr8uyC039e0qYaef1TC
allison:$2y$17$8ODsatSNUJImOsPMnSoe0uFMkpDBcnHatc9XXOY3/N857ZyVyYPmO

here, the algorithm is listed as ‘2y’, which is bcrypt. there is also a field for the computer time value: $05$ for molly and $17$ for allison.

configuring nginx with basic auth

once we have an .htpasswd flat file, we can use it to require authentication to access our nginx-served site. we do this by modifying nginx’s configuration files.

at it’s core, basic auth is enabled by adding two lines:

auth_basic           "<login message>";
auth_basic_user_file /path/to/.htpasswd;

the first line here, basic_auth, sets the message that is shown in the login form. the second line, basic_auth_user_file sets the path to our .htpassword file.

protecting the entire site

to protect our entire site, we add our basic auth directives to the server block of nginx configuration:

server {

    server_name example.ca;
    root "/var/www/html/example/";

    index index.html index.htm index.php;

    charset utf-8;

    # basic auth configuratoin
    auth_basic           "Login required";
    auth_basic_user_file /etc/apache2/.htpasswd;

    location /private/ {
        try_files $uri $uri/ =404;
    }

    location /public/ {
        try_files $uri $uri/ =404;
    }

    access_log /var/log/nginx/access.log combined;
    error_log  /var/log/nginx/error.log error;

    listen 80;
}

with this config, both the private and public locations, as well as any other uri, requires a successful login to access.

excluding locations from basic auth

if we want to exclude a location or locations from requiring authentication, we can use the directive

auth_basic off;

at the location level.

here, we’ve turned off basic auth for the public location.

server {

    server_name example.ca;
    root "/var/www/html/example/";

    index index.html index.htm index.php;

    charset utf-8;

    # basic auth configuratoin
    auth_basic           "Login required";
    auth_basic_user_file /etc/apache2/.htpasswd;

    # authentication required
    location /private/ {
        try_files $uri $uri/ =404;
    }

    # no authentication required
    location /public/ {
        # turn off auth basic for this location
        auth_basic off;
        
        try_files $uri $uri/ =404;
    }

    access_log /var/log/nginx/access.log combined;
    error_log  /var/log/nginx/error.log error;

    listen 80;
}

all other locations require login.

requiring auth for only certain locations

if we configure basic auth at the location level, instead of the server level, authentication will only be required for the location.

here, we have set auth for the private and alsoprivate locations but not for public.

server {

    server_name example.ca;
    root "/var/www/html/example/";

    index index.html index.htm index.php;

    charset utf-8;

    # authentication required
    location /private/ {
        # basic auth configuratoin
        auth_basic           "Login required";
        auth_basic_user_file /etc/apache2/.htpasswd;
    
        try_files $uri $uri/ =404;
    }

    # authentication required
    location /alsoprivate/ {
        # basic auth configuratoin
        auth_basic           "Login required";
        auth_basic_user_file /etc/apache2/.htpasswd;
        
        try_files $uri $uri/ =404;
    }

    # no authentication required
    location /public/ {
        try_files $uri $uri/ =404;
    }

    access_log /var/log/nginx/access.log combined;
    error_log  /var/log/nginx/error.log error;

    listen 80;
}

adding further restrictions by username

normally, basic auth is a pretty blunt instrument: you’re either authenticated and allowed to make a request or you aren’t.

if we want more fine-grained control, we can limit access to specific users by their username by using the $remote_user variable.

$remote_user is a built-in variable that contains the username of the user. we can combine that with an if statement in a location block to fine tune our permissions. in this example, we have three locations:
  1. public: no authentication.
  2. private: any authenticated user who is not molly
  3. molly: only molly

let’s look:

server {

    server_name example.ca;
    root "/var/www/html/example/";

    index index.html index.htm index.php;

    charset utf-8;
    
    auth_basic           "Login required";
    auth_basic_user_file /etc/apache2/.htpasswd;

    # all authenticated users except molly permitted
    location /private/ {

        # check remote user and deny molly access
        if ($remote_user = 'molly') {
            add_header Content-Type text/html;
            return 403 '<html><body>Molly is not allowed</body></html>';
        }
        try_files $uri $uri/ =404;
    }

    # only molly permitted
    location  /molly/ {

        # check remote user and allow only molly
        if ($remote_user != 'molly') {
            add_header Content-Type text/html;
            return 403 '<html><body>Only Molly is allowed</body></html>';
        }
        try_files $uri $uri/ =404;
    }

    # no authentication required
    location /public/ {
        # turn off auth basic for this location
        auth_basic off;
        
        try_files $uri $uri/ =404;
    }

    access_log /var/log/nginx/access.log combined;
    error_log  /var/log/nginx/error.log error;

    listen 80;
}

here, in the private location, we test the value of $remote_user using a pretty basic if statement. if it is ‘molly’, we return a 403 error code and some html. all other authenticated users are permitted access:

if ($remote_user = 'molly') {
    add_header Content-Type text/html;
    return 403 '<html><body>Molly is not allowed</body></html>';
}

likewise, in the molly location, we can test for the ‘not molly’ condition:

if ($remote_user != 'molly') {
    add_header Content-Type text/html;
    return 403 '<html><body>Only Molly is allowed</body></html>';
}

the add_header and return directives here are how we output html from nginx. if we decide (wisely) that hardcoding content in our nginx config file is a bad idea, we can return content from a file using the rewrite directive like so:

if ($remote_user = "molly") {
    rewrite ^ /path/to/molly_not_allowed.html break;
}

restricting by user role

handling permissions by user name is useful stuff, but it can get clumsy and difficult to maintain pretty fast. it would be much more convenient if we could assign ‘roles’ to our users and then handle access by that.

doing this is a two-step process:

  1. assigning users to roles using the map directive
  2. testing the role

the ‘assigning users to roles’ is the new material here, so we’ll look at that first. let’s look at an example:

# map user names to roles
map $remote_user $role {
  molly     "dev";
  allison   "dev";
  erin      "client";
}

here, we use nginx‘s map directive to map a username to a role and assign that role to a variable. if you read the article on restricting nginx by ip address this should be familiar. if not, we’ll go over it:

the map function takes three arguments:

  1. the value to test: this is $remote_user, the internal variable we’ve used before to get the username of the authenticated user. this is the value that we will be testing in the list of test cases below.
  2. the variable to set: the variable that will hold the result of our mapping procedure. in this case it is $role
  3. the list of test cases: a list of tuples, essentially. if the left value of a tuple matches the value we are testing ($remote_user) then the right value is assigned to the variable we want to set ($role)

knowing this, we can see that if the value of $remote_user is ‘molly’, then our map call will assign the value ‘dev’ to the variable $role. we can then use $role in an if statement in our location blocks.

let’s look at a full configuration file:

# map user names to roles
map $remote_user $role {
  molly     "dev";
  allison   "dev";
  erin      "client";
}

server {

    server_name example3.ca;
    root "/var/www/html/example3/";

    index index.html index.htm index.php;

    charset utf-8;
    
    auth_basic           "Login required";
    auth_basic_user_file /etc/apache2/.htpasswd;

    # role 'dev' only
    location /devonly/ {
        if ($role != 'dev') {
            add_header Content-Type text/html;
            return 403 '<html><body>Development only</body></html>';
        }

        try_files $uri $uri/ =404;
    }

    # role 'client' only
    location /client/ {
        if ($role != 'client') {
            add_header Content-Type text/html;
            return 403 '<html><body>Client only</body></html>';
        }

        try_files $uri $uri/ =404;
    }

    # authentication required
    location /staging/ {
        try_files $uri $uri/ =404;
    }

    # no authentication required
    location /public/ {
        # turn off auth basic for this location
        auth_basic off;
        
        try_files $uri $uri/ =404;
    }


    access_log /var/log/nginx/access.log combined;
    error_log  /var/log/nginx/error.log error;

    listen 80;
}

here, we see that in the devonly location we test the value of $role and if it is not dev, we deny access.

handling access by user role this way does require a lot of direct intervention on our behalf. we need to manually edit our nginx config file and restart the daemon every time we want to make a change. that’s not the most convenient thing in the world, but if we have a small number of users and don’t want to fiddle with the authentication model our site or api is using, it can be effective.

getting the username into our code

one of the great things about basic auth is that you can add it on top of your site or api’s already-existing authentication structure. you can keep your site or api’s login model the way it is; your code never even needs to know that basic auth is being used.

but sometimes that might be more of a bug than a feature. maybe we want our app to know the basic auth username of our visitors.

we can do this using ‘fastcgi params’.

the fastcgi_param directive is a way to assign a value to a variable in our nginx configuration and then pass that variable to the fastcgi server, allowing our code to read it. if we’re using php, the value is accessible in the $_SERVER array.

fastcgi_param takes two arguments: first, the name of the fastcgi parameter we want to declare and, second, the value we want to give it. to assign $remote_user to the fastcgi parameter REMOTE_USER we would do this:

fastcgi_param REMOTE_USER $remote_user;

once we have assigned that parameter, we can read it from the $_SERVER array like so:

$_SERVER['REMOTE_USER']

let’s look at an example configuration for this:

server {

    server_name example.ca;
    root "/var/www/html/example/";

    index index.html index.htm index.php;

    charset utf-8;
    
    auth_basic           "Login required";
    auth_basic_user_file /etc/apache2/.htpasswd;

    location ~ \.php$ {
        fastcgi_split_path_info ^(.+\.php)(/.+)$;
        fastcgi_pass unix:/var/run/php/php8.3-fpm.sock;
        fastcgi_index index.php;
        include fastcgi_params;

        # set $remote_user in $_SERVER['REMOTE_USER']
        fastcgi_param REMOTE_USER $remote_user;

        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
        fastcgi_intercept_errors off;
        fastcgi_buffer_size 16k;
        fastcgi_buffers 4 16k;
        fastcgi_connect_timeout 300;
        fastcgi_send_timeout 300;
        fastcgi_read_timeout 300;
    }

    access_log /var/log/nginx/access.log combined;
    error_log  /var/log/nginx/error.log error;

    listen 80;
}

if we look inside the location ~ \.php$ block we can see the fastcgi parameter assignment.

conclusion

basic auth isn’t a replacement for a real and robust login model and if you try to use it like one, you will be disappointed. however, if you need to put together a fast or ad hoc authorization system or need to add some ‘extra’ auth to restrict some or all of your site for internal users, it is a powerful tool. if you even casually dabble in ops, having a strong understanding of basic auth is a good thing.

Posted by: grant horwood

co-founder of fruitbat studios. cli-first linux snob, metric evangelist, unrepentant longhair. all the music i like is objectively horrible. he/him.

Leave a Reply