DevKitHub

API & Security

Htpasswd Generator for Apache and Nginx

Make a user:hash line for an Apache or nginx password file, or check a password against a line you already have. The password is hashed in your browser and never sent anywhere.

Generate

Verify

This tool runs entirely in your browser. Your input is never uploaded, stored or logged.

How it works

An htpasswd file is a list of user:hash lines, read by Apache’s AuthUserFile and nginx’s auth_basic_user_file. It never holds the password itself: when a browser sends Basic credentials, the server hashes the password it received with the salt stored in the line and compares the results. Three formats work in both servers without help from the operating system. bcrypt ($2y$…) is the one to choose. apr1 ($apr1$…), the htpasswd command’s default, is Apache’s variant of MD5-crypt — 1,000 rounds of MD5 over the password, an 8-character salt and the previous digest — implemented here exactly as Apache’s apr_md5.c and nginx’s ngx_crypt.c do it, and checked against openssl passwd -apr1. {SHA} is base64 of one unsalted SHA-1, which nginx’s own documentation says should not be used.

Which server accepts what is less uniform than it looks. Apache checks $2a$ and $2y$ bcrypt, apr1 and {SHA} itself and passes anything else, $2b$ included, to the system crypt() — or on Windows compares it as plain text — so this writes $2y$, as Apache’s htpasswd does. nginx implements apr1, {SHA}, {SSHA} and {PLAIN} itself and passes everything else, bcrypt included, to the system crypt(): bcrypt works on Alpine’s musl and on systems whose crypt() comes from libxcrypt, and fails wherever crypt() lacks it. bcrypt also has a cost that a password database does not. Basic authentication sends the password with every request, so the server runs bcrypt on every request, and a page with 30 images runs it 30 times. That is why htpasswd -B defaults to cost 5, the default here too, well below the 10 OWASP asks for stored passwords.

The line format has traps of its own, and each is checked. A colon cannot appear in a username, because it ends the username both in the file and in the Basic credentials (RFC 7617). A username starting with # turns its line into a comment in both servers, so that user can never sign in. Line breaks are refused anywhere. The checker takes a line from an existing file — a trailing :comment is allowed, as both servers allow it — works out the format from its prefix, and says plainly when it is one it cannot check, such as DES crypt, SHA-crypt or {SSHA}, instead of calling it a mismatch. It also recognises the doubled $$ that Docker Compose files need, the usual reason a copied line fails.

Common problems

Every example below is run against this tool in our test suite, so what it says here is what the tool actually does.

The line contains $$, which is Docker Compose’s escape for a single $.

admin:$$apr1$$r31.....$$HqJZimcKQFAMYayBlzkrA/
Why:
In docker-compose.yml a $ starts a variable, so a password line in a label or environment value — Traefik’s basicauth label is the familiar case — must have every $ doubled. Written to a real password file, the doubled signs stay and the line never matches.
Fix:
Double the $ signs only inside the compose file. Anywhere else, including a mounted htpasswd file, use the line exactly as generated.

An htpasswd line is user:hash, and this has no colon.

$apr1$r31.....$HqJZimcKQFAMYayBlzkrA/
Why:
Only the hash was copied. The server finds each user by the name before the colon, so a bare hash matches nobody, and both servers skip the line without an error.
Fix:
Put the username and a colon in front of the hash: admin:$apr1$… on one line.

That line starts with #, so Apache and nginx both read it as a comment.

#admin:{SHA}W6ph5Mm5Pz8GgiULbPgzG37mj9g=
Why:
Both servers skip lines that begin with #. A username that starts with # produces exactly such a line, and so does a line commented out while testing and then forgotten.
Fix:
Remove the # or choose a username without one. This generator refuses to write such a line.

That looks like traditional DES crypt, which reads only the first 8 characters of a password.

myName:rqXexS6ZhobKA
Why:
htpasswd -d writes the old Unix crypt(3) format: a 2-character salt and an 11-character result that depends only on the first 8 characters of the password. Apache’s documentation calls it insecure.
Fix:
Replace the line with a bcrypt one. It cannot be upgraded in place, because that needs the password.

A $2b$ line from Python or OpenBSD fails in Apache on some servers.

Why:
Apache checks $2a$ and $2y$ with its own bcrypt code but passes $2b$ to the operating system’s crypt(), which may not implement bcrypt. On Windows there is no crypt(), and the hash is compared as plain text.
Fix:
Write $2y$ for Apache. For a text password $2b$ and $2y$ compute the same hash, so changing the letter in an existing line is safe.

Pages behind basic auth load slowly after switching to bcrypt.

Why:
Basic authentication sends the password with every request and neither server caches the check by default, so bcrypt runs again for every image, script and stylesheet. At cost 10 or more that adds up quickly.
Fix:
Keep the cost low, like htpasswd’s default of 5, or cache the result — Apache’s mod_authn_socache exists for this.

Frequently asked questions

Does nginx support bcrypt in htpasswd files?
Only through the operating system. nginx implements apr1, {SHA}, {SSHA} and {PLAIN} itself and hands every other format, bcrypt included, to the system crypt(). musl, used by Alpine, and libxcrypt both implement bcrypt; where crypt() does not, nginx cannot check the line. apr1 works everywhere nginx runs.
Why does Apache write $2y$ rather than $2b$?
Its bcrypt code is Openwall’s crypt_blowfish, whose marker for correctly computed hashes is $2y$. Apache checks $2a$ and $2y$ itself and sends anything else to the system crypt(), so $2y$ is the prefix that works in Apache on every platform.
Which htpasswd algorithm should I use?
bcrypt wherever the server can check it: it is salted and slow on purpose. apr1 is salted but fast, so a leaked file is cheap to attack; use it where bcrypt is not available, such as nginx on a system whose crypt() lacks bcrypt. {SHA} is unsalted and fast, and belongs only in setups that accept nothing else.
How do I add the line to a password file?
Append it on a line of its own, for example with echo followed by the line in single quotes and >> /etc/nginx/.htpasswd — single quotes so the shell leaves the $ signs alone. Then point auth_basic_user_file (nginx) or AuthUserFile (Apache) at the file.

Last updated