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.

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
- installing
apache2-utils
to gethtpasswd
- creating a new htpasswd file
- adding accounts to htpasswd
- adding htpasswd accounts non-interactively
- deleting htpasswd accounts
- choosing a better hashing algorithm
- configuring nginx with basic auth
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.

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
:public
: no authentication.private
: any authenticated user who is not mollymolly
: 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:
- assigning users to roles using the
map
directive - 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:
- 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. - the variable to set: the variable that will hold the result of our mapping procedure. in this case it is
$role
- 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.
co-founder of fruitbat studios. cli-first linux snob, metric evangelist, unrepentant longhair. all the music i like is objectively horrible. he/him.