Protecting web sites with NGINX subrequest authentication
TL;DR
Protecting a web site with NGINX by using authentication server via a subrequest.
- Use
auth_request /authin 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. /authis 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-Realmheader. - Optional custom authentication logic via
auth.jsmodule. - 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.cssandauth_padlock.svg(a padlock SVG icon) instead ofcss/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
/authreturned a 201 status code. The current auth-server returns 200 on success (res.sendStatus(200)). Either works with NGINX'sauth_requestdirective — 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/logininterface. Subject to per-IP rate limiting.GET /login- login interfaceGET /logged-in- tells user they are logged in, with link back to protected area and logout buttonGET /logout- forces immediate logout via redirectPOST /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
/loginPOST endpoint. Default: 20 requests per 15 minutes per IP. Configurable viaAUTH_LOGIN_RATE_LIMIT_WINDOW_MINandAUTH_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_MINandAUTH_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_myrealminstead ofauthToken. - The JWT's
audienceclaim 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
| Variable | Default | Description |
|---|---|---|
AUTH_PORT | 3000 | Listening port |
AUTH_PASSWORD | — | Authentication password (required) |
AUTH_TOKEN_SECRET | — | JWT signing secret (required) |
AUTH_EXPIRY_DAYS | 7 | JWT expiry in days |
AUTH_COOKIE_NAME | authToken | Cookie name prefix (appended with _<realm> if realm is set) |
AUTH_COOKIE_SECURE | true | Secure flag on auth cookie |
AUTH_COOKIE_OVERRIDES | — | JSON string merged into cookie options |
AUTH_USE_USERNAME | false | Show username field on login form |
AUTH_VISIT_LINK_URL | — | Fixed redirect URL after login |
AUTH_LOGIN_RATE_LIMIT_WINDOW_MIN | 15 | Rate limit window (minutes) for login attempts |
AUTH_LOGIN_RATE_LIMIT_MAX | 20 | Max login attempts per IP per window |
AUTH_GLOBAL_RATE_LIMIT_WINDOW_MIN | 1 | Global rate limit window (minutes) |
AUTH_GLOBAL_RATE_LIMIT_MAX | 100 | Max total requests per global window |