🍱 Lunchbox Hands

security

.htpasswd Hash Formats: bcrypt vs apr1 (MD5) vs {SHA} vs crypt, and What nginx Actually Accepts

htpasswd hash formats explained: bcrypt $2y$, apr1 MD5, {SHA} and crypt(3) — how to tell them apart, and which ones Apache, nginx, Caddy and Traefik accept.

A .htpasswd file looks like the simplest configuration format in the world: a username, a colon, some gibberish. Then you move a site from Apache to nginx, paste the same file across, and every login 401s. The gibberish is not one format — it’s five, and no two web servers accept the same set. Here’s how to read the prefix, what each server actually supports, and the one-character difference that silently breaks bcrypt hashes generated by most non-Apache tools.

The file format itself

One entry per line, username:hash, and that’s the whole specification:

admin:$2y$05$WHfG2bGw2C7iloC3AGBHXeFk3jWpXX5oRW2uPZyJPtAChZ/w4qLTe
deploy:$apr1$X6Q3oirX$ZKtRMJSiRV39cFlrkhMNE.
legacy:{SHA}NstYn3QVe0WBGmkMWLQ0CV9I6fo=

Formats can be mixed freely in one file — each line is evaluated on its own. nginx additionally documents support for # comments and a trailing third field (name2:password2:comment), which Apache does not.

The hash itself is self-describing, and you identify it by its prefix.

Telling the five formats apart

PrefixFormatWhat it is
$2y$ / $2a$bcryptcrypt_blowfish, salted, tunable cost. The one you want.
$apr1$apr1 (MD5)Apache’s own algorithm: 1,000 iterations of MD5 over a random 32-bit salt
{SHA}SHA-1Base64 of a raw, unsalted SHA-1 digest
13 chars, no $crypt(3)Traditional Unix DES. 12-bit salt, first 8 characters of the password only
anything elseplaintextExactly what it looks like

Every example below was generated locally with Apache 2.4.62’s htpasswd, using the password correct-horse:

$ htpasswd -nbB admin 'correct-horse'     # bcrypt
admin:$2y$05$WHfG2bGw2C7iloC3AGBHXeFk3jWpXX5oRW2uPZyJPtAChZ/w4qLTe

$ htpasswd -nb admin 'correct-horse'      # apr1 — the default
admin:$apr1$X6Q3oirX$ZKtRMJSiRV39cFlrkhMNE.

$ htpasswd -nbs admin 'correct-horse'     # SHA-1
admin:{SHA}NstYn3QVe0WBGmkMWLQ0CV9I6fo=

$ htpasswd -nbd admin 'correct-horse'     # crypt(3)
Warning: Password truncated to 8 characters by CRYPT algorithm.
admin:w4ewa18BM17n6

The $2y$05$ prefix decomposes the same way any bcrypt string does — algorithm, cost, salt, digest — which how bcrypt actually works walks through character by character. The important part here is that 05 is a cost, and it’s very low. More on that shortly.

The htpasswd flags worth knowing

From the official Apache 2.4 man page:

FlagMeaningDoc’s own words
-Bbcrypt”currently considered to be very secure”
-Cbcrypt cost”higher is more secure but slower, default: 5, valid: 4 to 17”
-mapr1 MD5”This is the default (since version 2.2.18).”
-sSHA-1”This algorithm is insecure by today’s standards.”
-dcrypt(3)“limits the password length to 8 characters… insecure by today’s standards”
-pplaintexthttpd “will only accept plain text passwords on Windows and Netware”
-2 / -5SHA-256 / SHA-512 crypt”supported on most Unix platforms” (-r sets rounds, default 5,000)
-nprint, don’t write a file
-btake the password as an argument
-iread the password from stdin

Two of those deserve emphasis.

-C 5 is the documented default and it is far too low. Cost 5 means 2⁵ = 32 key-setup rounds. A 2026 GPU chews through that. If you use -B, always pass -C explicitly — 10 as a floor, 12 if your login volume tolerates it. Every +1 doubles the work.

Never use -b interactively. The docs say so plainly: “This option should be used with extreme care, since the password is clearly visible on the command line.” That means it lands in your shell history file and is visible in ps output to every other user on the box for as long as the command runs. Use -i in scripts instead:

# bad: password in ~/.zsh_history and in ps
htpasswd -nbB -C 12 admin 'hunter2'

# fine: password on stdin
printf '%s' "$PASSWORD" | htpasswd -niB -C 12 admin

Or skip the CLI entirely and use a browser-side generator — the .htpasswd generator never puts the password on a command line or on a network.

What each server actually accepts

This is the part that breaks migrations, and the docs disagree with each other in ways that matter.

Apache 2.4 (Password Formats) states there are “five formats that Apache recognizes for basic-authentication passwords. Note that not all formats work on every platform”:

FormatApache’s note
bcrypt"$2y$" + crypt_blowfish
apr1 MD5"$apr1$" + 1,000-iteration MD5
SHA1"{SHA}" + Base64 SHA-1. Insecure.
CRYPTUnix only. Insecure.
PLAIN TEXTWindows & Netware only. Insecure.

So bcrypt, apr1 and {SHA} are the portable three; crypt is Unix-only; plaintext is Windows/Netware-only. (The SHA-2 formats from -2/-5 aren’t among those five — they ride the platform’s crypt() fall-through, which is why the docs hedge with “most Unix platforms.” On macOS, htpasswd -5 just returns “SHA-2 crypt() algorithms are not supported on this platform.”)

nginx (auth_basic_user_file) documents a different set:

  • “encrypted with the crypt() function”
  • “hashed with the Apache variant of the MD5-based password algorithm (apr1)”
  • RFC 2307 {scheme}data syntax: PLAIN, SHA (1.3.13+), and SSHA

Two consequences:

  1. nginx supports {SSHA}; Apache does not. Salted SHA-1 from OpenLDAP or Dovecot drops straight into nginx and is invisible to Apache.
  2. The nginx docs never say the word “bcrypt.” bcrypt works only because $2y$ falls into the crypt() bucket — so whether it works is a property of your libc, not of nginx.

On a modern glibc Linux that’s usually fine: distributions now ship libxcrypt, whose supported list is “yescrypt, gost-yescrypt, sm3-yescrypt, scrypt, bcrypt, sha512crypt…”, and whose implementation covers all four subtypes — $2a$, $2b$, $2x$ and $2y$. Alpine/musl also dispatches it; musl’s __crypt_r routes any $2?$ setting to __crypt_blowfish. The old glibc-only crypt() did not. If you’re on an unusual base image, don’t assume — test.

The 30-second nginx test. Don’t reason about your libc; ask it:

printf 'admin:%s\n' "$(htpasswd -nbB -C 12 admin 'test-pw' | cut -d: -f2)" \
  | sudo tee /etc/nginx/.htpasswd
sudo nginx -t && sudo nginx -s reload
curl -u admin:test-pw -o /dev/null -w '%{http_code}\n' https://your.host/protected/

200 means bcrypt works. 401 means it doesn’t, and the error log says why: crypt_r() failed is libc reporting it doesn’t know the format, while “password mismatch” means the file parsed fine and the password is simply wrong.

And the other two:

ServerAccepts
Caddy (basic_auth)Hashes only — “Caddy configuration does not accept plaintext passwords; you MUST hash them.” Options “include argon2id, the default is bcrypt.” Generate with caddy hash-password. Not a .htpasswd file: the format is <username> <hashed_password> in the Caddyfile.
Traefik (BasicAuth middleware)“Passwords must be hashed using MD5, SHA1, or BCrypt. Use htpasswd to generate the passwords.” users or usersFile, both name:hashed-password.

The $2b$ trap

Here’s the failure mode that costs people an afternoon. Apache’s bcrypt support lives in APR’s apr_password_validate(), and it detects bcrypt like this:

if ((apr_strneq_timingsafe(hash, "$2a$", 4) | /* test both */
     apr_strneq_timingsafe(hash, "$2y$", 4))) {

Only $2a$ and $2y$. A $2b$ hash doesn’t match, falls through to the generic crypt() branch, and fails to verify. Confirmed against Apache 2.4.62:

$ htpasswd -vb file.htpasswd admin 'correct-horse'   # $2b$ hash
password verification failed          # exit 3
$ htpasswd -vb file.htpasswd admin 'correct-horse'   # same hash, $2y$ prefix
Password for user admin correct.      # exit 0

This matters because $2b$ is what most modern bcrypt libraries emit by default — Go’s x/crypto/bcrypt, Node’s bcryptjs, and others. The digest bytes are identical ($2b$ and $2y$ carry the same behavior flags in crypt_blowfish); only the label differs. So the fix is a rename, not a re-hash:

sed -i 's/:\$2b\$/:$2y$/' .htpasswd

nginx, going through libxcrypt, accepts $2b$ happily. Apache does not. If a hash works on one and not the other, check the third character before anything else.

Why apr1 is still the default, and why that’s a problem

-m has been the default since Apache 2.2.18, and it stays the default for compatibility, not for security. apr1 is “an iterated (1,000 times) MD5 digest of various combinations of a random 32-bit salt and the password.”

The salt is real and it works — hash the same password for two users and you get different output:

alice:$apr1$RloDVqj4$tOJps1oQ2I.jIQ4yIcYCY0
bob:$apr1$0gEvSsnJ$LUCFMy.IPdoJOggQU3InN1

But 1,000 MD5 iterations is fixed, non-tunable, and by modern standards trivial. bcrypt at cost 12 is roughly four thousand rounds of a deliberately cache-hostile algorithm; apr1 is a thousand rounds of the fastest hash ever widely deployed, with no -C equivalent to raise it. Better than nothing, and not what you should pick in 2026.

Why {SHA} is the worst option on the list

{SHA} is a single, unsalted SHA-1. Watch what that means:

alice:{SHA}87u9ZqY9S/F0eUBXjsPQEDUw4h0=
bob:{SHA}87u9ZqY9S/F0eUBXjsPQEDUw4h0=

Same password, same hash, visibly. An attacker who reads your .htpasswd can see which users share a password, and can test the whole file against a wordlist at one SHA-1 per guess — billions per second on commodity hardware. nginx’s docs are blunt about it: the scheme “was added only to aid in migration from other web servers. It should not be used for new passwords, since unsalted SHA-1 hashing that it employs is vulnerable to rainbow table attacks.” Apache labels it “Insecure” outright. The contrast with bcrypt is the whole reason password hashing is its own discipline.

crypt(3) is nearly as bad and adds a special insult: it silently uses only the first 8 characters. That 30-character passphrase you chose?

$ htpasswd -vb crypt.htpasswd admin 'correct-'
Password for user admin correct.

correct- opens an account whose password is correct-horse.

One practical note on bcrypt while you’re here: it reads only the first 72 bytes of input. Not a problem for normal passwords, but worth knowing before you feed it a generated 100-character secret from the password generator.

The file is only half the job

Even a perfect bcrypt file doesn’t secure anything by itself.

Basic auth is Base64, not encryption. RFC 7617 constructs the credential by concatenating “the user-id, a single colon (”:”) character, and the password” and Base64-encoding it — sent on every single request. The RFC’s own Security Considerations: “The most serious flaw of Basic authentication is that it results in the cleartext transmission of the user’s password over the physical network.” Apache’s auth howto agrees: Basic auth “sends the password from the client to the server unencrypted… should therefore not be used for highly sensitive data, unless accompanied by mod_ssl.” HTTPS is not optional here.

Put the file outside the document root. Apache’s howto: “This file should be placed somewhere not accessible from the web. This is so that folks cannot download the password file.” /etc/apache2/.htpasswd or /etc/nginx/.htpasswd, never /var/www/html/.htpasswd. Apache’s shipped default config blocks the filename as a backstop, but a backstop is not a plan:

#
# The following lines prevent .htaccess and .htpasswd files from being
# viewed by Web clients.
#
<Files ".ht*">
    Require all denied
</Files>

nginx ships no such default. If the file has to live under the web root on nginx, deny it yourself:

location ~ /\.ht {
    deny all;
}

And set permissions. chmod 640 owned by root with the web server’s group, or 600 if the worker runs as the file’s owner — the reasoning in chmod 755 vs 644 applies exactly. The .htaccess generator produces the matching AuthType Basic block.

How our generator works

Full disclosure, since this post is about trusting hashes:

  • bcrypt via the bcryptjs package at cost 10 — 2¹⁰ = 1,024 rounds, 32× more work than Apache’s documented -C default of 5. Stronger than what htpasswd -B gives you out of the box.
  • {SHA} via the browser’s WebCrypto SHA-1, Base64-encoded. Offered for migration only; the tool labels it weak in the UI, and everything above still applies.
  • No apr1, no crypt(3), no plaintext — deliberately. Those are the formats to leave behind.
  • Everything runs in your browser. The password is never sent anywhere, never touches a command line, and never enters shell history.

One detail worth knowing: bcryptjs emits the $2b$ prefix, and as shown above Apache rejects it. Until this post was written, our generator passed that straight through — every bcrypt line it produced worked on nginx and failed on Apache with password verification failed. It now relabels the hash to $2y$ before showing it (the digest bytes are identical, so nothing else changes), and the test suite checks both that the prefix is $2y$ and that the hash still verifies. If you copied a $2b$ line from the tool before that fix, the sed one-liner above repairs it.

The short version

  • Read the prefix: $2y$ bcrypt, $apr1$ Apache MD5, {SHA} unsalted SHA-1, 13 bare characters crypt(3).
  • Use bcrypt. Pass -C 10 or higher — the default of 5 is a relic.
  • Apache takes $2y$/$2a$ but not $2b$. nginx takes whatever your libc’s crypt() takes, plus {SSHA} that Apache can’t read.
  • Caddy is bcrypt/argon2id and its own file format; Traefik takes MD5, SHA1 or bcrypt.
  • Never -b with a real password. Never {SHA} or -d for anything new.
  • HTTPS, outside the document root, 640.

Generate a correct line in your browser with the .htpasswd generator — no install, no command line, nothing uploaded. If you just want to inspect a bcrypt string or feel the cost factor bite, the bcrypt tool and the hash generator are next door.