A password hash is supposed to be expensive. Argon2id fills 64 MiB of memory and walks it three times; bcrypt runs its key setup 4096 times; scrypt builds a 128 MiB table and reads it back in an order nobody can predict. That is what makes a stolen database slow to crack, and it is also what makes a public endpoint for it a gift to anyone who wants to spend your CPU. Nothing else we serve has that property, so we built something for it rather than adding four more lines to the router.
What was added
| Endpoint | Profile | Answer |
|---|---|---|
POST /argon2id_hash | 64 MiB, 3 passes, 1 lane | $argon2id$v=19$m=65536,t=3,p=1$...$..., a PHC string |
POST /bcrypt_hash | cost 12, at most 72 bytes | $2b$12$..., 60 characters |
POST /scrypt_hash | N 2^17, r 8, p 1 | $scrypt$ln=17,r=8,p=1$...$..., a PHC string |
POST /password_verify | reads the string | match, algorithm and the cost it read |
POST /blake3_hash | input up to 1 MiB | 64 hex characters, the digest |
The profiles are fixed and are what OWASP recommends in 2026. A new salt every call, so the same password gives a different string every time, and both strings verify. The formats are the ones everyone else writes, which matters more than it sounds: a string from /bcrypt_hash verifies in PHP, Python and Node, and a $2y$ string that PHP made verifies here, with its cost read out of it.
POST /password_verify
{"password":"correct horse battery staple","hash":"$2y$10$.pVh5j2t9bCDRJYB1DzZZODcgYcU9F8RXr8lO4XEYu2Y5wnRt.EoK"}
{"match":true,"algorithm":"bcrypt","params":{"cost":10}}
Reading the cost from the string is also where the first door had to be closed. A verification costs exactly as much as the string says, so a caller who sends an Argon2id string with four gigabytes of memory in it would be asking us to try. We do not. Anything above the fixed profiles is refused before a single byte is hashed:
{"password":"x","hash":"$argon2id$v=19$m=4194304,t=10,p=4$...$..."}
{"error":"Invalid input.","fix":"hash asks for m=4194304,t=10,p=4; this service verifies at most m=65536,t=3,p=1."}
Measured on the box
Before writing a line of this we timed the profiles on the API server itself, with the same PHP build the service runs. It has two cores.
| One call | CPU time |
|---|---|
| SHA-256 of 256 KB | 0.4 ms |
| Argon2id, 64 MiB, 3 passes | 101 ms |
| bcrypt, cost 12 | 191 ms |
| scrypt, 128 MiB | 232 ms |
So one password hash is between 250 and 580 ordinary calls. The whole API served 303 575 calls yesterday in roughly five minutes of CPU; a thousand password hashes a day would add three. Volume is not the problem. Bursts are. The API runs on one shared service with four request workers, and a worker that is computing is a worker that answers nobody for the duration. Four scrypt calls arriving together would freeze every tenant on the machine for a quarter of a second, and a loop of them would hold the whole API down for as long as it ran. A PHP extension would have had the same problem, since it runs inside the same worker.
A process of its own
The four new algorithms are not computed by the API at all. They are computed by aisense-hash-worker, a small program written in Rust that listens on a Unix socket on the same machine and does nothing else. The API sends it one JSON line and gets one JSON line back, and while it waits the request worker is free to serve everyone else.
Inside, the program is two processes. A supervisor owns the socket and never computes anything. A child, behind two pipes, does all the hashing, and it is the only process that ever touches input. That split is what makes the rules enforceable:
- One computation at a time. A second caller is told
busyin well under a millisecond and gets a 503 withRetry-After: 1. Nobody queues, nobody waits, and the worst case is one core. - Ten seconds, then the child dies. A job that passes the deadline has its process killed and reaped, the caller gets a timeout, and a fresh child answers the next request. Nothing a caller sends can hold the machine for longer than that.
- Memory is given back. The child reports its resident size after every job and is replaced after 500 jobs or when it passes 400 MiB. It also sets its own address space limit of 768 MiB, so a leak ends in the allocator rather than in the kernel. Measured after today's first scrypt, the child sat at 0.9 MB.
- A crash costs one request. If the child panics, aborts or is killed for memory, the supervisor answers that one job with an error, starts a new child, and writes a line about it. The API sees a 503 with a Retry-After, not an outage.
- The box comes first. systemd holds the whole service to 768 MiB and 80 percent of one core, kills the child rather than the service when memory runs out, and marks it as the first thing to die if the machine itself runs short. It has no network, no capabilities and no write access anywhere but its own log line. A watchdog restarts it within 30 seconds if it stops answering its own ping.
Every one of those events, start, stop, a child killed, a child crashed, a child replaced, is one line in the same event log the rest of the service writes, next to the restarts and reloads, so when a request log has a hole in it the cause is on the line above.
Budgets
200 a day per address, 20 000 a day for everyone.
On top of the 5000 calls every address already has. Hashing and verifying share the budget, past it the answer is 429 with a Retry-After until midnight Oslo time, and the shared ceiling makes the worst possible day about an hour of one core. BLAKE3 is fast and is not budgeted.
Checked before writing
From outside, against the live service, in this order.
| Step | What happened |
|---|---|
BLAKE3 of abc | 6437b3ac38465133ffb63b75273a8db548c558465d79db03fd359c6cd5bd9d85, the published vector, in 0.19 s round trip |
| Argon2id hash | A PHC string with m=65536,t=3,p=1 and a fresh salt, 0.33 s round trip |
| Verify it | match: true, the same parameters read back; the wrong password gives match: false as a result, not an error |
A $2y$ string made by PHP at cost 10 | match: true, cost: 10, 0.22 s |
| scrypt hash | A PHC string with ln=17,r=8,p=1, 0.47 s |
BLAKE3 named in /hash_verify | Verified; a 64 character digest without a name is still read as SHA-256, as before |
Argon2id named in /hash_verify | 400 with a fix pointing at /password_verify |
| An Argon2id string asking for 4 GiB | 400 with the ceiling in the fix, nothing computed |
| The daemon after ten jobs | 0 busy, 0 timeouts, 0 crashes, child at 0.9 MB, service at 0.8 MB |
The round trips include TLS from a laptop in Norway; the hashing itself is the 100 to 230 ms above. The daemon's own test suite, 16 unit tests and 12 that start the real binary, makes the child sleep past its deadline, crash on command and leak 450 MiB on command, and checks that each of those costs exactly one request.
What this is for
Test data, fixtures and comparisons.
The string is kept by nobody here, but the password travels to a public service, and a real password belongs to the application that uses it. These routes exist so you can generate test strings without installing a library first, verify what a legacy table holds, and settle an argument between two implementations with a third.
Where it sits
The same four algorithms are on the free MCP server through hash_data and verify_hash, which now take the names blake3, argon2id, bcrypt and scrypt; the server reports version 1.9.2. The Python and JavaScript clients and the OpenAI tool definitions in the public repository have the calls, each algorithm has its own page, and the hashing guide has the password section with the budgets. The worker's source, its unit file and its tests are in the production repository under hash-worker/.
Take one
Thirteen hash endpoints, two verify routes, no account.
Ten digests from MD5 to BLAKE3, three password hashes with fixed profiles, and a process of their own so the slow ones can be free without being a problem.