Password Hashing & Sessions

Node.js · Lesson 21 of 30 8 min read Updated

Almost every app needs user accounts: a student portal, an online shop, a clinic's appointment system. Two questions come up straight away. How do you store passwords so that a database leak does not expose them? And how does the server remember that a user has logged in, when HTTP itself forgets everything between requests?

This lesson answers both. You will hash passwords with Argon2 (and see bcrypt and the built-in scrypt), build register, login and logout routes, and keep users logged in with sessions and secure cookies. The next lesson shows the other common approach, tokens with JWT.

Never store plain passwords

If you save passwords as plain text, anyone who sees the database (an attacker, a careless backup, a curious employee) sees every password. Because people reuse passwords, this also exposes their email and bank accounts.

Encryption is not the answer either, because whoever steals the key can decrypt everything. Instead, store a hash: the output of a one-way function. You cannot turn a hash back into the password. At login, you hash what the user typed and compare.

Ordinary hash functions such as SHA-256 are the wrong tool. They are designed to be fast, so an attacker with a graphics card can try billions of guesses per second, and the same password always gives the same hash:

JavaScript
import { createHash } from "node:crypto";
console.log(createHash("sha256").update("123456").digest("hex"));
Output
8d969eef6ecad3c29a3a629280e686cf0c3f5d5a86aff3ca12020c923adc6c92

Search for that hash online and you will find "123456" in seconds, because attackers keep huge tables of pre-computed hashes. Password hashing needs two extra things:

  • A salt: random bytes mixed into each password before hashing, so two users with the same password get different hashes and pre-computed tables are useless.
  • A slow, memory-hard function that makes every guess expensive.

Argon2id is the current first choice (it won the Password Hashing Competition and is the top recommendation of OWASP, the web security community). scrypt and bcrypt are also acceptable.

Hashing with Argon2

Install the argon2 package (it ships ready-built binaries for common systems):

Terminal
npm install argon2
JavaScript
import argon2 from "argon2";

const password = "Chai@Pune2026";

const hash1 = await argon2.hash(password);
const hash2 = await argon2.hash(password);
console.log(hash1);
console.log(hash1 === hash2); // different salt each time

console.log(await argon2.verify(hash1, password));
console.log(await argon2.verify(hash1, "chai@pune2026"));
Output
$argon2id$v=19$m=65536,p=4,t=3$uvBUK6nDHcE3+beRCoKvFg$s5rIdtsQ5Yhe+xxQdYZ07lFNPlP9tk6x/nFwkHFF9TU
false
true
false

The hash string contains everything needed to check it later: the algorithm (argon2id), the cost settings (64 MiB of memory, 3 passes, 4 lanes), the salt, and the result. You store this one string in a column such as password_hash VARCHAR(255). verify() reads the settings and salt from it, so you never manage salts yourself.

bcrypt and the built-in scrypt

bcrypt has been the standard for many years and you will see it in most existing projects. Its API is almost the same:

JavaScript
import bcrypt from "bcrypt";

const hash = await bcrypt.hash("Chai@Pune2026", 12); // 12 = cost factor
console.log(hash);
console.log(await bcrypt.compare("Chai@Pune2026", hash));
Output
$2b$12$z2ahag7cUViP2wgaeRqbPOj0HNNzM6TDNl4mqweuqwc6JNL906T.a
true

bcrypt only uses the first 72 bytes of a password, so very long passphrases are cut short. If you cannot install native packages, Node.js has scrypt built into node:crypto. You then store the salt yourself and compare with timingSafeEqual(), which takes the same time whether the first byte or the last byte differs, so attackers cannot learn anything from response times:

JavaScript
import { scrypt, randomBytes, timingSafeEqual } from "node:crypto";
import { promisify } from "node:util";

const scryptAsync = promisify(scrypt);

async function hashPassword(password) {
  const salt = randomBytes(16);
  const key = await scryptAsync(password, salt, 64);
  return `${salt.toString("hex")}:${key.toString("hex")}`;
}

async function verifyPassword(stored, password) {
  const [saltHex, keyHex] = stored.split(":");
  const key = await scryptAsync(password, Buffer.from(saltHex, "hex"), 64);
  return timingSafeEqual(key, Buffer.from(keyHex, "hex"));
}

How sessions keep you logged in

HTTP is stateless: each request arrives on its own, with no memory of earlier ones. A session fixes this:

  1. When the user logs in, the server creates a session record (for example { userEmail: "meera@example.in" }) and gives it a long random session ID.
  2. The server sends the ID to the browser in a cookie, a small named value the browser stores and sends back automatically with every later request to the same site.
  3. On each request, the server reads the cookie, finds the session record and knows who the user is.

The user's data stays on the server; the browser only holds a meaningless ID. The express-session middleware does all of this:

Terminal
npm install express-session

A complete login system

JavaScript
import express from "express";
import session from "express-session";
import argon2 from "argon2";

const app = express();
app.use(express.json());

app.use(session({
  name: "sid",
  secret: process.env.SESSION_SECRET,
  resave: false,
  saveUninitialized: false,
  cookie: {
    httpOnly: true,
    secure: process.env.NODE_ENV === "production",
    sameSite: "lax",
    maxAge: 1000 * 60 * 60 * 2, // 2 hours
  },
}));

// A tiny "users table". Real apps store this in a database.
const users = new Map();

app.post("/api/register", async (req, res) => {
  const { email, password } = req.body;
  if (!email || !password || password.length < 8) {
    return res.status(400).json({ error: "Email and a password of 8+ characters are required" });
  }
  if (users.has(email)) return res.status(409).json({ error: "Email already registered" });
  users.set(email, { email, passwordHash: await argon2.hash(password) });
  res.status(201).json({ email });
});

app.post("/api/login", async (req, res) => {
  const { email, password } = req.body;
  const user = users.get(email);
  const ok = user && (await argon2.verify(user.passwordHash, password));
  if (!ok) return res.status(401).json({ error: "Wrong email or password" });

  req.session.regenerate((err) => {      // new session id after login
    if (err) return res.status(500).json({ error: "Login failed" });
    req.session.userEmail = user.email;
    res.json({ message: `Welcome, ${user.email}` });
  });
});

function requireLogin(req, res, next) {
  if (!req.session.userEmail) return res.status(401).json({ error: "Please log in" });
  next();
}

app.get("/api/profile", requireLogin, (req, res) => {
  res.json({ email: req.session.userEmail });
});

app.post("/api/logout", (req, res) => {
  req.session.destroy(() => {
    res.clearCookie("sid");
    res.json({ message: "Logged out" });
  });
});

app.listen(3000);

Run it with a SESSION_SECRET in your .env file. A test script that stores the cookie like a browser does prints:

Output
POST /register -> 201 {"email":"meera@example.in"}
GET /profile -> 401 {"error":"Please log in"}
POST /login -> 401 {"error":"Wrong email or password"}
POST /login -> 200 {"message":"Welcome, meera@example.in"}
  Set-Cookie: sid=s%3A...; Path=/; Expires=Fri, 30 Oct 2026 12:15:40 GMT; HttpOnly; SameSite=Lax
GET /profile -> 200 {"email":"meera@example.in"}
POST /logout -> 200 {"message":"Logged out"}
  Set-Cookie: sid=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT
GET /profile -> 401 {"error":"Please log in"}

The important settings and choices:

  • secret signs the cookie so it cannot be forged. Use a long random value from the environment.
  • resave: false, saveUninitialized: false avoid saving empty sessions for every visitor.
  • httpOnly stops JavaScript in the page from reading the cookie, so a cross-site scripting bug cannot steal it.
  • secure sends the cookie only over HTTPS. Turn it on in production (and see trust proxy in Deployment).
  • sameSite: "lax" stops most cross-site requests from carrying the cookie, which blocks most CSRF (cross-site request forgery) attacks.
  • regenerate() gives a new session ID at login. This blocks session fixation, where an attacker plants a known ID before the victim logs in.
  • The same message for a wrong email and a wrong password, so attackers cannot find out which emails are registered.

Common mistakes

  • Storing plain, MD5 or SHA-256 passwords. Use Argon2id, scrypt or bcrypt.
  • Writing the session secret in code. Load it from the environment.
  • Leaving secure: false in production. Cookies would travel over plain HTTP.
  • Different error messages for "no such user" and "wrong password". Use one message.
  • Using the memory store in production. Choose a database-backed store.
  • No limit on login attempts. Add rate limiting (see Security Best Practices) so attackers cannot guess passwords endlessly.

Key points

  • Store only password hashes, made with a slow, salted algorithm: Argon2id first, scrypt or bcrypt also fine.
  • argon2.hash() and argon2.verify() handle salts and settings inside one stored string.
  • Sessions keep user data on the server and give the browser a random ID in a cookie.
  • Configure session cookies with httpOnly, secure (in production), sameSite and a maxAge.
  • Call req.session.regenerate() at login and req.session.destroy() at logout.
  • Use a persistent session store such as Redis or MySQL in production.

Exercise

Extend the login example with a POST /api/change-password route for logged-in users. It takes currentPassword and newPassword. Return 401 if the current password is wrong, 400 if the new one is shorter than 8 characters or the same as the current one, and otherwise save the new hash and reply { "message": "Password changed" }.

Show solution
JavaScript
app.post("/api/change-password", requireLogin, async (req, res) => {
  const { currentPassword, newPassword } = req.body;
  const user = users.get(req.session.userEmail);

  if (!(await argon2.verify(user.passwordHash, currentPassword ?? ""))) {
    return res.status(401).json({ error: "Current password is wrong" });
  }
  if (typeof newPassword !== "string" || newPassword.length < 8 || newPassword === currentPassword) {
    return res.status(400).json({ error: "Choose a new password of at least 8 characters" });
  }

  user.passwordHash = await argon2.hash(newPassword);
  res.json({ message: "Password changed" });
});
Output
POST /change-password -> 401 {"error":"Current password is wrong"}
POST /change-password -> 400 {"error":"Choose a new password of at least 8 characters"}
POST /change-password -> 200 {"message":"Password changed"}

requireLogin makes sure only a logged-in user can call the route, and asking for the current password again protects users who left their laptop unlocked.

Quick Quiz

1. Why is SHA-256 a poor choice for storing passwords?

2. What does a salt do?

3. What does the httpOnly cookie option prevent?

4. Why call req.session.regenerate() when a user logs in?

Learn Node.js with a mentor

Free lessons get you started. Our Full Stack Web Development training adds real projects and code reviews from working developers.

  • Project-based learning
  • Mentor code reviews
  • Free counselling call