Background

I run a site of small browser-based tools, and one of them looks for the input behind a hash.

https://hashitosystem.com/en/tools/hashfinder/

You paste a hash, pick the algorithm, and either give it a list of candidate strings or let it enumerate every string from a character set.
I built it to recover things like a forgotten test fixture value, where I knew roughly what the input looked like.
While testing it, I noticed how quickly it finds short numeric inputs, and I wanted real numbers for a question I hear a lot: "we store the SHA-256 of the PIN, not the PIN, so it is safe, right?"

Terms

A hash function turns any input into a fixed-length value, and you cannot run it backwards.
SHA-256 is a common one that outputs 32 bytes, usually written as 64 hex characters.
"You cannot run it backwards" is true, but it does not stop anyone from running it forwards on every possible input and comparing the results.
That is a brute-force search, and its cost is simply (number of possible inputs) x (cost of one hash).

A salt is a random value stored next to each hash and mixed into the input, so the same PIN gives a different hash for every user.
A key-derivation function (KDF) such as scrypt is a hash that is deliberately slow and memory-hungry, so that each guess costs much more.

How the tool enumerates candidates

The brute-force tab turns a number into a candidate string, so it can walk the whole space with a simple counter.
This is the real function from the page: index is the counter, len is the candidate length, and chars is the selected character set, for example "0123456789".

function indexToWord(index,len,chars){const arr=[...chars],base=arr.length,out=new Array(len);for(let p=len-1;p>=0;p--){out[p]=arr[index%base];index=Math.floor(index/base)}return out.join("")} 

It is just writing the counter in base chars.length.
With the digits as the character set and len set to 6, index 482913 becomes the string "482913", and the space has exactly 10^6 = 1,000,000 entries.
The page then hashes each candidate with the Web Crypto API and compares it with the target.

Measuring it in Node.js

The browser version has to yield to the UI, so I measured the raw cost in Node.js 24 instead.
The script below hashes a 6-digit PIN with plain SHA-256, then searches all PINs from 000000 to 999999 for a match.

const crypto = require('node:crypto'); const secret = '482913'; const target = crypto.createHash('sha256').update(secret).digest('hex'); let t0 = process.hrtime.bigint(); let found = null; for (let i = 0; i < 1_000_000; i++) { const pin = String(i).padStart(6, '0'); if (crypto.createHash('sha256').update(pin).digest('hex') === target) { found = pin; break; } } let ms = Number(process.hrtime.bigint() - t0) / 1e6; console.log('sha256 found', found, 'after', ms.toFixed(0), 'ms'); 

On my Windows machine it printed:

sha256 found 482913 after 1049 ms 

Hashing the whole space, without stopping at the match, took 2252 ms.
That is one CPU core, one thread, no GPU, and no special tooling.

The same search with scrypt

Next I timed scrypt with Node's default parameters (N=16384, r=8, p=1) and a random 16-byte salt, over 20 PINs, and multiplied the average by the size of the space.

const salt = crypto.randomBytes(16); t0 = process.hrtime.bigint(); for (let i = 0; i < 20; i++) crypto.scryptSync(String(i).padStart(6, '0'), salt, 32); ms = Number(process.hrtime.bigint() - t0) / 1e6; const per = ms / 20; console.log('scrypt per hash', per.toFixed(1), 'ms; 1,000,000 would take about', (per * 1e6 / 1000 / 3600).toFixed(1), 'hours'); 

The output was:

scrypt per hash 50.4 ms; 1,000,000 would take about 14.0 hours 

So the same 1,000,000 guesses go from about 2 seconds to about 14 hours on the same machine.
Because the salt is different for every record, that cost is paid again for each user, and a table of precomputed PIN hashes is useless.

Why scrypt is still not the whole answer

Fourteen hours is a slowdown, not a wall.
An attacker with many cores or a GPU will be faster than my single-threaded loop, and a 6-digit PIN still has only a million values.
The KDF buys time; it does not add entropy.
That is why systems that use short PINs also limit attempts on the server side, and keep the hash database away from anyone who could run an offline search.

The trade-off on the defending side is cost too: 50 ms per login check is fine for a login form, but you would not want it on every API request.

Conclusion

If the input space is small, a fast hash of it is effectively the input itself: a SHA-256 of a 6-digit PIN can be reversed in seconds.
Store such values with a per-record salt and a slow KDF like scrypt, and rely on attempt limits for the rest, because no hash can make a million possibilities into more than a million.

This article is about my own side project. It was written with AI assistance.