TestingAPIs is still being built. If something breaks, email support@testingapis.com.

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:

Terminal
$ 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."}
This example uses endpoints for signed-in members.Sign inCreate a free account

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:

Terminal
$ 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
This example uses endpoints for signed-in members.Sign inCreate a free account
GET/api/auth/basic
This request uses an endpoint for signed-in members.Sign inCreate a free account

Base64 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 sentResult
No header401 with the challenge
Right credentials200
Wrong password401, Invalid username or password.
basic in lower case200. 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 colon200. 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:

Terminal
$ 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"
This example uses endpoints for signed-in members.Sign inCreate a free account

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:

JavaScript
// 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 });
This example uses endpoints for signed-in members.Sign inCreate a free account
Output
{
  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:

Request header and response
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"}
This example uses endpoints for signed-in members.Sign inCreate a free account

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:

Terminal
$ curl --digest -u apibee:password123 https://api.testingapis.com/api/auth/digest
{"authenticated":true,"scheme":"digest","user":"apibee","qop":"auth","nc":"00000001"}
This example uses endpoints for signed-in members.Sign inCreate a free account

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=true and Nonce expired — retry with the new nonce. stale=true tells 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 nc for the same nonce, which blocks replays, and this one does: the identical header sent a second time got 401 with nc 00000001 was already used with this nonce. Send a higher nc (replay refused). A client that reuses a nonce must count up, 00000002 and 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.

Practise and go further