The {SHA} Format
An htpasswd line in {SHA} format is the base64 of a raw SHA-1 digest, prefixed with
the literal scheme name:
``
username:{SHA}qUqP5cyxm6YcTAhz05Hph5gvu9M=
`
It exists for interoperability with LDAP directories, which use the same encoding, and it is
supported by Apache and by nginx's auth_basic_user_file.
It Is Unsalted, and That Is the Problem
There is no salt in the format at all. Two users with the same password produce byte-identical
hashes, which means:
A leaked file immediately shows which accounts share a password.- Precomputed rainbow tables apply directly, with no per-account work.
- A single SHA-1 operation is fast, so brute force runs at billions of guesses per second.
APR1 at least salts and iterates. {SHA} does neither.When to Use It Anyway
Only when something on the other end demands it β an LDAP import, a legacy appliance, a
device firmware that reads no other format. If you have a choice, choose APR1; if you have a
real choice, do authentication in your application with bcrypt or Argon2 and leave Basic Auth
for infrastructure gating.
Using It With nginx
`nginx
location /internal/ {
auth_basic "Internal";
auth_basic_user_file /etc/nginx/.htpasswd;
}
`
nginx reads both {SHA} and $apr1$ lines from the same file, so a file can mix formats
during a migration.
Compensating Controls
If you must use this format, the password must carry the security the hash does not:
Generate a long random password rather than choosing one.- Never reuse it anywhere else β an unsalted hash of a reused password is the worst case.
- Serve only over HTTPS.
- Restrict by IP as well, where the client set is known.
- Rotate when anyone with access leaves.
Computed Locally
The digest is computed in your browser with the Web Crypto API. The password is never
transmitted.
Testing It Works
`bash
# Should return 401
curl -I https://staging.example.com/
# Should return 200
curl -I -u admin:password https://staging.example.com/
# What the browser actually sends
printf 'admin:password' | base64
# YWRtaW46cGFzc3dvcmQ= β Authorization: Basic YWRtaW46cGFzc3dvcmQ=
`
That last line is the whole security model: base64, no key, reversible by anyone who sees
the header. Over TLS it is fine; over plain HTTP it is equivalent to publishing the password.
Keeping Machines Out but Letting the Right Ones In
A blanket auth gate breaks things you rely on. Exempt them explicitly:
| Path | Why |
|---|---|
/.well-known/acme-challenge/ | Let's Encrypt renewal fails otherwise, silently, until the certificate expires |
/healthz | Load balancer and uptime probes |
/api/webhooks/ | Third-party callbacks cannot authenticate |
Blocking Crawlers Properly
Basic auth keeps a staging site out of search results, and it is worth pairing with the
other two signals so that nothing leaks through a link:
`
# robots.txt
User-agent: *
Disallow: /
`
`
X-Robots-Tag: noindex, nofollow
`
Note that robots.txt alone does not prevent indexing β it prevents crawling, and a
URL linked from elsewhere can still appear in results without a snippet. The noindex
header is what actually removes it.
CI and Scripts
`bash
# curl
curl -u "$STAGING_USER:$STAGING_PASS" https://staging.example.com/
# Playwright
await context.setHTTPCredentials({ username, password });
``
Keep the credentials in secrets, never in the repository β a password committed to git stays in the history after you delete it.