Protecting web sites with NGINX subrequest authentication

2020-06-30

TL;DR

Protecting a web site with NGINX by using authentication server via a subrequest.

  • Use auth_request /auth in NGINX conf.
  • When user requests protected area, NGINX makes an internal request to /auth. If 200 is returned, protected contents are served. Anything else, NGINX responds with 401.
  • /auth is reverse proxied to Express app auth-server which handles authentication. Cookies are passed on as well, so the auth server can check for a JWT.
  • Auth server sets httpOnly cookie containing a JWT.
  • JWT updated with new expiry each time a user visits protected area.
  • Per-IP rate limiting on the login endpoint and global rate limiting on all routes.
  • Supports multi-tenant/realm authentication via X-Auth-Realm header.
  • Optional custom authentication logic via auth.js module.
  • Optional username-based login in addition to password-only mode.

Details

For this server block, we want to protect the entire site, except the authentication areas. We can use a NGINX conf file such as like this:

# optional:
# internal redirect to /login if there is a auth failure
# delete or comment this out if you don't want this behaviour and just show a generic 401 error
error_page 401 /login;

location / {
    auth_request /auth;

    # pass Set-Cookie headers from the subrequest response back to requestor
    auth_request_set $auth_cookie $upstream_http_set_cookie;
    add_header Set-Cookie $auth_cookie;

    auth_request_set $auth_status $upstream_status;

    try_files $uri $uri/ /index.html;
}

location = /auth {
    # internaly only, /auth can not be accessed from outside
    internal;

    # internal proxy to auth-server running on port 3000, responses expected from proxy:
    #   2xx response = access allowed via auth_request
    #   401 or 403 response = access denied via auth_request
    #   anything else = error
    proxy_pass http://localhost:3000;

    # don't pass request body to proxied server, we only need the headers which are passed on by default
    proxy_pass_request_body off;

    # there is no content length since we stripped the request body
    proxy_set_header Content-Length "";

    # let proxy server know more details of request
    proxy_set_header X-Original-URI $request_uri;
    proxy_set_header X-Original-Remote-Addr $remote_addr;
    proxy_set_header X-Original-Host $host;

    # optional realm header, allows you to use the same auth server for multiple sites
    # proxy_set_header X-Auth-Realm "myrealm";
}

# these are handled by the proxy as part of the auth routines
location ~ ^/(login|logged-in|logout)$ {
    proxy_pass http://localhost:3000;
    proxy_set_header X-Original-URI $request_uri;
    proxy_set_header X-Original-Remote-Addr $remote_addr;
    proxy_set_header X-Original-Host $host;

    # optional realm header, allows you to use the same auth server for multiple sites
    # proxy_set_header X-Auth-Realm "myrealm";
}

# static assets used by the auth server login/logged-in pages
location ~* ^/(auth_style\.css|auth_padlock\.svg)$ {
    proxy_pass http://localhost:3000;
}

# optional location block
# if you have other location blocks, be sure to add auth_request there too otherwise these requests won't get protected, for example
location ~* \.(js|css|png|jpg|jpeg|gif|ico)$ {
    expires 90d;
    log_not_found off;
    auth_request /auth;
}

Note: The static assets have changed since the original version. The auth server now serves auth_style.css and auth_padlock.svg (a padlock SVG icon) instead of css/skeleton.css.

Using directive auth_request /auth

We are protecting /. For each request to /* except for regex pattern ^/(auth|login|logged-in|logout)$ and the auth server's static assets, NGINX will send a GET request to /auth and listen to the response. This is done with the auth_request directive.

A 200 response from /auth is a successful authentication and the /* contents will be served as normal. Any other response from /auth is a failed authentication and the client will be served a 401 (unauthorised) response. We'll customise this 401 response later by serving a login interface.

Important: The original version of this article stated that /auth returned a 201 status code. The current auth-server returns 200 on success (res.sendStatus(200)). Either works with NGINX's auth_request directive — it treats any 2xx response as success.

From NGINX's documentation:

"NGINX and NGINX Plus can authenticate each request to your website with an external server or service. To perform authentication, NGINX makes an HTTP subrequest to an external server where the subrequest is verified. If the subrequest returns a 2xx response code, the access is allowed, if it returns 401 or 403, the access is denied. Such type of authentication allows implementing various authentication schemes, such as multifactor authentication, or allows implementing LDAP or OAuth authentication."

We use add_header Set-Cookie $auth_cookie so that any Set-Cookie header returned from the upstream auth server is forwarded back to the client. The auth server usually uses Set-Cookie to renew the JWT each time, so that any timeout is respected and calculated from the time of last access.

The headers from client-to-server is passed on to /auth as well, including any cookies. This is important, as a JWT is used to determine if the client is authenticated.

Send subrequests to Node-Express app auth-server

We run a Node-Express auth-server on http://localhost:3000. Then proxy all requests to /auth to app. This app will ignore any request body content when made to /auth, so we can use:

location = /auth {
    internal;
    proxy_pass http://localhost:3000;
    proxy_pass_request_body off;
    proxy_set_header Content-Length "";
    proxy_set_header X-Original-URI $request_uri;
    proxy_set_header X-Original-Remote-Addr $remote_addr;
    proxy_set_header X-Original-Host $host;

    # optional realm header, allows you to use the same auth server for multiple sites
    # proxy_set_header X-Auth-Realm "myrealm";
}

The last 3 directives here, add an extra 3 headers to the subrequest. The auth-server uses X-Original-Remote-Addr for proper client IP detection in rate limiting, and X-Original-URI is used for post-login redirect behaviour.

auth-server

We are running the open source auth-server (written by myself). In summary, it listens on port 3000 for the following requests:

  • GET /auth - requested by NGINX for subrequest authentication. Returns 200 if authenticated, 401 if not. Also refreshes the JWT cookie on each authenticated visit.
  • POST /login - XHR made from /login interface. Subject to per-IP rate limiting.
  • GET /login - login interface
  • GET /logged-in - tells user they are logged in, with link back to protected area and logout button
  • GET /logout - forces immediate logout via redirect
  • POST /logout - XHR logout endpoint (used by the logged-in page)

The following location block, will pass requests to those URIs to the auth-server at http://localhost:3000 with a reverse proxy.

location ~ ^/(login|logged-in|logout)$ {
    proxy_pass http://localhost:3000;
    proxy_set_header X-Original-URI $request_uri;
    proxy_set_header X-Original-Remote-Addr $remote_addr;
    proxy_set_header X-Original-Host $host;

    # optional realm header, allows you to use the same auth server for multiple sites
    # proxy_set_header X-Auth-Realm "myrealm";
}

New features in auth-server

Since the original article was published, the auth-server has gained several notable features:

Rate limiting

Two tiers of rate limiting are now built in:

  • Login rate limiter (per-IP): Limits attempts to the /login POST endpoint. Default: 20 requests per 15 minutes per IP. Configurable via AUTH_LOGIN_RATE_LIMIT_WINDOW_MIN and AUTH_LOGIN_RATE_LIMIT_MAX.
  • Global rate limiter: Applied to all routes. Default: 100 requests per 1 minute window. Configurable via AUTH_GLOBAL_RATE_LIMIT_WINDOW_MIN and AUTH_GLOBAL_RATE_LIMIT_MAX.

Both limiters use the client's real IP, respecting the X-Original-Remote-Addr header when the request originates from a trusted loopback proxy (e.g. a local NGINX instance).

Multi-realm / multi-tenant support

The X-Auth-Realm header allows you to use the same auth-server instance for multiple independent sites. When set in the NGINX proxy configuration:

proxy_set_header X-Auth-Realm "myrealm";

The following behaviour changes:

  • The cookie name is scoped per-realm, e.g. authToken_myrealm instead of authToken.
  • The JWT's audience claim is set to the realm name, and verified on each request.
  • Tokens from one realm are invalid for another realm — the server returns 403 if a realm mismatch is detected.

This is especially useful if you manage multiple subdomains or applications and want a single auth-server instance.

Custom authentication routine (auth.js)

By default, the server authenticates against a single password set in AUTH_PASSWORD. You can override this by placing an auth.js file in the app root that exports a function:

const checkAuth = (user, pass, realm) => {
  // query a database, LDAP, OAuth provider, etc.
  // return true/false
};

module.exports = checkAuth;

See auth.example.js in the repository for a template.

Optional username support

Set AUTH_USE_USERNAME=true in your .env to require both username and password on the login form. The username is then stored in the JWT payload and displayed on the logged-in page ("Hello <user>"). When disabled (the default), the login form only asks for a password and the JWT stores "user" as the username.

Configurable post-login redirect

Set AUTH_VISIT_LINK_URL to a fixed URL. When configured, users are immediately redirected there upon visiting /logged-in. When unset, the behaviour falls back to the original requesting URL from the X-Original-URI header, or shows the logged-in page.

Cookie overrides

AUTH_COOKIE_OVERRIDES accepts a JSON string that is merged into the cookie options. For example, setting a custom domain attribute:

AUTH_COOKIE_OVERRIDES={"domain":".example.com"}

HTTP status code correction

The auth endpoint (GET /auth) returns 200 (not 201 as this article originally stated) upon successful authentication. The response body is empty — res.sendStatus(200) is used.

NGINX default error response

401 (unauthorised) errors are handled by rendering to the user the /login page. We add this to the server block:

error_page 401 /login;

When a user is not authenticated and attempts to visit a protected area, it serves the /login interface. This is not an external redirect and the user's browser will still show the original target URL.

Logging out

To log out, the client needs to remove its cookie. Since it's an httpOnly cookie, the request to clear the cookies must come from a Set-Cookie response header with empty contents.

User authentication will also automatically time out from cookie expiry and JWT expiry time.

The auth-server supports both GET /logout (for redirect-based logout) and POST /logout (for XHR-based logout from the logged-in page). Both clear the cookie and disable caching on the response via:

Cache-Control: no-store, no-cache, must-revalidate, proxy-revalidate
Pragma: no-cache
Expires: 0
Surrogate-Control: no-store

Environment variables reference

VariableDefaultDescription
AUTH_PORT3000Listening port
AUTH_PASSWORDAuthentication password (required)
AUTH_TOKEN_SECRETJWT signing secret (required)
AUTH_EXPIRY_DAYS7JWT expiry in days
AUTH_COOKIE_NAMEauthTokenCookie name prefix (appended with _<realm> if realm is set)
AUTH_COOKIE_SECUREtrueSecure flag on auth cookie
AUTH_COOKIE_OVERRIDESJSON string merged into cookie options
AUTH_USE_USERNAMEfalseShow username field on login form
AUTH_VISIT_LINK_URLFixed redirect URL after login
AUTH_LOGIN_RATE_LIMIT_WINDOW_MIN15Rate limit window (minutes) for login attempts
AUTH_LOGIN_RATE_LIMIT_MAX20Max login attempts per IP per window
AUTH_GLOBAL_RATE_LIMIT_WINDOW_MIN1Global rate limit window (minutes)
AUTH_GLOBAL_RATE_LIMIT_MAX100Max total requests per global window

Links

Loading footer...