How to test basic auth with curl
Run curl -v --user user:passwd https://api.example.com/private. curl builds the header itself and puts Authorization: Basic dXNlcjpwYXNzd2Q= on the first request, before the server asks for anything. A 200 means the pair was accepted. A 401 carrying www-authenticate: Basic realm="..." means it was not.
Why check this
Basic auth guards staging environments, internal dashboards and machine-to-machine endpoints, and it fails in ways that a browser hides behind a login dialog. Run this check after any change to a reverse proxy, an ingress rule or a deployment that moves a service behind a gate, and again on staging sign-off.
The concrete failure it prevents: a proxy rewrite that drops the Authorization header on the way to the upstream service. The browser still shows the credential dialog, the dialog still reappears after a correct password, and the bug report reads "login loops". The transcript below shows the header leaving the client, which splits that report into a client problem or a server problem in one command.
The check also settles what is on the wire. Basic auth is base64, and base64 is an encoding. Step 2 turns the credential back into a password with one command and no key, which is the argument for requiring TLS on every route that uses it.
Prerequisites
- curl 7.0 or later. No HTTP/2 build is needed. See the curl manual page for --user.
- A credential pair the endpoint accepts. The examples use
httpbin.org/basic-auth/user/passwd, which acceptsuserandpasswd. - Node 22 for steps 4 and 5, which run against a local server so the failing cases cost a third party nothing. Save this as
authsrv.js, start it withnode authsrv.js, and stop it with the PID that command prints.
const http = require('http');
const USER = 'tester', PASS = 's3cret';
const PAGE = `<!doctype html><meta charset=utf-8><title>auth demo</title>
<script>fetch('/echo', { headers: { Authorization: 'Bearer demo-token-123' } })
.then(r => r.text()).then(t => document.title = t.trim());</script>`;
http.createServer((req, res) => {
const got = req.headers.authorization || '';
if (req.url === '/') {
res.writeHead(200, { 'content-type': 'text/html' });
return res.end(PAGE);
}
if (req.url === '/echo') {
res.writeHead(200, { 'content-type': 'text/plain' });
return res.end('authorization: ' + (got || '(absent)') + '\n');
}
const want = 'Basic ' + Buffer.from(USER + ':' + PASS).toString('base64');
if (got === want) {
res.writeHead(200, { 'content-type': 'text/plain' });
return res.end('ok, sent: ' + got + '\n');
}
res.writeHead(401, { 'www-authenticate': 'Basic realm="staging", charset="UTF-8"' });
res.end('denied, sent: ' + (got || '(absent)') + '\n');
}).listen(8731, '127.0.0.1', () => console.log('listening on 8731'));
Steps
- Step 1.
Send the pair and read the request curl produced.
curl -s -v --user user:passwd https://httpbin.org/basic-auth/user/passwd… TLS handshake lines cut … > GET /basic-auth/user/passwd HTTP/2 > Host: httpbin.org > Authorization: Basic dXNlcjpwYXNzd2Q= > User-Agent: curl/8.21.0 > Accept: */* > < HTTP/2 200 < date: Fri, 11 Sep 2026 20:16:40 GMT < content-type: application/json < content-length: 47 < server: gunicorn/19.9.0 < { "authenticated": true, "user": "user" }There is one request on the wire, not two. curl did not wait to be challenged.
- Step 2.
Decode the credential curl sent.
echo 'dXNlcjpwYXNzd2Q=' | base64 -duser:passwd - Step 3.
Send a wrong password to the same endpoint and read the challenge.
curl -s -v --user user:wrong https://httpbin.org/basic-auth/user/passwd> Authorization: Basic dXNlcjp3cm9uZw== … < HTTP/2 401 < content-length: 0 < server: gunicorn/19.9.0 < www-authenticate: Basic realm="Fake Realm" - Step 4.
Ask the local server with no credentials at all and compare the challenge.
curl -s -i http://127.0.0.1:8731/protectedHTTP/1.1 401 Unauthorized www-authenticate: Basic realm="staging", charset="UTF-8" Date: Fri, 11 Sep 2026 20:19:48 GMT Connection: keep-alive Transfer-Encoding: chunked denied, sent: (absent) - Step 5.
Read the exit code curl returns on that rejection.
curl -s -o /dev/null -w 'http_code=%{http_code}\n' --user tester:wrong http://127.0.0.1:8731/protected; echo "exit=$?"http_code=401 exit=0Adding
-fto the same command changesexit=0toexit=22and prints nothing else.
How to read the result
| What you see | What it means | What to do |
| --- | --- | --- |
| > Authorization: Basic … then < 200 | The pair was accepted on the first request | Nothing. This is the shape you want. |
| > Authorization: Basic … then < 401 | The header arrived and the server rejected the pair | Wrong password, wrong realm, or the account is locked. |
| No > Authorization line at all | curl never built the header | --user is missing or the URL was consumed as its argument. |
| < 401 with no www-authenticate | The route is gated but names no scheme | A browser will not show a credential dialog. Report it as a server defect. |
| The header is sent and the upstream logs show none | Something between client and service strips it | Check the proxy, not the credentials. |
Common mistakes
What to check next
- How to test digest auth with curl: the same endpoint shape with a challenge and response instead of a preemptive header.
- How to check authorization header: what reaches the server when
--userand a hand-written header are both present. - How to test API authentication: the same reasoning applied to tokens and API keys.
- How to check HTTP response headers with curl: reading the rest of the response the challenge came in.
- How to check if HSTS is enabled: a base64 credential over plain HTTP is readable in transit, and HSTS is what stops the first request happening there.
FAQ
How to check the www-authenticate header?
Send the request without credentials and print the response headers: curl -s -i https://host/private | grep -i www-authenticate. The first word of the value is the scheme the server wants. The realm names the protection space, and a browser shows it in the credential dialog.
Can I pass the user name and password in the URL?
Yes. curl http://tester:s3cret@127.0.0.1:8731/echo and curl --user tester:s3cret http://127.0.0.1:8731/echo both put Basic dGVzdGVyOnMzY3JldA== on the wire, byte for byte. The URL form leaks the password into logs and history more readily, so prefer --user.
Is basic auth encrypted?
No. Step 2 recovers the password from the header with one command and no key. The only thing protecting a basic credential in transit is TLS, which is why the check belongs on every route that carries one.
What does a password with a colon in it do?
curl splits --user on the first colon, so everything after it is the password and further colons are kept. A user name containing a colon cannot be expressed this way and has to go through --netrc.
Why does the request repeat when a browser does the same thing?
A browser sends nothing until it is challenged, then repeats the request with the header. curl with --user sends the header first, so one browser login is two requests in the server log and one curl call is one. Compare access logs accordingly.
Verified
Verified by Maks Vernycurl 8.21.0node 22.23.2
Each output block is what the command above it printed on that date, on the host named in the step. Figures read from a live site move between runs. Compare the shape of the answer rather than the digits, and see the methodology for how a page is re-verified.
Related on this site
basic5 minpublished updated Maks Verny