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

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

  1. 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.

  2. Step 2.

    Decode the credential curl sent.

    echo 'dXNlcjpwYXNzd2Q=' | base64 -d
    
    user:passwd
  3. 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"
  4. Step 4.

    Ask the local server with no credentials at all and compare the challenge.

    curl -s -i http://127.0.0.1:8731/protected
    
    HTTP/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)
  5. 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=0

    Adding -f to the same command changes exit=0 to exit=22 and 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

Sign: A CI step that hits a basic-auth endpoint reports success on every run, including the runs where the password was wrong.Cause: curl exits 0 on a 401, as step 5 shows, because the transfer itself succeeded. Only -f, or an explicit test on %{http_code}, turns an authentication failure into a non-zero exit.
Sign: The password works from the shell and fails from a script.Cause: A password containing a shell metacharacter is split before curl sees it. Quote the whole argument: --user 'name:p@ss word', or pass --user name and let curl prompt for the rest.
Sign: The endpoint answers 401 although the credentials are correct.Cause: The server offers a scheme other than Basic. curl sends Basic by default and does not switch, so a Digest or Bearer endpoint rejects a correct password. Read www-authenticate before blaming the pair.
Sign: Credentials appear in the shell history and in process listings.Cause: curl http://user:pass@host and --user both expose the password to anyone who can read the command line. Use --user name with no colon and let curl prompt, or a --netrc file with restricted permissions.

What to check next

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.

basic5 minpublished updated Maks Verny