@vladimir-plakhotnik/scryptjs
v1.2.0
Published
A lightweight library for password hashing using scrypt in Node.js
Maintainers
Readme
scryptjs
A lightweight library for password hashing using scrypt in Node.js.
This library offers a reliable, modern, and efficient alternative to bcrypt and other libraries for password generation and storage on the server side.
Motivation
Although Node.js provides built-in support for the scrypt algorithm, using it often requires repetitive boilerplate code. For example, each time you need to:
- Wrap the scrypt function in a Promise to use it asynchronously.
- Manually handle salt generation.
- Serialize or structure the resulting hash for storage.
This package was created to simplify the process by:
- Providing an easy-to-use API for password hashing and verification.
- Automatically handling salt generation and JSON formatting.
- Ensuring safe and consistent password comparisons using timingSafeEqual.
With this package, you can securely hash and verify passwords without worrying about these implementation details.
Why scrypt Instead of bcrypt?
OWASP's current guidance ranks Argon2id first, scrypt second, and bcrypt third — bcrypt is there for compatibility, not because it's the strongest option. Two concrete, common problems scrypt avoids:
- bcrypt silently truncates passwords longer than 72 bytes — most bcrypt implementations (including the popular
bcrypt/bcryptjsnpm packages) drop everything past that, so"correct-horse-battery-staple-and-then-some"and the same string with extra characters appended can hash identically. scrypt has no such limit. - bcrypt's cost factor only scales CPU time; its memory use stays fixed (~4 KB) regardless of cost. scrypt's
N/rparameters scale memory alongside CPU, which is specifically what makes GPU/ASIC-parallel cracking attempts expensive — memory bandwidth doesn't scale as cheaply as raw compute.
There's also a practical dependency-management angle: the bcrypt npm package is a native addon (requires node-gyp/prebuilt binaries per platform), while bcryptjs is pure JS but noticeably slower. scrypt ships in Node's own crypto module — no native build step, no extra dependency at all, on any platform Node itself supports.
None of this makes existing bcrypt hashes wrong or something you must migrate off urgently — if you already have bcrypt hashes in production, see the FAQ below for how to move new hashes to scrypt without breaking old ones.
Installation
npm i @vladimir-plakhotnik/scryptjsUsage
import scryptjs from "@vladimir-plakhotnik/scryptjs";
const scrypt = scryptjs();
const hashedPassword = await scrypt.hash("password");
const isMatch = await scrypt.compare("password", hashedPassword);
console.log(isMatch); // trueYou can also provide a custom salt if needed. If not provided, a random salt will be generated:
const hashedPassword = await scrypt.hash("password", "custom salt");Warning: if you provide a custom salt, it must be unique for every password. Reusing the same salt across passwords allows an attacker to crack them all at once with a single precomputed table. Unless you have a specific reason, omit the salt and let the library generate a random one per password.
Both the password and the salt accept a string, a Buffer, or any ArrayBufferView/ArrayBuffer — not just strings:
const hashedPassword = await scrypt.hash(Buffer.from("password"));If you are using plain CommonJS (require) or native Node.js ESM (.mjs without a bundler or TypeScript), the function is exposed as the default property:
// CommonJS
const scryptjs = require("@vladimir-plakhotnik/scryptjs").default;
// Native ESM
import pkg from "@vladimir-plakhotnik/scryptjs";
const scryptjs = pkg.default;TypeScript and bundler users are unaffected: import scryptjs from "@vladimir-plakhotnik/scryptjs" works as shown above.
Options
The scryptjs options allows you to customize the behavior of the scryptjs library. All fields are optional, and any omitted fields will use the default values.
Fields:
N: The CPU/memory cost parameter. This controls the computational cost of the hashing process.Default:4096r: The block size parameter. This affects the amount of memory used for the hashing operation.Default:8p: The parallelization parameter. This determines the number of parallel threads used during hashing.Default:1keylen: The length of the derived key (hashed password) in bytes.Default:64saltSize: The size of the randomly generated salt in bytes.Default:32maxmem: The memory upper bound for scrypt, in bytes. Scrypt needs128 * N * rbytes, so this is computed automatically from the effectiveNandr— you only need to set it explicitly if you want a tighter or looser bound.Default:Math.max(32 MiB, 128 * N * r * 2)version: The password hash format thathashproduces ("v1"or"v2", see Password Hash Format below).Default:"v1"encoding: The serialization used byhash("json"or"phc", see PHC Encoding below).Default:"json"pepper: A secret, application-wide value mixed into the password before hashing (see Pepper below).Default: nonemaxConcurrency: Caps how many scrypt operations this instance runs at once (see Limiting Concurrency below).Default: unlimited
You can pass custom options to configure the scrypt hashing algorithm:
const scrypt = scryptjs({
N: 16384,
r: 8,
p: 2,
keylen: 64,
saltSize: 32,
});Password Hash Format
By default, the hashed password returned by the hash method is a stringified JSON object, whose shape depends on the format version. Passing encoding: "phc" switches to a compact string format instead — see PHC Encoding below.
v1 (default)
version:"v1".hash: The derived key (password hash), represented as a hexadecimal string.salt: A randomly generated salt used during the hashing process, represented as a hexadecimal string.
{
"version": "v1",
"hash": "a3d53f9c5a2bd4711f3e2c9ad760b63eebf4e8b0b7485a5f5c0c9fda9a7c7b5b",
"salt": "5f4dcc3b5aa765d61d8327deb882cf99"
}A v1 hash does not carry its own scrypt cost parameters, so compare uses whichever N, r, p, and keylen the calling scryptjs() instance is configured with. This means those options must stay the same over time for existing v1 hashes to keep verifying correctly.
v2
Pass version: "v2" to scryptjs(options) to opt in:
const scrypt = scryptjs({ version: "v2", N: 16384 });
const hashedPassword = await scrypt.hash("password");version:"v2".hash: The derived key (password hash), represented as a hexadecimal string.salt: A randomly generated salt used during the hashing process, represented as a hexadecimal string.options: TheN,r,p, andkeylenused to produce this hash.
{
"version": "v2",
"hash": "a3d53f9c5a2bd4711f3e2c9ad760b63eebf4e8b0b7485a5f5c0c9fda9a7c7b5b",
"salt": "5f4dcc3b5aa765d61d8327deb882cf99",
"options": { "N": 16384, "r": 8, "p": 1, "keylen": 64 }
}Because the cost parameters travel with the hash, a v2 hash keeps verifying correctly with compare even after you change the scryptjs() instance's default options — useful if you want to raise N over time without invalidating existing password hashes.
compare inspects the version field of the stored hash and picks the right verification path automatically, regardless of how the current scryptjs() instance is configured. This means a single instance can verify both v1 and v2 hashes at once, so you can switch new hashes over to version: "v2" while older v1 hashes already in your database keep working.
Since stored hashes may come from an untrusted source (e.g. a compromised database), compare validates the scrypt options embedded in a v2 hash and rejects values that would make verification unreasonably expensive.
In both cases, the JSON object is stringified into a single string for storage or further use.
PHC Encoding
version (v1/v2) controls whether cost parameters travel with a JSON hash. encoding is a separate, orthogonal choice about how the hash is serialized. Passing encoding: "phc" produces a compact, self-describing string in the style of the PHC string format, instead of JSON:
const scrypt = scryptjs({ encoding: "phc", N: 131072 });
const hashedPassword = await scrypt.hash("password");
// "$scrypt$v=1$ln=17,r=8,p=1$k9F2...$a3d5..."The format is $scrypt$v=1$ln=<log2 N>,r=<r>,p=<p>$<salt>$<hash>, with the salt and derived key base64-encoded. The v=1 segment is this PHC layout's own format version — unrelated to the version option above — reserved so a future revision of the layout (e.g. a different parameter set) can be introduced unambiguously, the same way Argon2's own PHC strings carry a v=19; there is currently only one layout. Because a PHC hash always embeds its cost parameters, it behaves like v2 with respect to compare and needsRehash — there's no v1-style PHC hash, so encoding: "phc" cannot be combined with version: "v1" (scryptjs throws if you try).
compare/compareSync/needsRehash auto-detect the encoding of a stored hash from its first character ({ vs $), so a single instance can verify JSON and PHC hashes side by side — the same way it already handles v1/v2 — which lets you switch encodings without a database migration: both are just strings, so they can share the same column while you progressively rehash old rows to the new encoding. See Choosing a Column Type below for which one to pick.
Choosing a Column Type
- Postgres
JSONBcolumn → useencoding: "json"(the default) — required, in fact: a PHC string isn't valid JSON, so inserting it into aJSONBcolumn fails outright. Pick this if you actually run SQL against the hash's fields, e.g. a migration-progress dashboard:SELECT count(*) FROM users WHERE stored_password->>'version' = 'v1'. TEXT/VARCHARcolumn (works in any database, not just Postgres) → both encodings fit. If you never query the hash's internal fields via SQL, preferencoding: "phc"for the smaller row: atN: 131072, a v1 hash is 228 bytes, a v2 hash is 275 bytes, a PHC hash is 156 bytes — roughly a third to a half smaller.- Not sure yet? Default to a
TEXTcolumn withencoding: "json"(the library default). It's the safest starting point: it keeps theJSONBoption open later without a migration, and switching to"phc"afterward is also migration-free — both formats are just strings that can share one column while old rows get progressively rehashed.
Upgrading Stored Hashes (Progressive Rehashing)
Hashing options apply per scryptjs() instance and define the policy for new hashes, while compare always verifies each stored hash with the parameters it was created with. This means old hashes never break when you strengthen your policy — but they also don't get stronger on their own.
The needsRehash method closes that gap. It reports whether a stored hash was produced under a different policy (an older format version or different cost parameters) than the instance's current one. The standard pattern is to re-hash on successful login, while the plaintext password is available:
const scrypt = scryptjs({ version: "v2", N: 131072 });
async function login(password: string, storedPassword: string) {
const isMatch = await scrypt.compare(password, storedPassword);
if (isMatch && scrypt.needsRehash(storedPassword)) {
const upgraded = await scrypt.hash(password);
// save `upgraded` to the database in place of `storedPassword`
}
return isMatch;
}With this in place, your database migrates itself gradually: every successful login upgrades that user's hash to the current policy — including migrating v1 hashes to v2, or JSON hashes to PHC — without forcing anyone to reset their password.
Note: v1 hashes do not record their cost parameters, so an instance configured for v1 cannot detect a parameter change and
needsRehashreturnsfalsefor them. An instance configured forversion: "v2"reportstruefor every v1 hash. Similarly,needsRehashcannot detect a pepper rotation, since the pepper itself is never stored — rehash proactively if you change it.
Security Recommendations
The default cost parameter N: 4096 is kept for backward compatibility with existing v1 hashes, but it is low by current standards. For new projects, OWASP recommends scrypt with N: 131072 (2^17), r: 8, p: 1:
const scrypt = scryptjs({ version: "v2", N: 131072 });Using version: "v2" together with a raised N is the recommended combination: the cost parameters are stored inside each hash, so you can raise N again later without invalidating existing passwords, and compare keeps verifying old and new hashes alike. Combine it with progressive rehashing to gradually upgrade existing hashes as users log in.
Higher N values cost real time and memory per hashing operation (e.g. N: 131072 takes roughly 300–400 ms and 134 MiB, versus ~15 ms and 4 MiB for the default) — scrypt's point is to make brute-force expensive, so pick the highest value your server comfortably affords. Note that the memory cost applies to each concurrent hashing operation.
Pepper
A pepper is a secret, application-wide value mixed into every password (via HMAC-SHA256) before it reaches scrypt. Unlike the salt, it is never stored alongside the hash — keep it outside the database (an environment variable or secrets manager) so that a database-only leak isn't enough to brute-force the stored hashes; an attacker would also need to compromise the app's secrets.
const scrypt = scryptjs({ pepper: process.env.PASSWORD_PEPPER });
const hashedPassword = await scrypt.hash("password");Generate the pepper itself once, during setup, and store the output in your secrets manager/.env:
import { randomBytes } from "crypto";
// Run once; don't regenerate it, or existing hashes stop verifying
console.log(randomBytes(32).toString("base64"));The pepper must stay the same for a given set of hashes to keep verifying — treat changing it like changing N: pick a new value only if you can rehash everything (progressive rehashing won't detect a pepper rotation on its own, since the pepper is never stored — see the note above).
Avoiding Timing-Based User Enumeration
A common way apps leak whether an account exists: looking up a user, and only calling compare when one is found, makes "wrong password" (a real scrypt run) measurably slower than "no such user" (an instant false). dummyCompare closes that gap — it runs a real scrypt operation at this instance's configured cost and always resolves false, so you can give both cases the same latency profile:
async function login(email: string, password: string) {
const user = await findUserByEmail(email);
if (!user) {
await scrypt.dummyCompare();
return false;
}
return scrypt.compare(password, user.storedPassword);
}Limiting Concurrency
scrypt/scryptSync run on Node's libuv threadpool (4 threads by default — the same pool shared by fs, dns.lookup, zlib, and other crypto calls across your whole process). Left unbounded, a burst of concurrent calls — a login spike, or an attacker deliberately flooding a login/register endpoint — submits that many scrypt jobs to the threadpool's queue all at once, ahead of any unrelated work submitted after them; every other part of your app that also needs the threadpool then waits behind the flood, not just the login path. maxConcurrency caps how many scrypt operations this instance submits at once; extra calls to hash, compare, and dummyCompare queue (FIFO) in-process until a slot frees up, leaving threadpool room for everything else:
const scrypt = scryptjs({ N: 131072, maxConcurrency: 8 });This has no effect on hashSync/compareSync, which block the event loop for their entire duration regardless.
Synchronous API
hashSync and compareSync mirror hash and compare without the Promise wrapper, using Node's scryptSync under the hood. They're a better fit for one-off scripts (seeding data, a CLI tool) than for request-handling code, since they block the event loop for the full duration of the scrypt computation:
const hashedPassword = scrypt.hashSync("password");
const isMatch = scrypt.compareSync("password", hashedPassword);Error Handling
compare/compareSync resolve to true or false for well-formed stored hashes — including the case where the stored hash was created with a different keylen (it resolves to false). They reject/throw with:
Error("Invalid hash format")— the stored password is not valid JSON or PHC, is missing thehashorsaltfields, or carries missing, malformed, or unreasonably expensive scrypt options.Error("Unsupported password version")— the JSONversionfield is not one of the supported versions.
needsRehash throws the same errors for a malformed stored password.
scryptjs(options) itself throws synchronously if the options are contradictory or invalid — e.g. encoding: "phc" combined with version: "v1", or a maxConcurrency that isn't a positive integer.
Framework Integration
A typical register/login flow, wired into each framework's routing. All three follow the same shape: hash on register, compare + dummyCompare on login, needsRehash to upgrade opportunistically.
Express
import express from "express";
import scryptjs from "@vladimir-plakhotnik/scryptjs";
const app = express();
app.use(express.json());
const scrypt = scryptjs({ version: "v2", N: 131072 });
app.post("/register", async (req, res) => {
const { email, password } = req.body;
const storedPassword = await scrypt.hash(password);
const user = await db.users.create({ email, storedPassword });
res.status(201).json({ id: user.id });
});
app.post("/login", async (req, res) => {
const { email, password } = req.body;
const user = await db.users.findByEmail(email);
if (!user) {
await scrypt.dummyCompare();
return res.status(401).json({ error: "Invalid credentials" });
}
if (!(await scrypt.compare(password, user.storedPassword))) {
return res.status(401).json({ error: "Invalid credentials" });
}
if (scrypt.needsRehash(user.storedPassword)) {
const upgraded = await scrypt.hash(password);
await db.users.update(user.id, { storedPassword: upgraded });
}
res.json({ id: user.id });
});Fastify
import Fastify from "fastify";
import scryptjs from "@vladimir-plakhotnik/scryptjs";
const fastify = Fastify();
// maxConcurrency caps how many scrypt operations run at once, so a burst of
// requests to these routes can't starve the rest of the app — see Limiting
// Concurrency above.
const scrypt = scryptjs({ version: "v2", N: 131072, maxConcurrency: 8 });
fastify.post("/register", async (request, reply) => {
const { email, password } = request.body as {
email: string;
password: string;
};
const storedPassword = await scrypt.hash(password);
const user = await db.users.create({ email, storedPassword });
reply.code(201).send({ id: user.id });
});
fastify.post("/login", async (request, reply) => {
const { email, password } = request.body as {
email: string;
password: string;
};
const user = await db.users.findByEmail(email);
if (!user) {
await scrypt.dummyCompare();
return reply.code(401).send({ error: "Invalid credentials" });
}
if (!(await scrypt.compare(password, user.storedPassword))) {
return reply.code(401).send({ error: "Invalid credentials" });
}
if (scrypt.needsRehash(user.storedPassword)) {
const upgraded = await scrypt.hash(password);
await db.users.update(user.id, { storedPassword: upgraded });
}
reply.send({ id: user.id });
});NestJS
import { Injectable, UnauthorizedException } from "@nestjs/common";
import scryptjs from "@vladimir-plakhotnik/scryptjs";
@Injectable()
export class AuthService {
private readonly scrypt = scryptjs({ version: "v2", N: 131072 });
constructor(private readonly usersService: UsersService) {}
async register(email: string, password: string) {
const storedPassword = await this.scrypt.hash(password);
return this.usersService.create({ email, storedPassword });
}
async validateUser(email: string, password: string) {
const user = await this.usersService.findByEmail(email);
if (!user) {
await this.scrypt.dummyCompare();
throw new UnauthorizedException();
}
if (!(await this.scrypt.compare(password, user.storedPassword))) {
throw new UnauthorizedException();
}
if (this.scrypt.needsRehash(user.storedPassword)) {
const upgraded = await this.scrypt.hash(password);
await this.usersService.update(user.id, { storedPassword: upgraded });
}
return user;
}
}FAQ
How do I hash passwords in Node.js without bcrypt or a native addon?
That's this library's whole point — see Why scrypt Instead of bcrypt? above. Everything runs on Node's built-in crypto module: no native compilation step, no extra runtime dependency.
I'm getting Invalid scrypt params / ERR_CRYPTO_INVALID_SCRYPT_PARAMS from node:crypto.
That's Node's own crypto.scrypt rejecting a maxmem too small for the N/r you asked for (it needs 128 * N * r bytes). This library computes maxmem for you automatically from whichever N/r you configure, so using scryptjs() instead of calling crypto.scrypt directly avoids this entirely.
Can I migrate my existing bcrypt hashes to scrypt?
Not automatically — no library can convert a hash without the plaintext password, by design. What works instead is the same progressive rehashing pattern this README already uses for scrypt's own v1→v2 migration: on a successful login, detect the stored hash's format (bcrypt hashes start with $2a$/$2b$/$2y$; this library's hashes start with { or $scrypt$), verify with whichever library matches, then re-hash with scryptjs and save that instead. Existing users migrate the next time they log in — nobody resets a password.
Does this work outside Node.js (browser, Deno, Bun, edge runtimes)?
It wraps node:crypto's scrypt, so it needs that module. Deno and Bun both aim for node:crypto compatibility and should work in practice; browsers and edge runtimes (Cloudflare Workers, Vercel Edge) generally don't expose node:crypto at all, so this won't run there.
Examples
The project includes examples that demonstrate how to use the library. These examples are located in the example folder of the project.
How to Run the Examples
If you have cloned the repository locally, you can run the examples as follows:
- Build the Project.Ensure the TypeScript source files are compiled:
npm run build- Run the example.Use
ts-nodeto execute the example:
npx ts-node example/usage.tsLicense
This library is licensed under the MIT License.
