Password-Protect a Django Staging Site

Keep a Django staging site private: HTTP basic auth in Nginx or Caddy with health-check and webhook exceptions, Django middleware for PaaS hosts, noindex headers, IP or VPN restrictions, and why basic auth interferes with API tokens.

Problem statement

A staging or preview site runs production code with test data, unfinished features, and often weaker settings. If it is public, search engines index it (competing with your real site), customers stumble onto it, and bots find its admin. Requiring a password before anything loads keeps it private. HTTP basic authentication at the proxy is the simplest way, but it needs exceptions for health checks and webhooks, and it collides with APIs that use the Authorization header.

Gating a staging siteVisitorstaging.example.comBasic authat Nginx or CaddyExceptionshealth, webhooksDjangonormal auth insideThe proxy asks for a shared password before Django sees the request, with narrow exceptions.
  1. Create a password file: htpasswd -c /etc/nginx/.htpasswd-staging staging (from apache2-utils).
  2. In the staging Nginx server block: auth_basic "Staging"; auth_basic_user_file /etc/nginx/.htpasswd-staging;.
  3. Exempt /health/ and webhook paths with auth_basic off; in their own locations.
  4. Add X-Robots-Tag: noindex, nofollow and a disallow-all robots.txt as a second safeguard.
  5. On PaaS hosts without proxy access, use a small Django middleware that checks a shared password.
  6. For APIs that use Authorization: Bearer, restrict by IP or VPN instead of basic auth.

Step-by-step solution

1. Basic auth in Nginx

sudo apt install -y apache2-utils
sudo htpasswd -c /etc/nginx/.htpasswd-staging staging
sudo chown root:www-data /etc/nginx/.htpasswd-staging && sudo chmod 640 /etc/nginx/.htpasswd-staging
server {
    listen 443 ssl;
    server_name staging.example.com;

    auth_basic "Staging";
    auth_basic_user_file /etc/nginx/.htpasswd-staging;
    add_header X-Robots-Tag "noindex, nofollow" always;

    location = /health/ {
        auth_basic off;
        proxy_pass http://unix:/run/gunicorn/gunicorn.sock;
        include proxy_params;
    }
    location /webhooks/ {
        auth_basic off;
        proxy_pass http://unix:/run/gunicorn/gunicorn.sock;
        include proxy_params;
    }
    location / {
        proxy_pass http://unix:/run/gunicorn/gunicorn.sock;
        include proxy_params;
    }
}

Only use basic auth over HTTPS; the password is merely base64-encoded in each request.

2. Basic auth in Caddy

staging.example.com {
    @protected not path /health/ /webhooks/*
    basic_auth @protected {
        staging $2a$14$<bcrypt-hash-from-caddy-hash-password>
    }
    header X-Robots-Tag "noindex, nofollow"
    reverse_proxy 127.0.0.1:8000
}

Generate the hash with caddy hash-password.

protection options for stagingEffortCaveatProxy basic authโœ“minutesclashes with Authorization APIsDjango middlewaresmall code changeworks on any PaaSIP allow-listconfig per officeremote staff, changing IPsVPN / tailnet onlyโœ“one-time setuptesters need the clientBasic auth suits most teams; VPN access suits API-heavy appsdjango-deployment.com
Whatever you choose, add noindex headers as a second line of defence.

3. Middleware for PaaS hosts

When you cannot configure the proxy (Heroku, Render, Railway review apps), gate requests in Django:

# config/middleware.py
import base64, hmac
from django.conf import settings
from django.http import HttpResponse

class StagingBasicAuthMiddleware:
    EXEMPT = ("/health/", "/webhooks/")

    def __init__(self, get_response):
        self.get_response = get_response
        self.expected = settings.STAGING_BASIC_AUTH   # "user:password" or None

    def __call__(self, request):
        if not self.expected or request.path.startswith(self.EXEMPT):
            return self.get_response(request)
        header = request.META.get("HTTP_AUTHORIZATION", "")
        if header.startswith("Basic "):
            supplied = base64.b64decode(header[6:]).decode("utf-8", "replace")
            if hmac.compare_digest(supplied, self.expected):
                return self.get_response(request)
        resp = HttpResponse("Authentication required", status=401)
        resp["WWW-Authenticate"] = 'Basic realm="Staging"'
        return resp

Put it first in MIDDLEWARE and set STAGING_BASIC_AUTH only in staging environments. hmac.compare_digest avoids timing leaks.

4. The Authorization header conflict

Basic auth uses the Authorization header, which is also where API clients send Bearer tokens. A request can carry only one, so a frontend or mobile app calling the staging API with tokens fails behind basic auth. For API-heavy apps, restrict staging by IP or VPN instead, or exempt /api/ and rely on API authentication there.

5. Belt and braces against indexing

Search engines may still find staging URLs through links. Besides authentication, serve a disallow-all robots.txt on staging and the X-Robots-Tag header, so even an accidental public window does not get indexed. Never point staging at production's email or payment credentials.

staging that stays privatePassword at the proxy or middlewareHTTPS onlyHealth and webhook paths exemptedmonitors and providers worknoindex header and robots.txtsecond safeguardAPIs gated by IP or VPN if tokens usedAuthorization conflictTest credentials, not production onesemail, payments, storageA staging site should be invisible to search engines and strangersdjango-deployment.com
Separate credentials also stop staging from emailing real customers.

Verification check: curl -I https://staging.example.com/ returns 401 with WWW-Authenticate; curl -I -u staging:password https://staging.example.com/ returns 200 with X-Robots-Tag; curl -I https://staging.example.com/health/ returns 200 without credentials.

Explanation

HTTP basic authentication is handled by the proxy before any request reaches Django: unauthenticated requests receive a 401 challenge, and browsers prompt for credentials and resend them with each request. That makes it independent of Django's own login system and protects every URL, including static files and the admin. Its simplicity is also its limit: one shared credential, no logout, and it occupies the Authorization header, which conflicts with token-based APIs and some webhook senders.

Edge cases / notes

  • Preview environments per pull request can reuse one shared password stored in CI secrets.
  • Cloudflare Access or similar zero-trust proxies give per-user login with SSO instead of a shared password.
  • CORS preflight requests do not carry credentials; exempt OPTIONS if a separate frontend calls the staging API.
  • Mobile testing with basic auth works in browsers but not in many native HTTP clients.

Next steps

FAQ

How do I password-protect a Django staging site?

Add HTTP basic auth in Nginx or Caddy, or a small middleware on PaaS hosts, with exceptions for health checks and webhooks.

Will basic auth break my API?

If clients send Authorization: Bearer tokens, yes. Use IP or VPN restrictions for API staging.

Is basic auth secure enough for staging?

Over HTTPS with a strong password, it is a reasonable barrier for non-production data.

How do I stop search engines indexing staging?

Require authentication and also send X-Robots-Tag: noindex with a disallow-all robots.txt.

Keep going