Subtleties of configuring nginx’s auth_basic to “remember me”
Aug 3, 2021

A disclaimer upfront: The solution presented in this post uses a single hard-coded cookie for all users. This means, for one thing, you can’t tell users apart in your web app—which shouldn’t come as a surprise as this is based on the “Basic” authentication scheme. And for another, that you cannot invalidate sessions on a per-user basis.

The auth_basic directive is the simplest option to protect an nginx-hosted site with a password. Upon visiting the site, the user is asked for a username and password. The browser remembers the credentials and sends them along with each request until the session ends, that is, usually, when the browser is closed.

I’m protecting my personal wiki and the web interface of my spam filter with auth_basic. When accessing my wiki I want to reduce friction to a minimum, and because my password manager, pass with the BrowserPass web extension, doesn’t support filling in “Basic” authentication dialogs, I wanted to come up with a solution to stay logged in permanently.

The naive approach (don’t do this)

This approach uses a variable, $auth_basic, to turn off authentication in case the secret cookie is present. If the client doesn’t send along the cookie a password is requested, and upon successful authentication the cookie is sent to the client. I like to encapsulate functionality like this in a snippet that I include directly in the server block.

set $auth_basic "closed site";
if ($cookie_auth = "supersecret") {
    set $auth_basic off;
}

auth_basic $auth_basic;
auth_basic_user_file htpasswd;

add_header Set-Cookie "auth=supersecret;max-age=31536000;path=/";

This is secure so long as there are no return or rewrite directives in your config. If, for example, you’ve got this redirect in the same server block, this opens up your site to everyone!

location = / {
    return 302 /landingpage;
}

This is because nginx processes it’s directives in a specific order: first the return and rewrite directives, then authentication related directives, then the try_files directive, and finally the content is served, typically from the file system (source).

In our example the redirect is processed before the authentication. Therefore the client receives the redirect, including the cookie header which is inherited by the location block, without authenticating. A quick fix in this case would be to overwrite the header in the location block.

location = / {
    add_header Set-Cookie "";
    return 302 /landingpage;
}

But it’s not always that easy. For example, if the redirect is taking place inside an if block instead of a location block, you can’t overwrite the header in there. Regardless, I wanted a solution that I can just include into an existing server block without worrying too much.

The final solution

I’ll start by showing the snippet and explain it after.

error_page 418 = @auth;
if ($cookie_auth != "supersecret") {
    return 418;
}

location @auth {
    auth_basic "closed site";
    auth_basic_user_file htpasswd;
    try_files /dev/null @cookie;
}

location @cookie {
    add_header Set-Cookie "auth=supersecret;max-age=31536000;path=/";
    return 302 $request_uri;
}

The final solution uses two named locations, @auth and @cookie. Named locations can’t be reached by entering an URL into your browser; they can only be referred to from inside the nginx config. The error_page trick can be used to safely change location and is documented here. (BTW, the status code 418 I’m a teapot was defined in RFC 2324 as an April Fools’ joke. It’s a good choice here because it’s likely not used anywhere else.)

So if the secret cookie is not present, the request is delegated to the @auth named location where the authentication takes place. Using try_files the request is then further delegated to the @cookie named location: /dev/null can never be found so nginx falls back to the named location. Note that you can’t use the error_page trick here as the return directive would be processed before the authentication, bypassing it completely. But as try_files is processed after the authentication, as we’ve learned before, this works out. Likewise, you can’t combine the @auth and @cookie named locations into one for the same reason. Finally, in the @cookie named location, the cookie is set and the client is redirected to the current URI. As the cookie is now present, authentication is skipped.

Prize money

You’ll find a complete example site configuration to drop into sites-available at the end of the post. You can try it out, but beware that the example is not HTTPS, so the password and secret cookie you try this with will be sent unencrypted.

I offer a prize money of 100 € for finding a vulnerability in the configuration that let’s you bypass the authentication. Just send me an email decribing how you did it and I will update the post and send you the money.

server {
    listen 80;
    server_name example.com;

    error_page 418 = @auth;
    if ($cookie_auth != "supersecret") {
        return 418;
    }

    location @auth {
        auth_basic "closed site";
        auth_basic_user_file htpasswd;
        try_files /dev/null @cookie;
    }

    location @cookie {
        add_header Set-Cookie "auth=supersecret;max-age=31536000;path=/";
        return 302 $request_uri;
    }

    location = / {
        return 302 /landingpage;
    }

    root html;
}