The Symptom: Your Site Still Uses That 1998 Browser Dialog?
A buddy asked me to help with his internal static site last month. It was running plain Nginx Basic Auth. Every time someone tried to log in, the browser threw up that hideous system-level dialog—no logo, no styling, no “remember me,” no SSO.
His exact words: “This thing looks like it’s from 1998.”
He wanted a real webform login page: input fields, submit button, proper error messages.
I thought: “Easy, just swap the auth method.”
I was wrong.
Root Cause: Why Nginx Basic Auth Is So Hard to Replace
Nginx’s ngx_http_auth_basic_module is hardcoded into the request processing pipeline. It intercepts every request before it reaches your application, returns a 401 Unauthorized, and forces the browser to show that system dialog.
Here’s the thing: this module processes authentication inside Nginx, completely bypassing your application layer. If you want a custom webform, you’re fighting Nginx’s request flow.
Three specific pain points:
- Nginx never forwards the request to your app: As long as
auth_basic "Restricted"is enabled, Nginx drops the request at the 401 stage. Your PHP/Python/Node.js app never even sees it. - The browser caches the auth state: Once a user authenticates via Basic Auth, the browser caches the
Authorizationheader. All subsequent requests automatically includeAuthorization: Basic xxxx. This means you cannot force a logout (short of closing the browser). - Webform credentials are POST data, while Basic Auth expects an
Authorizationheader. They’re fundamentally incompatible protocols.
The Fix: Three Approaches Compared
I tested three approaches. Here’s the hard data:
| Approach | Complexity | Security | UX | Maintainability | Compatibility |
|---|---|---|---|---|---|
| A: Nginx Reverse Proxy + External Auth Service | High | High | High | Medium | Good |
| B: nginx-auth-ldap module | Medium | Medium | Medium | Low | Poor |
| C: Fake webform in frontend + header conversion | Low | Low | Medium | High | Poor |
We went with Approach A. Why? Security isn’t negotiable, and neither is user experience.
Step-by-Step: Building a Webform Login with Nginx
Step 1: Disable Nginx Basic Auth
Comment out those auth_basic and auth_basic_user_file lines. Don’t delete them yet—you might need to roll back.
# Comment these out
# auth_basic "Restricted";
# auth_basic_user_file /etc/nginx/.htpasswd;
Step 2: Build a Standalone Auth Service
We used Python Flask. It’s been running in production for six months without a single issue.
from flask import Flask, request, jsonify, make_response
import base64
import hashlib
app = Flask(__name__)
# In production, use Redis or a database
USERS = {
"admin": hashlib.sha256("password123".encode()).hexdigest()
}
@app.route('/auth', methods=['POST'])
def authenticate():
data = request.json
username = data.get('username')
password = data.get('password')
if username in USERS and USERS[username] == hashlib.sha256(password.encode()).hexdigest():
token = base64.b64encode(f"{username}:{password}".encode()).decode()
response = make_response(jsonify({"status": "ok", "token": token}))
response.set_cookie('auth_token', token, httponly=True, secure=True, samesite='Strict')
return response
else:
return jsonify({"status": "error", "message": "Invalid credentials"}), 401
if __name__ == '__main__':
app.run(host='0.0.0.0', port=5000)
Step 3: Configure Nginx as a Reverse Proxy
The key insight: Nginx checks cookies, not Authorization headers.
server {
listen 443 ssl;
server_name yourdomain.com;
# Login page - no auth required
location /login {
proxy_pass http://127.0.0.1:5000/login;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
# Auth API endpoint
location /api/auth {
proxy_pass http://127.0.0.1:5000/auth;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
# Protected static assets
location / {
# Check for auth_token cookie
if ($cookie_auth_token = "") {
return 302 /login;
}
# Validate token via internal subrequest
auth_request /api/verify;
auth_request_set $auth_status $upstream_status;
root /var/www/html;
index index.html;
}
# Internal validation endpoint
location = /api/verify {
internal;
proxy_pass http://127.0.0.1:5000/verify;
proxy_pass_request_body off;
proxy_set_header Content-Length "";
proxy_set_header X-Original-URI $request_uri;
proxy_set_header X-Auth-Token $cookie_auth_token;
}
}
Step 4: Create the Webform Login Page
A clean HTML page at /var/www/html/login/index.html:
<!DOCTYPE html>
<html>
<head>
<title>Login</title>
<style>
body {
font-family: Arial, sans-serif;
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
background-color: #f5f5f5;
}
.login-box {
background: white;
padding: 40px;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
width: 300px;
}
input[type="text"], input[type="password"] {
width: 100%;
padding: 10px;
margin: 10px 0;
border: 1px solid #ddd;
border-radius: 4px;
}
button {
width: 100%;
padding: 10px;
background-color: #007bff;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
.error {
color: red;
margin-top: 10px;
}
</style>
</head>
<body>
<div class="login-box">
<h2>Login</h2>
<form id="loginForm">
<input type="text" id="username" placeholder="Username" required>
<input type="password" id="password" placeholder="Password" required>
<button type="submit">Login</button>
</form>
<div id="error" class="error" style="display:none;"></div>
</div>
<script>
document.getElementById('loginForm').addEventListener('submit', async function(e) {
e.preventDefault();
const username = document.getElementById('username').value;
const password = document.getElementById('password').value;
const response = await fetch('/api/auth', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({username, password})
});
if (response.ok) {
window.location.href = '/';
} else {
document.getElementById('error').style.display = 'block';
document.getElementById('error').textContent = 'Invalid username or password';
}
});
</script>
</body>
</html>
Step 5: Implement Logout
Basic Auth’s biggest weakness is the inability to log out. We solve it by clearing the cookie:
location /logout {
add_header Set-Cookie "auth_token=; Path=/; Max-Age=0";
return 302 /login;
}
When a user hits /logout, the cookie is cleared and they’re redirected to the login page.
FAQ
Q: Why can’t I just set the Authorization header via JavaScript?
A: You can, but it’s unreliable. Browsers ignore manually-set Authorization headers in cross-origin requests and certain redirect scenarios. Plus, once set, the browser caches it—making logout effectively impossible.
Q: Is this approach secure?
A: More secure than Basic Auth. Here’s why:
- HTTPS with
SecureandHttpOnlycookie flags - CSRF protection via
SameSite=Strict - Easy to integrate 2FA
- Session expiration and refresh support
Q: Are there any open-source solutions?
A: Yes. nginx-auth-ldap exists but the docs are garbage—we wasted a weekend on it. I’d recommend Authelia or OAuth2 Proxy instead. Both support webform login out of the box.
Q: What about performance?
A: The auth service is lightweight, but every request triggers an auth_request subrequest for validation. In our tests at 200 QPS, this added about 5ms per request. Acceptable for most use cases.
Q: Can I keep Basic Auth as a fallback?
A: Technically yes, using satisfy any in Nginx. But this creates a security hole—attackers can bypass your webform and use Basic Auth directly. Don’t do it unless you have a specific reason.
Final Thoughts
Look, Nginx’s Basic Auth module feels archaic in 2026. But to be fair, it was designed for API authentication, not frontend user login.
We’ve been running this setup in production for three months. The only complaint? Someone on Reddit said the login page was ugly. Fair point—frontend isn’t my strong suit. But at least it’s not that 1998 browser dialog anymore.
If you’re dealing with this exact problem, go with Approach A. Yes, the initial setup takes more time, but you’ll save yourself weeks of maintenance headaches down the road. Don’t waste two days on Approach C like I did—it simply doesn’t work.
Update: A commenter pointed out that ngx_http_auth_request_module can integrate directly with Keycloak. If your org already uses Keycloak, that’s probably the cleaner solution. Worth considering.