The Story Behind This Package
Every time I started a new Node.js backend project, I found myself doing the same thing.
npm install bcrypt
npm install joi
# copy-paste AppError class from some tutorial
Three packages. Three different docs. Three different ways of doing the same things. And I did this in every single project — and so did every developer I knew.
Me and my friend Ubed decided to fix this. We built nodearmor —a single npm package that replaces all of that with one install.
npm install nodearmor
This is the story of why we built it, what problem each module solves, and what we learned publishing our first open source npm package.
The Problem With bcrypt in 2026
Let me start with the biggest one — password hashing.
bcrypt has been the default answer to "how do I hash passwords in Node.js" for over 15 years. Search that question right now. bcrypt is in the first three results. Every tutorial uses it. Every project copy-pastes it.
Here is the problem nobody talks about. bcrypt was designed in 1999.
In 1999, hashing was done on CPUs. CPUs had one or two cores. An attacker trying to crack a stolen database of bcrypt hashes could try maybe 100 passwords per second. At that speed, cracking a strong password would take years.
In 2025, attackers use GPUs. A modern GPU has 4,000 to 10,000 cores. Every core can attempt a bcrypt hash simultaneously. That 100 attempts per second becomes 400,000 attempts per second. The slowness that bcrypt provides is completely parallelized away.
bcrypt is not broken. But it is not the best we can do in 2025.
The Solution — Argon2id
Argon2id won the international Password Hashing Competition in 2015 — a competition run by cryptography researchers specifically to find the best password hashing algorithm for the modern era.
The key difference is that Argon2id is memory-hard.
Every single hash attempt requires a large, fixed amount of RAM — 64MB by default. You cannot skip the RAM requirement. The algorithm is mathematically designed so that without the full 64MB, the computation cannot complete.
Here is the math that matters:
bcrypt attack in 2025:
GPU with 4,000 cores — 400,000 parallel attacks per second
Argon2id attack in 2025:
GPU with 16,000 MB of RAM
16,000 MB ÷ 64 MB per attempt = 250 parallel attacks maximum
The attacker with Argon2id needs 16 times more GPUs to achieve the same attack speed they had with bcrypt. GPUs cost money. Electricity costs money. The attack becomes economically impractical.
OWASP — the Open Web Application Security Project — updated their recommendation in 2025. Argon2id is their first choice for password hashing. bcrypt is listed as acceptable only if Argon2id is not available.
What nodearmor Does
nodearmor bundles four modules that every serious Node.js backend needs:
| Module | Replaces | What It Does |
|---|---|---|
| nodearmor/env | dotenv + manual checks | Type-safe env validation at startup |
| nodearmor/hash | bcrypt | Argon2id password hashing |
| nodearmor/guard | joi / express-validator | Zod-powered request validation |
| nodearmor/errors | copy-pasted AppError | Typed HTTP error classes |
Let me walk through each one.
Module 1 — env
The Problem
What happens when your app starts without a DATABASE_URL?
It starts fine. No errors at boot time. Then 10 minutes later, when it tries to connect to the database, it crashes — with an error that points to a database file, not the missing environment variable. You spend 20 minutes debugging something that should have been caught immediately.
The Solution
nodearmor/env validates every environment variable at startup. If anything is missing or wrong, the process exits immediately before the server starts — with a clear message listing every problem at once.
import { envault } from "nodearmor/env";
export const env = envault({
DATABASE_URL: { type: "string", message: "Set DATABASE_URL to your PostgreSQL connection string" },
PORT: { type: "number", default: 3000, min: 1000, max: 65535 },
DEBUG: { type: "boolean", default: false },
NODE_ENV: { type: "string", enum: ["development", "production", "test"] },
API_BASE_URL: { type: "url" },
ADMIN_EMAIL: { type: "email" },
STRIPE_KEY: { type: "string", required: false },
});
// All values are correctly typed — no casting needed anywhere
env.PORT // number
env.DEBUG // boolean
env.NODE_ENV // string
env.STRIPE_KEY // string | undefined
When validation fails, you see this — all problems at once:
nodearmor/env — validation failed:
x Missing required variable: "DATABASE_URL"
x "PORT" must be >= 1000, got: 80
x "NODE_ENV" must be one of [development, production, test], got: "staging"
Fix everything. Restart once. No more guessing.
What We Overcame
Before envault, developers wrote this in every project:
if (!process.env.DATABASE_URL) {
console.error("DATABASE_URL is required");
process.exit(1);
}
const PORT = parseInt(process.env.PORT || "3000");
if (isNaN(PORT)) {
console.error("PORT must be a number");
process.exit(1);
}
15 lines of boilerplate that grows with every new variable. No TypeScript types. No defaults. No format validation. envault replaces all of it with a clean schema definition.
Module 2 — hash
The Problem
bcrypt — already covered above. CPU-only, 1999, vulnerable to modern GPU attacks.
The Solution
nodearmor/hash wraps Argon2id with OWASP 2025 recommended defaults baked in. The API is almost identical to bcrypt.
import { hash, verify, needsRehash } from "nodearmor/hash";
// REGISTER — hash before storing in database
const passwordHash = await hash(plainPassword);
// Returns: "$argon2id$v=19$m=65536,t=3,p=1$..."
// LOGIN — verify against stored hash
const isValid = await verify(passwordHash, plainPassword);
// Returns: true or false
// MAINTENANCE — upgrade old hashes automatically
if (await needsRehash(passwordHash)) {
const newHash = await hash(plainPassword);
await db.users.updateHash(userId, newHash);
}
Notice the hash string that comes back — $argon2id$v=19$m=65536,t=3,p=1$.... It is self-describing. The algorithm name, version, all parameters, and the random salt are all embedded in the string. You never need to store the salt separately. You never need to remember what parameters you used.
Migrating From bcrypt
You do not need to force users to reset their passwords. The migration is transparent — users upgrade automatically on their next successful login.
import bcrypt from "bcrypt";
import { hash, verify } from "nodearmor/hash";
async function login(email: string, plainPassword: string) {
const user = await db.users.findByEmail(email);
let isValid = false;
if (user.passwordHash.startsWith("$2b$")) {
// old bcrypt hash — verify with bcrypt
isValid = await bcrypt.compare(plainPassword, user.passwordHash);
if (isValid) {
// silently upgrade to Argon2id on next login
await db.users.updateHash(user.id, await hash(plainPassword));
}
} else {
// new Argon2id hash
isValid = await verify(user.passwordHash, plainPassword);
}
if (!isValid) throw new Unauthorized("Invalid credentials");
return issueToken(user);
}
Module 3 — guard
The Problem
Without validation middleware, every developer writes this in every route:
app.post("/register", async (req, res) => {
if (!req.body.email || !req.body.email.includes("@")) {
return res.status(400).json({ error: "Invalid email" });
}
if (!req.body.password || req.body.password.length < 8) {
return res.status(400).json({ error: "Password too short" });
}
// ... 10 more lines of manual checks
// ... finally the actual route logic
});
10 to 15 lines of boilerplate. No TypeScript types on req.body. Copy-pasted into every route. Different format in every project. Frontend developers do not know what error shape to expect.
The Solution
nodearmor/guard takes a Zod schema and returns an Express middleware. One line. Validation runs before your route handler. req.body is fully typed.
import { guard } from "nodearmor/guard";
import { z } from "nodearmor";
const RegisterSchema = z.object({
email: z.string().email(),
password: z.string().min(8),
name: z.string().min(1).max(100),
});
app.post("/register",
guard(RegisterSchema), // one line — validation done
async (req, res) => {
const { email, password, name } = req.body; // fully typed
// no validation code here — it already ran
}
);
When validation fails, the response is always the same structure:
{
"status": 400,
"code": "VALIDATION_FAILED",
"message": "Request validation failed",
"issues": [
{ "field": "email", "message": "Invalid email", "code": "invalid_string" },
{ "field": "password", "message": "String must contain at least 8 character(s)", "code": "too_small" }
]
}
Same shape. Every route. Every project. Your frontend writes one error handler and it works everywhere.
You can also validate query parameters and URL parameters:
// Validate query params — GET /users?page=2&limit=50
const PaginationSchema = z.object({
page: z.coerce.number().min(1).default(1),
limit: z.coerce.number().min(1).max(100).default(20),
});
app.get("/users", guard(PaginationSchema, "query"), async (req, res) => {
const { page, limit } = req.query; // real numbers, not strings
});
// Validate URL params — GET /users/:id
const IdSchema = z.object({
id: z.string().uuid("User ID must be a valid UUID"),
});
app.get("/users/:id", guard(IdSchema, "params"), async (req, res) => {
const { id } = req.params; // guaranteed to be a valid UUID
});
Module 4 — errors
The Problem
Search any Node.js project on GitHub. You will find one of these patterns:
// Pattern 1 — magic numbers everywhere
return res.status(404).json({ error: "User not found" });
// Pattern 2 — copy-pasted AppError class
class AppError extends Error {
constructor(message, statusCode) {
super(message);
this.statusCode = statusCode; // sometimes 'status', sometimes 'statusCode'
}
}
// Pattern 3 — everyone writes it differently
throw new AppError("User not found", 404); // no code field, no meta, no types
Every project looks different. Frontend developers write defensive code trying to handle every possible error shape.
The Solution
nodearmor/errors provides typed HTTP error classes that always produce the same JSON structure.
import {
NotFound, Conflict, Unauthorized,
Forbidden, TooManyRequests,
isApiError, toResponse,
} from "nodearmor/errors";
// In your routes — one line, fully typed
throw new NotFound("User not found", { userId: req.params.id });
throw new Conflict("Email already registered", { field: "email" });
throw new Unauthorized("Token expired or invalid");
throw new Forbidden("Admin access required");
throw new TooManyRequests("Rate limit exceeded", { retryAfter: 60 });
Every error serializes to the same shape:
{
"status": 409,
"code": "CONFLICT",
"message": "Email already registered",
"meta": { "field": "email" }
}
Write one error handler for your entire app:
import { isApiError, toResponse } from "nodearmor/errors";
app.use((err, req, res, next) => {
if (isApiError(err)) {
return res.status(err.status).json(toResponse(err));
}
// unknown error — log internally, hide details from client
// stack trace is never sent to clients — security best practice
console.error("[UNHANDLED ERROR]", err);
res.status(500).json({
status: 500,
code: "INTERNAL_SERVER_ERROR",
message: "Something went wrong. Please try again.",
});
});
A Complete Working Example
Here is a full register and login flow using all four modules:
// src/env.ts
import { envault } from "nodearmor/env";
export const env = envault({
DATABASE_URL: { type: "string" },
PORT: { type: "number", default: 3000 },
JWT_SECRET: { type: "string" },
NODE_ENV: { type: "string", enum: ["development", "production", "test"] },
});
// src/routes/auth.ts
import { Router } from "express";
import { z } from "nodearmor";
import { hash, verify } from "nodearmor/hash";
import { guard } from "nodearmor/guard";
import { Conflict, Unauthorized } from "nodearmor/errors";
const router = Router();
const RegisterSchema = z.object({
email: z.string().email(),
password: z.string().min(8),
name: z.string().min(1),
});
const LoginSchema = z.object({
email: z.string().email(),
password: z.string().min(1),
});
router.post("/register", guard(RegisterSchema), async (req, res) => {
const { email, password, name } = req.body;
const existing = await db.users.findByEmail(email);
if (existing) throw new Conflict("Email already registered", { field: "email" });
const passwordHash = await hash(password);
const user = await db.users.create({ email, name, password: passwordHash });
res.status(201).json({ id: user.id, email: user.email });
});
router.post("/login", guard(LoginSchema), async (req, res) => {
const { email, password } = req.body;
const user = await db.users.findByEmail(email);
if (!user) throw new Unauthorized("Invalid email or password");
const isValid = await verify(user.passwordHash, password);
if (!isValid) throw new Unauthorized("Invalid email or password");
const token = jwt.sign({ userId: user.id }, env.JWT_SECRET);
res.json({ token });
});
export default router;
// src/app.ts
import express from "express";
import { env } from "./env";
import { errorHandler } from "./middleware/errorHandler";
import authRoutes from "./routes/auth";
const app = express();
app.use(express.json());
app.use("/auth", authRoutes);
app.use(errorHandler);
app.listen(env.PORT, () => {
console.log(`[${env.NODE_ENV}] Server running on port ${env.PORT}`);
});
Why One Package Instead of Three
| bcrypt + joi + AppError | nodearmor | |
|---|---|---|
| Install | Three separate commands | npm install nodearmor |
| Extra dependencies | Manual peer dep installs | Everything included |
| Versions to track | Three changelogs | One |
| TypeScript support | Mixed — some need @types | Native throughout |
| Password algorithm | bcrypt — 1999, CPU-only | Argon2id — 2025, memory-hard |
| Error response shape | Different in every project | Always status + code + message + meta |
| Validation boilerplate | 10-15 lines per route | One line per route |
Security
nodearmor v1.0.5 passes npm audit with zero vulnerabilities.
Argon2id default parameters follow OWASP 2025 recommendations:
Memory cost: 64 MB per hash attempt
Time cost: 3 iterations
Parallelism: 1 thread
Stack traces from your server are never included in HTTP error responses.
How to Get Started
npm install nodearmor
That is it. All dependencies are included automatically. No peer dependency installs needed.
Full documentation and source code:
If you find a bug or want to contribute, open an issue or pull request on GitHub. We review everything.
Final Thought
bcrypt still works. We are not saying it is broken. We are saying that in 2025, with GPU attacks being as cheap and accessible as they are, there is a better option that takes the same amount of code to use.
Argon2id is that option. nodearmor makes it the default.
One install. Four protections. Zero excuses for insecure backends.
Built by Shaswat and Ubed- real open source package.
If this helped you, share it with a developer who is still using bcrypt.
Top comments (0)