Security guide

HTTP Basic Auth and htpasswd: How It Works and When to Use It

The exact bytes Basic authentication puts on the wire, why it is useless without TLS, what each htpasswd hashing algorithm is worth, how to pick a bcrypt cost, and where Basic auth is the right answer.

HTTP Basic authentication is the oldest authentication scheme on the web and the least mysterious: the client sends the username and password, Base64-encoded, with every single request. That design is either exactly what you want or completely disqualifying, and the difference is entirely about what you are protecting.

What actually happens on the wire

The exchange, specified in RFC 7617, has three steps and no state. A client requests a protected resource. The server answers 401 Unauthorized with a WWW-Authenticate header naming the scheme and a realm. The client retries with an Authorization header containing the word Basic and one Base64 token.

That token is the username, a colon, and the password, encoded as Base64. Nothing more. There is no hashing, no nonce, no challenge-response and no key agreement. This is why the username may not contain a colon: the server splits on the first one, so any colon after that belongs to the password.

Two consequences follow immediately. First, the credential is recoverable from the header by anyone who can read it — Base64 is an encoding, and the point of an encoding is that it reverses. Second, the full password travels on every request, not once at login. There is no session to steal because there is no session; the password itself is the session token, replayed indefinitely.

A complete Basic authentication exchange
GET /metrics HTTP/1.1
Host: internal.example.com

HTTP/1.1 401 Unauthorized
WWW-Authenticate: Basic realm="Internal metrics", charset="UTF-8"

GET /metrics HTTP/1.1
Host: internal.example.com
Authorization: Basic YWxhZGRpbjpvcGVuc2VzYW1l

# That token decodes to exactly this, with no key involved:
$ echo 'YWxhZGRpbjpvcGVuc2VzYW1l' | base64 -d
aladdin:opensesame

# The charset parameter may only be "UTF-8"; it tells the client
# how to encode non-ASCII credentials before Base64.

Why it needs TLS, and what TLS does not fix

Basic auth over plain HTTP transmits a reusable password in readable form to every device on the path. That is not a subtle weakness to be weighed against convenience; it is a password disclosure with extra steps. If a service uses Basic auth, it must be HTTPS-only, with HTTP either redirected before the credentials are ever prompted for or refused outright.

TLS solves the transport problem completely and leaves three others untouched, which is the part people skip.

The credential is replayed on every request, so anything that sees a request sees the password. That includes your access logs if a misconfiguration logs request headers, your error tracker if it captures headers on exceptions, an intermediate proxy that terminates TLS, and the browser's own developer tools and HAR exports. A bearer token has the same property but is at least scoped and revocable; a Basic password usually is neither.

The htpasswd file and its algorithms

The server side of Basic auth is usually a flat file: one line per user, username and password hash separated by a colon, with the hash's prefix identifying the algorithm in the same self-describing style as a Unix shadow entry.

The algorithm choice matters more than it looks, because this file is a password database. It tends to end up in a configuration repository, in a container image layer, in a backup, or in a configuration-management inventory — all places where a weak hash is worth far more to an attacker than a strong one. Use bcrypt, and treat the file as a secret regardless.

One point of confusion worth clearing up: Apache's -m flag is named MD5 but it is not a plain MD5 digest. It is the APR1 algorithm, which salts the password and iterates MD5 one thousand times. That was a reasonable design in 1996 and is far better than an unsalted digest, but a thousand MD5 rounds is trivially fast on modern hardware. It remains the default on many platforms for compatibility, which means accepting the default gets you the weak option.

FlagAlgorithmStored prefixSaltedVerdict
-Bbcrypt, cost set by -C$2y$Yes, 128-bitUse this. Apache has supported it since 2.4.4.
-mAPR1: salted MD5, 1000 iterations$apr1$Yes, 48-bitThe historical default. Fast to attack offline — replace it.
-2 / -5SHA-256 / SHA-512 crypt, iterated$5$ / $6$YesAcceptable where bcrypt is unavailable. Newer Apache builds only.
-sSHA-1, single pass{SHA}NoUnsalted and instantaneous to crack. Do not use.
-dcrypt(3) DESno prefix, 13 charsYes, 12-bitTruncates the password to 8 characters. Legacy only.
-pPlaintextnoneNoStores the password as typed. Windows and NetWare only, and never.
Real htpasswd output for each format
$ htpasswd -nbB -C 10 deploy 'correct horse battery staple'
deploy:$2y$10$BGmm0BZWcCbLgebJ4eSabOpuY0/mHQl/iSB6wNIqSFwTnVn9J1cDW

$ htpasswd -nbm legacy 'hunter2'
legacy:$apr1$8LOMaYO6$fg2450HshNgRkR2i0sfcx.

$ htpasswd -nbs legacy 'hunter2'
legacy:{SHA}87u9ZqY9S/F0eUBXjsPQEDUw4h0=

# -n prints to stdout instead of writing the file; -b takes the
# password as an argument, which puts it in your shell history.
# Drop -b to be prompted instead.
#
# The {SHA} line is unsalted: the same password produces the same
# string on every machine in the world, so it can be looked up
# rather than cracked.

Choosing a bcrypt cost

The -C flag sets bcrypt's cost factor, and it is the only parameter that decides what an offline attack on your file costs. Each increment doubles the work: cost 12 is four times cost 10, and 128 times cost 5.

Apache's default is 5, which is far too low for 2026 and is the single most common defect in a real htpasswd file. The default was chosen when bcrypt was new and hardware was slower, and it has never been raised. If you type htpasswd -B without -C, you get it.

Pick the cost by measuring rather than by folklore, because the right answer depends on your hardware. On a current laptop core, cost 10 takes roughly 40 milliseconds per verification and cost 12 roughly 170. Choose the highest value whose latency you can accept on every request — remembering that Basic auth verifies on every request, not once per session, so a cost of 14 on a high-traffic endpoint is a self-inflicted denial of service. Cost 10 to 12 is the sensible band for a Basic-auth gate, and you should benchmark on the machine that will actually serve it.

Apache's htpasswd accepts a cost between 4 and 17. Be aware also of bcrypt's 72-byte input limit: passwords longer than that are silently truncated, so a very long passphrase gains you nothing past that point.

Measuring cost on the machine that will run it
$ time htpasswd -nbB -C 10 u p > /dev/null
real  0m0.04s

$ time htpasswd -nbB -C 12 u p > /dev/null
real  0m0.17s

# Roughly 4x per two increments, as expected. Pick the highest
# cost whose latency you can pay on EVERY request, then write it
# down in your runbook so the next person does not reset to 5.

Wiring it up

On nginx the configuration is two directives inside a location or server block, and a nested location with auth_basic off exempts a path — useful for a health check that your load balancer must reach without credentials.

One platform detail catches people. nginx verifies APR1 and {SHA} entries itself, but delegates DES and modern crypt formats to the system's crypt(3). Whether bcrypt entries work therefore depends on the C library: distributions built on libxcrypt handle $2y$ correctly, while older glibc-only systems do not. If your bcrypt line is rejected on one host and accepted on another, that is why. Test the file on the actual target before shipping it.

nginx and Apache configuration
# nginx
location /internal/ {
    auth_basic           "Internal metrics";
    auth_basic_user_file /etc/nginx/secrets/htpasswd;

    location /internal/healthz {
        auth_basic off;
    }
}

# Apache
<Directory "/srv/app/internal">
    AuthType Basic
    AuthName "Internal metrics"
    AuthUserFile /etc/apache2/secrets/htpasswd
    Require valid-user
</Directory>

# Keep the file out of any served directory, mode 0640, owned by
# root and readable by the server user only.

When Basic auth is the right answer, and when it is not

Basic auth is genuinely good at one thing: putting a cheap, stateless, dependency-free gate in front of something that should not be publicly reachable at all. A staging environment you do not want indexed or probed. A Prometheus metrics endpoint. An internal dashboard behind a VPN that you want one more layer on. A webhook receiver where you control both ends and can rotate a long random password. In those cases it is decided at the proxy, requires no application code, survives a rewrite of the service behind it, and has no library to keep patched.

It is the wrong answer for anything with actual users. There is no logout, no password reset, no second factor, no lockout after failed attempts, no per-device sessions, and no way to revoke one person's access without changing the file and reloading the server. The browser prompt cannot be styled or explained, so users see a bare dialog with a realm string and no context. Multiple accounts on one host and one realm interact badly. Every one of these is a product problem as much as a security problem.

The strongest framing: use Basic auth to keep strangers out of something that is not meant to be public, and never as the mechanism that distinguishes one legitimate user from another. If the answer to who is logged in matters to your application, you need real authentication.

Generating htpasswd lines in a browser

Our htpasswd generator produces a bcrypt line in the page, at a cost factor you choose, and sends nothing to a server. That is useful when you do not have the Apache tools installed, which is common on a minimal container or a Windows workstation.

It is worth being precise about what this does and does not change. The output line is a hash, and publishing a bcrypt hash at cost 10 is not a catastrophe. The password you typed to produce it is the sensitive value, and it was in a browser text field: reachable by extensions with host permissions, present in the page's memory, and one autofill mishap from being saved somewhere you did not intend. So generate a fresh random password for this purpose rather than reusing one that protects anything else, and generate it with your password manager.

Where a local tool exists, prefer it. htpasswd -B -C 12 -n user reads the password from a prompt rather than from a text field, does not put it in your shell history, and never involves a browser at all. The generator here is a convenience for the case where that is not available, not an upgrade over it — and we would rather tell you that than imply otherwise.

What to remember

  • Basic auth Base64-encodes username:password and replays it on every request, so it is only ever acceptable over HTTPS.
  • TLS protects the transport but not your logs, error trackers, TLS-terminating proxies, or URLs containing embedded credentials.
  • Use bcrypt in htpasswd and set the cost explicitly — the default of 5 is far too low, and 10 to 12 is the sensible band today.
  • Verify that your server can actually read bcrypt entries; nginx delegates them to the system crypt(3), whose support varies by platform.
  • Reach for Basic auth to keep strangers out of a staging site or a metrics endpoint, never as the login for real users, who need logout, reset, MFA and revocation.

Continue with related checks and tools