DEV Community

Shaswat Choudhary
Shaswat Choudhary

Posted on

Why bcrypt Is Not Enough in 2026 And What We Built Instead


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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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
});
Enter fullscreen mode Exit fullscreen mode

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
  }
);
Enter fullscreen mode Exit fullscreen mode

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" }
  ]
}
Enter fullscreen mode Exit fullscreen mode

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
});
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 });
Enter fullscreen mode Exit fullscreen mode

Every error serializes to the same shape:

{
  "status":  409,
  "code":    "CONFLICT",
  "message": "Email already registered",
  "meta":    { "field": "email" }
}
Enter fullscreen mode Exit fullscreen mode

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.",
  });
});
Enter fullscreen mode Exit fullscreen mode

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}`);
});
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)