Basic and Digest authentication
The Authorization header, the 401 challenge, and a Digest response worked out by hand.
HTTP has its own framework for logging in, built into the protocol: the server answers with 401 and a WWW-Authenticate header saying how to authenticate, and the client tries again with an Authorizationheader. Basic and Digest are the two schemes that came with it. Basic is still everywhere, in internal tools, CI webhooks and API gateways. Digest is rarer, but it's alive in routers, cameras and older enterprise systems, and it's the clearest example there is of a challenge and response.
The 401 challenge
Ask for a protected resource without credentials and the server says what it wants:
$ curl -i https://api.testingapis.com/api/auth/basic
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Basic realm="apibee", charset="UTF-8"
{"status":401,"error":"Unauthorized","message":"Missing or malformed Basic Authorization header."}Basic names the scheme. realm names the protected area, so a client can keep different passwords for different parts of a site. charset="UTF-8", from RFC 7617, tells the client how to encode non-ASCII usernames and passwords. Browsers react to this header by showing their own login box. Script clients ignore the box but should read the header: a 401 without WWW-Authenticate breaks the HTTP rules and confuses clients that wait for the challenge before sending credentials.
Basic: a password in every request
The Basic header is the username, a colon and the password, encoded in Base64:
$ echo -n "apibee:password123" | base64
YXBpYmVlOnBhc3N3b3JkMTIz
$ curl https://api.testingapis.com/api/auth/basic \
-H "Authorization: Basic YXBpYmVlOnBhc3N3b3JkMTIz"
{"authenticated":true,"scheme":"basic","user":"apibee"}
# curl can build the header for you
$ curl -u apibee:password123 https://api.testingapis.com/api/auth/basic/api/auth/basicBase64 is an encoding, not encryption. Paste YXBpYmVlOnBhc3N3b3JkMTIz into the Base64 decoderand the password is right there. So Basic auth is only as safe as the connection: over HTTPS it's acceptable for many uses; over plain HTTP anyone on the network reads the password. The password also travels with every single request, so it ends up wherever the requests end up, including logs of any proxy that records headers.
Edge cases to send
Here is what this API does with each:
| Header sent | Result |
|---|---|
| No header | 401 with the challenge |
| Right credentials | 200 |
| Wrong password | 401, Invalid username or password. |
basic in lower case | 200. Scheme names are case-insensitive under RFC 9110, so this is correct |
Basic !!! (not Base64) | 401, Missing or malformed Basic Authorization header. |
| A password containing a colon | 200. Only the first colon separates username from password |
The wrong-password message doesn't say whether the username existed, which is right: a message like "unknown user" lets an attacker find valid usernames before guessing passwords. The colon rule needs a test on any server you build, because a naive split(":") cuts passwords that contain one. The /api/auth/basic/foo/a:bsign in to see endpoint accepts foo / a:b, so the server here splits correctly.
Hiding the resource instead
Some servers would rather not admit a protected resource exists. /api/auth/basic-hidden/foo/barsign in to see answers bad credentials with 404 and no challenge, so a browser shows no login box and a scanner learns nothing. GitHub does something similar for private repositories. It's a reasonable choice, but it trades away the standard signal clients rely on, so a client written for normal 401 behaviour will treat a wrong password as "page not found". If an API you test does this, make sure the client's error message doesn't send users chasing a missing page.
Digest: proving you know the password
Digest fixes Basic's biggest flaw: the password never crosses the network. Instead, the server sends a random value, the nonce, and the client proves it knows the password by hashing it together with that nonce. Here is the challenge:
$ curl -i https://api.testingapis.com/api/auth/digest
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Digest realm="apibee", qop="auth", algorithm=MD5,
nonce="MTc5MTcxNjQ3Mzo3MTExZmIxNmFhZGEzYWE3OkE1NjgzQTQzRkU5NkRBN0MyMjJDRkE4Rjc5RUI3RTcwOTNFRERFQzkyODQzQjlFMEQxRTc3ODYyQ0UxMkU5MjM=",
opaque="069d52228fd9db182da07fdef422cf7e"RFC 7616 defines the calculation. With qop=authit's three MD5 hashes. HA1 covers who you are, HA2 covers what you're asking for, and the response ties them to this nonce and a counter. You can do it yourself in a few lines:
// digest.mjs: work out a Digest response by hand
import { createHash } from "node:crypto";
const md5 = (s) => createHash("md5").update(s).digest("hex");
const username = "apibee", password = "password123", realm = "apibee";
const nonce = "MTc5MTcxNjQ3Mzo3MTExZmIxNmFhZGEzYWE3OkE1NjgzQTQzRkU5NkRBN0MyMjJDRkE4Rjc5RUI3RTcwOTNFRERFQzkyODQzQjlFMEQxRTc3ODYyQ0UxMkU5MjM=";
const method = "GET", uri = "/api/auth/digest";
const nc = "00000001", cnonce = "0a4f113b", qop = "auth";
const ha1 = md5(`${username}:${realm}:${password}`);
const ha2 = md5(`${method}:${uri}`);
const response = md5(`${ha1}:${nonce}:${nc}:${cnonce}:${qop}:${ha2}`);
console.log({ ha1, ha2, response });{
ha1: 'cbfc3cdedeec9289f3b23fb5f12c8d1e',
ha2: '5571f8aaa85892a5b2fb1840b7c03612',
response: '92cf042d56f073a8d27791720d9c65dc'
}nc is how many times the client has used this nonce, as eight hex digits. cnonce is a random value the client picks, which stops a malicious server from choosing nonces that make the hash easier to attack. Send the result back:
Authorization: Digest username="apibee", realm="apibee",
nonce="MTc5MTcxNjQ3Mzo3…", uri="/api/auth/digest", qop=auth, nc=00000001,
cnonce="0a4f113b", response="92cf042d56f073a8d27791720d9c65dc",
opaque="069d52228fd9db182da07fdef422cf7e"
{"authenticated":true,"scheme":"digest","user":"apibee","qop":"auth","nc":"00000001"}The server did the same calculation with its copy of the password, got the same response, and let the request in. In practice nobody writes this by hand. curl, Postman and Python's requests.auth.HTTPDigestAuth all handle the round trip:
$ curl --digest -u apibee:password123 https://api.testingapis.com/api/auth/digest
{"authenticated":true,"scheme":"digest","user":"apibee","qop":"auth","nc":"00000001"}What to test on Digest
- A nonce the server never issued. This API rejects it with
Nonce was not issued by this server.Decode the nonce here from Base64 and you'll see a Unix timestamp, a random value and a signature, so the server can check its own nonces without storing them, and no two clients ever get the same one. - An old nonce. Nonces here expire after 5 minutes. Sent again 338 seconds later, the header above got a 401 with a fresh nonce,
stale=trueandNonce expired — retry with the new nonce.stale=truetells the client its password was fine and it should simply retry with the new nonce, without asking the user again. Check that the client does exactly that. - The same request twice. RFC 7616 lets a server reject a repeated
ncfor the same nonce, which blocks replays, and this one does: the identical header sent a second time got 401 withnc 00000001 was already used with this nonce. Send a higher nc (replay refused).A client that reuses a nonce must count up,00000002and so on. Check that the client does, and that a server you test refuses the repeat. Without that check, a captured request works for as long as its nonce lasts. - The wrong password, which should fail exactly like Basic does, without naming the bad part.
Exercise
Get a fresh challenge from /api/auth/digestsign in to see, compute the response with the script above using your new nonce, and send it with curl. Then change only the uri in the header to /api/auth/digest?x=1sign in to see without recalculating. What happens, and why? Finally, send the same valid header three times and report whether the server notices.