Skip to content

Digest Access Authentication Deep Dive — Challenge-Response Without Plaintext

DodaTech Updated 2026-06-28 6 min read

In this tutorial, you will learn about Digest Access Authentication Deep Dive. We cover key concepts, practical examples, and best practices to help you master this topic.

HTTP Digest Access Authentication uses a challenge-response mechanism where the server sends a nonce and the client responds with a cryptographic hash, never sending the password in plaintext.

What You'll Learn

The Digest Auth handshake protocol, hash computation with MD5/MD5-sess, qop (quality of protection), nonce counting for replay protection, and server-side nonce validation.

Why It Matters

Digest Auth is still relevant for Embedded Systems, IoT devices, and legacy APIs where TLS is not feasible. It provides replay protection without requiring a PKI infrastructure.

Real-World Use

Home router administration panels, IP camera APIs, and some embedded device management interfaces rely on Digest Auth. Durga Antivirus Pro uses Digest Auth for its on-premise network scanner configuration API.

sequenceDiagram
    Client->>Server: GET /config
    Server->>Client: 401 Unauthorized
WWW-Authenticate: Digest realm=..., nonce=... Client->>Client: Compute HA1 = MD5(user:realm:pass)
Compute HA2 = MD5(method:uri)
Compute response = MD5(HA1:nonce:nc:cnonce:qop:HA2) Client->>Server: Authorization: Digest username=..., response=..., nc=... Server->>Server: Recompute hash, compare Server->>Client: 200 OK + Config Data

Code Example: Server-Side Digest Auth Challenge

import hashlib, secrets, time
from flask import Flask, request, jsonify, make_response

app = Flask(__name__)

# In-memory nonce store (use Redis in production)
nonces = {}
USERS = {"admin": "secret123"}
REALM = "Durga Scanner Config"

def generate_nonce():
    return secrets.token_hex(16)

def compute_response(username, realm, password, method, uri, nonce, nc, cnonce, qop):
    ha1 = hashlib.md5(f"{username}:{realm}:{password}".encode()).hexdigest()
    ha2 = hashlib.md5(f"{method}:{uri}".encode()).hexdigest()
    return hashlib.md5(f"{ha1}:{nonce}:{nc}:{cnonce}:{qop}:{ha2}".encode()).hexdigest()

@app.route("/config")
def get_config():
    auth = request.headers.get("Authorization", "")

    if not auth.startswith("Digest "):
        nonce = generate_nonce()
        nonces[nonce] = time.time()
        resp = jsonify({"error": "Digest auth required"})
        resp.headers["WWW-Authenticate"] = \
            f'Digest realm="{REALM}", nonce="{nonce}", qop="auth", algorithm=MD5'
        return resp, 401

    # Parse digest params
    params = {}
    for part in auth[7:].split(", "):
        key, _, val = part.partition("=")
        params[key.strip()] = val.strip().strip('"')

    username = params.get("username", "")
    password = USERS.get(username, "")

    expected = compute_response(
        username, REALM, password,
        request.method, request.path,
        params.get("nonce"), params.get("nc", "00000001"),
        params.get("cnonce", ""), params.get("qop", "auth")
    )

    if params.get("response") != expected:
        return jsonify({"error": "Invalid credentials"}), 401

    # Validate nonce freshness
    nonce_time = nonces.get(params.get("nonce"))
    if not nonce_time or (time.time() - nonce_time) > 300:
        return jsonify({"error": "Nonce expired"}), 401

    return jsonify({"scanner_config": {"interval": 3600, "depth": "full"}})

if __name__ == "__main__":
    app.run()

Expected curl test:

# First request (gets challenge)
curl -s -w "\nHTTP %{http_code}" http://localhost:5000/config
# {"error":"Digest auth required"}
# HTTP 401

# Use --digest flag for automatic handling
curl -s --digest -u "admin:secret123" \
  http://localhost:5000/config
# {"scanner_config":{"interval":3600,"depth":"full"}}
# HTTP 200

Code Example: Client-Side Digest Auth in Python

import hashlib, requests
from urllib.parse import urlparse

def digest_auth_get(url, username, password):
    # First request — expect 401
    resp = requests.get(url)
    if resp.status_code != 401:
        return resp

    www_auth = resp.headers.get("WWW-Authenticate", "")
    params = {}
    for part in www_auth.replace("Digest ", "").split(", "):
        key, _, val = part.partition("=")
        params[key.strip()] = val.strip().strip('"')

    realm = params.get("realm", "")
    nonce = params.get("nonce", "")
    qop = params.get("qop", "auth")
    algorithm = params.get("algorithm", "MD5")

    parsed = urlparse(url)
    uri = f"{parsed.path}?{parsed.query}" if parsed.query else parsed.path

    cnonce = secrets.token_hex(8)
    nc = "00000001"

    ha1 = hashlib.md5(f"{username}:{realm}:{password}".encode()).hexdigest()
    ha2 = hashlib.md5(f"GET:{uri}".encode()).hexdigest()
    response = hashlib.md5(
        f"{ha1}:{nonce}:{nc}:{cnonce}:{qop}:{ha2}".encode()
    ).hexdigest()

    auth_header = (
        f'Digest username="{username}", realm="{realm}", '
        f'nonce="{nonce}", uri="{uri}", '
        f'qop={qop}, nc={nc}, cnonce="{cnonce}", '
        f'response="{response}", algorithm={algorithm}'
    )

    return requests.get(url, headers={"Authorization": auth_header})

# Usage
resp = digest_auth_get("http://localhost:5000/config", "admin", "secret123")
print(resp.json())

Code Example: Nonce Expiry and Replay Prevention

import secrets, time, hashlib
from collections import OrderedDict

class NonceManager:
    def __init__(self, expiry=300, max_size=10000):
        self.nonces = OrderedDict()
        self.expiry = expiry
        self.max_size = max_size

    def create_nonce(self):
        nonce = secrets.token_hex(16)
        self.nonces[nonce] = {
            "created": time.time(),
            "nc_values": set()
        }
        self._cleanup()
        return nonce

    def validate(self, nonce, nc):
        data = self.nonces.get(nonce)
        if not data:
            return False, "Nonce not found"
        if (time.time() - data["created"]) > self.expiry:
            del self.nonces[nonce]
            return False, "Nonce expired"
        if nc in data["nc_values"]:
            return False, "Nonce count replay detected"
        data["nc_values"].add(nc)
        return True, "OK"

    def _cleanup(self):
        now = time.time()
        expired = [k for k, v in self.nonces.items()
                   if (now - v["created"]) > self.expiry]
        for k in expired:
            del self.nonces[k]
        while len(self.nonces) > self.max_size:
            self.nonces.popitem(last=False)

nonce_mgr = NonceManager()

Common Mistakes

1. Not Validating Nonce Count (nc)

Without nc validation, an attacker can replay a captured Digest response within the nonce window. Always track and reject duplicate nc values.

2. Using Weak Nonces

Nonces must be cryptographically random and unique per challenge. Sequential or timestamp-only nonces are predictable.

3. Ignoring qop

Without quality-of-protection, the response hash covers fewer fields, reducing replay protection. Always require qop=auth or qop=auth-int.

4. Nonce Reuse Across Users

Each authentication attempt should get a unique nonce. Reusing nonces across users makes the system vulnerable to replay attacks.

5. Storing Passwords in Plaintext

The server needs the plaintext password (or HA1 hash) to verify Digest responses. Store HA1 values instead of plaintext passwords for security.

Practice Questions

  1. How does Digest Auth differ from Basic Auth in credential transmission?
  2. What fields are included in the HA1 and HA2 hash computations?
  3. Why is the nonce count (nc) important?
  4. What does qop=auth-int add beyond qop=auth?
  5. Why can't bcrypt be used with Digest Auth?

Answers:

  1. Basic Auth sends Base64-encoded credentials. Digest Auth never sends the password — it sends a hash computed from the password, nonce, and other values.
  2. HA1 = MD5(username:realm:password). HA2 = MD5(method:uri). The full response = MD5(HA1:nonce:nc:cnonce:qop:HA2).
  3. nc prevents replay attacks by ensuring each Digest response uses a unique counter value. The server tracks which nc values have been seen.
  4. qop=auth-int includes the entity body in the HA2 hash, protecting the request body from tampering in addition to the URI and method.
  5. Digest Auth requires the server to recompute the MD5 hash with the password. bcrypt is one-way and intentionally slow, making it unsuitable for the Digest computation.

Challenge: Implement a full Digest Auth system with nonce rotation, nc tracking, and automatic stale nonce handling. Test with curl --digest and ensure replay attacks are blocked.

FAQ

Is Digest Auth obsolete?

It is less common than JWT and OAuth2, but still relevant for IoT, embedded systems, and legacy APIs where TLS is unavailable.

Does Digest Auth work with load balancers?

Yes, but nonce state must be shared across instances (Redis) or use stateless nonces with HMAC validation to prevent nonce rejection after failover.

Can I use SHA-256 instead of MD5?

The standard specifies MD5, but some implementations support algorithm=SHA-256. MD5 is still acceptable for this use case since the hash is not the sole security mechanism.

How do I log out a Digest Auth session?

Expire the current nonce. The client must re-authenticate on the next request since Digest Auth is request-based, not session-based.

What happens if the server restarts and loses nonce state?

All clients must re-authenticate. Use persistent storage (Redis) or HMAC-based stateless nonces to survive restarts.

Is Digest Auth vulnerable to man-in-the-middle attacks?

Without TLS, a MITM can downgrade the auth scheme to Basic Auth or modify the WWW-Authenticate header. Digest Auth is best used with HTTPS.

Mini Project

Build a Flask API that implements full Digest Access Authentication with nonce management, qop=auth, stale nonce handling, and automatic nonce expiry cleanup. Test with Python requests and curl.

What's Next

Now compare Digest Auth with HTTP Basic Authentication and learn about Token Storage Strategies for modern API auth patterns.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro