How to protect or unprotect specific routes of your application using HTTP auth with NGINX


I said routes, not directories!

August 2019.
ImageImage

Situation


You have a nice web application (let's say a PHP one), and you'd like to protect specific routes of it using HTTP authentification to prevent people from hacking you and stealing your secrets.

Here is your base nginx configuration:


server {
listen 80 default_server;
server_name _;
index index.php index.html;
root /var/www/example.com/;

location / {
try_files $uri /index.php$is_args$args;
}

location ~ \.php$ {
fastcgi_pass 127.0.0.1:9000;
fastcgi_index index.php;
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
include fastcgi_params;
}
}

So basically any route not matching any static file will end up using index.php. That file boots up your application framework and dispatch the request to the right controller blablabla. You know how it works.

Let's use the most classical situation: you have a bunch of admin routes under the /admin prefix that you want to HTTP auth protect.

What the internet tells you


The naive solution


Every stackoverflow answer will tell you to use location blocks to achieve this.

Example:


# Don't do this!
server {
[...]

location / {
try_files $uri /index.php$is_args$args;

location /admin {
try_files $uri /admin/index.php$is_args$args;
auth_basic "Admin Login";
auth_basic_user_file /var/www/example.com.htpasswd;
}
}

location ~ \.php$ {
fastcgi_pass 127.0.0.1:9000;
fastcgi_index index.php;
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
include fastcgi_params;
}
}

This solution is very tempting

Let's query the front route:

# curl 'https://example.com/'
This is the index page.

Ok.

Let's query the admin route:


# curl 'https://example.com/admin/route-listing-sensitive-information'
<html>
<head><title>401 Authorization Required</title></head>
<body>
<center><h1>401 Authorization Required</h1></center>
<hr><center>nginx/1.17.1</center>
</body>
</html>

So far, so good.

Let's query the PHP file directly:


# curl 'https://example.com/admin/index.php'
This is secret information!

Huh? Why don't I get another HTTP 401 response?

It's time to learn a bit more about how nginx process a request. Thankfully, the documentation explains it: NGINX documentation -> How nginx processes a request.

What's happening here is that the request path matches location /, then sub-location /admin. The HTTP basic auth configuration is then used. Then, nginx sees statement try_files and executes an internal redirect to /admin/index.php. This redirect is not matched by the previous locations blocks but directly by the one handling PHP files location ~ \.php$, and this block doesn't have any auth_basic directive; hence the file is 100% accessible.

The correct solution for that specific case



Some other stackoverflow answers will tell you to use the following correct configuration:


server {
[...]

location / {
try_files $uri /index.php$is_args$args;

location /admin {
try_files $uri /admin/index.php$is_args$args;
auth_basic "Admin Login";
auth_basic_user_file /var/www/example.com.htpasswd;
location ~ \.php$ {
fastcgi_pass 127.0.0.1:9000;
fastcgi_index index.php;
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
include fastcgi_params;
}
}

location ~ \.php$ {
fastcgi_pass 127.0.0.1:9000;
fastcgi_index index.php;
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
include fastcgi_params;
}
}
}

Let's query the front route:

# curl 'https://example.com/'
This is the index page.

Ok.

Let's query the admin route:


# curl 'https://example.com/admin/route-listing-sensitive-information'
<html>
<head><title>401 Authorization Required</title></head>
<body>
<center><h1>401 Authorization Required</h1></center>
<hr><center>nginx/1.17.1</center>
</body>
</html>

So far, so good.

Let's query the PHP file directly:


# curl 'https://example.com/admin/index.php'
<html>
<head><title>401 Authorization Required</title></head>
<body>
<center><h1>401 Authorization Required</h1></center>
<hr><center>nginx/1.17.1</center>
</body>
</html>

All good!

What you really need


Ok. The previous configuration worked well. However, it protects a directory and not a route/path.

What if my entire application is served with file /index.php and my /admin routes do not correspond to any directories in my file system? What if I want to secure a very specific URL (/something?id=42 for instance)?

This is impossible using location blocks as shown above. If you want to allow everyone on route A and protect route B and both requests are handled by the same PHP file, then you'll only have one of the two behaviors for both requests.

So what do we do? We use maps.


map $request_uri $example_com_auth_basic {
default "off";
"~/admin/" "Admin Login";
}

server {
[...]

location / {
try_files $uri /index.php$is_args$args;

auth_basic $example_com_auth_basic;
auth_basic_user_file /var/www/example.com.htpasswd;

location ~ \.php$ {
fastcgi_pass 127.0.0.1:9000;
fastcgi_index index.php;
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
include fastcgi_params;
}
}
}

Here we're declaring a map that maps the request URL to the auth_basic value we need. Then we use that variable in our server config.

If someone requests an admin route, then $example_com_auth_basic equals "Admin Login" and thus the protection is active.

If someone requests any other route, then $example_com_auth_basic equals "off" and thus the protection is inactive.

Done!

The configuration does not care how the requests is handled. Whether it's a static file, a directly matching PHP file or any number of internal redirection, the route is protected.

Let's query the front route:

# curl 'https://example.com/'
This is the index page.

Ok.

Let's query the admin route:


# curl 'https://example.com/admin/route-listing-sensitive-information'
<html>
<head><title>401 Authorization Required</title></head>
<body>
<center><h1>401 Authorization Required</h1></center>
<hr><center>nginx/1.17.1</center>
</body>
</html>