Skip to main content

Express Password Hashing

When building web applications that require user authentication, storing passwords securely is critical. In this guide, we'll explore how to implement password hashing in Express.js applications to ensure your users' credentials remain protected even if your database is compromised.

What is Password Hashing?

Password hashing is the process of transforming a plain text password into a fixed-length string of characters that appears random. This transformation is performed using a mathematical algorithm designed to be:

  • One-way: You cannot reverse-engineer the original password from the hash
  • Deterministic: The same password will always produce the same hash (given the same algorithm and settings)
  • Unique: Different passwords should produce different hashes (minimizing "collisions")

Why Hash Passwords?

Imagine storing user passwords as plain text in your database. If a malicious actor gains access to your database, they immediately know all your users' passwords. Since many people reuse passwords across sites, this could compromise your users' accounts on other platforms as well.

By storing hashed passwords instead, even if your database is compromised, the attacker only obtains the hashes - not the original passwords.

Getting Started with Password Hashing in Express

In Express applications, the most commonly used library for password hashing is bcrypt. Let's start by setting up our project and installing the necessary dependencies.

Setting Up Your Express Project

First, create a new Express project or use an existing one:

bash
mkdir express-auth-demo
cd express-auth-demo
npm init -y
npm install express mongoose bcrypt

Creating the User Model

Let's create a basic user model with Mongoose that includes password hashing:

javascript
// models/User.js
const mongoose = require('mongoose');
const bcrypt = require('bcrypt');

const SALT_WORK_FACTOR = 10;

const UserSchema = new mongoose.Schema({
username: {
type: String,
required: true,
unique: true
},
email: {
type: String,
required: true,
unique: true
},
password: {
type: String,
required: true
},
createdAt: {
type: Date,
default: Date.now
}
});

// Pre-save middleware to hash password
UserSchema.pre('save', async function(next) {
const user = this;

// Only hash the password if it's modified (or new)
if (!user.isModified('password')) return next();

try {
// Generate a salt
const salt = await bcrypt.genSalt(SALT_WORK_FACTOR);

// Hash the password along with the new salt
const hash = await bcrypt.hash(user.password, salt);

// Override the plain text password with the hashed one
user.password = hash;
next();
} catch (err) {
next(err);
}
});

// Method to compare passwords
UserSchema.methods.comparePassword = async function(candidatePassword) {
return bcrypt.compare(candidatePassword, this.password);
};

module.exports = mongoose.model('User', UserSchema);

Understanding the Hashing Process

Let's break down what's happening in the code above:

  1. Salt Generation: bcrypt.genSalt(SALT_WORK_FACTOR) creates a random string (called a "salt") that will be incorporated into the hash. The salt work factor (10 in our example) determines how computationally intensive the hashing will be.

  2. Password Hashing: bcrypt.hash(user.password, salt) combines the plain text password with the salt and runs it through the hashing algorithm.

  3. Storage: We store only the hashed version in the database, never the original password.

  4. Password Comparison: The comparePassword method securely compares a provided password against the stored hash without ever exposing the original password.

Implementing User Registration with Password Hashing

Now let's create a registration route that leverages our model's built-in password hashing:

javascript
// app.js
const express = require('express');
const mongoose = require('mongoose');
const User = require('./models/User');

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

// Connect to MongoDB
mongoose.connect('mongodb://localhost:27017/auth-demo', {
useNewUrlParser: true,
useUnifiedTopology: true
})
.then(() => console.log('MongoDB connected'))
.catch(err => console.error('MongoDB connection error:', err));

// User registration route
app.post('/api/register', async (req, res) => {
try {
const { username, email, password } = req.body;

// Create new user with plain text password
// (The model's pre-save middleware will handle hashing)
const user = new User({
username,
email,
password // This will be hashed automatically before saving
});

await user.save();

// Don't send the password back in the response
res.status(201).json({
message: 'User registered successfully',
user: {
id: user._id,
username: user.username,
email: user.email
}
});
} catch (err) {
res.status(500).json({
message: 'Error registering user',
error: err.message
});
}
});

// User login route
app.post('/api/login', async (req, res) => {
try {
const { username, password } = req.body;

// Find the user
const user = await User.findOne({ username });

if (!user) {
return res.status(401).json({ message: 'Authentication failed' });
}

// Compare provided password with stored hash
const isMatch = await user.comparePassword(password);

if (!isMatch) {
return res.status(401).json({ message: 'Authentication failed' });
}

// Authentication successful
res.json({
message: 'Authentication successful',
user: {
id: user._id,
username: user.username,
email: user.email
}
});
} catch (err) {
res.status(500).json({
message: 'Error during authentication',
error: err.message
});
}
});

const PORT = process.env.PORT || 3000;
app.listen(PORT, () => console.log(`Server running on port ${PORT}`));

How It Works: Registration and Login Flow

Registration Flow:

  1. User submits their username, email, and password
  2. The server creates a new User instance with the plain text password
  3. Before saving to the database, the pre('save') middleware:
    • Generates a unique salt
    • Combines the salt with the password and hashes them
    • Replaces the plain text password with the hash
  4. Only the hashed password is stored in the database
  5. The server returns a success response (without the password)

Input:

json
{
"username": "johndoe",
"email": "[email protected]",
"password": "securepassword123"
}

Output:

json
{
"message": "User registered successfully",
"user": {
"id": "60d21b4667d0d8992e610c85",
"username": "johndoe",
"email": "[email protected]"
}
}

Login Flow:

  1. User submits their username and password
  2. The server finds the user by username
  3. The comparePassword method:
    • Takes the submitted plain text password
    • Hashes it using the same salt that was used during registration
    • Compares the resulting hash with the stored hash
  4. If the hashes match, authentication is successful
  5. The server returns a success response (again, without the password)

Input:

json
{
"username": "johndoe",
"password": "securepassword123"
}

Output (successful login):

json
{
"message": "Authentication successful",
"user": {
"id": "60d21b4667d0d8992e610c85",
"username": "johndoe",
"email": "[email protected]"
}
}

Best Practices for Password Hashing

To ensure maximum security when implementing password hashing:

  1. Never store plain text passwords: This might seem obvious, but it's the most critical rule.

  2. Use a strong hashing algorithm: Bcrypt, Argon2, or PBKDF2 are recommended. Avoid using outdated algorithms like MD5 or SHA-1.

  3. Include a unique salt for each user: This prevents attackers from using precomputed tables (rainbow tables) to crack multiple passwords at once.

  4. Set an appropriate work factor: Higher work factors make the hashing process slower and more secure against brute force attacks, but too high might affect your server's performance. Adjust based on your hardware capabilities.

  5. Keep your hashing library updated: Security vulnerabilities are discovered regularly. Stay updated to ensure you're protected.

  6. Use HTTPS: Ensure passwords are transmitted securely to your server in the first place.

  7. Implement rate limiting: Prevent brute force attempts by limiting login attempts.

Common Password Hashing Issues and Solutions

Issue: Hash Timing Attacks

Problem: Attackers might measure the time taken to compare passwords to infer information.

Solution: Use constant-time comparison functions like those built into bcrypt.

Issue: Password Length Limitations

Problem: Some hashing algorithms have input length limitations.

Solution: Validate password length requirements in your application before hashing.

javascript
// Example: Password validation middleware
const validatePassword = (req, res, next) => {
const { password } = req.body;

if (!password || password.length < 8 || password.length > 128) {
return res.status(400).json({
message: 'Password must be between 8 and 128 characters'
});
}

next();
};

// Use in routes
app.post('/api/register', validatePassword, async (req, res) => {
// Registration logic
});

Issue: Upgrading Hashing Algorithms

Problem: If you need to upgrade your hashing algorithm or work factor, you need to handle existing users.

Solution: Re-hash passwords upon successful login:

javascript
app.post('/api/login', async (req, res) => {
try {
const { username, password } = req.body;
const user = await User.findOne({ username });

if (!user) {
return res.status(401).json({ message: 'Authentication failed' });
}

const isMatch = await user.comparePassword(password);

if (!isMatch) {
return res.status(401).json({ message: 'Authentication failed' });
}

// Check if we need to update the hash (e.g., if work factor has changed)
if (user.shouldRehash) {
user.password = password; // This will trigger the pre-save middleware
user.shouldRehash = false;
await user.save();
}

// Authentication successful
res.json({
message: 'Authentication successful',
user: {
id: user._id,
username: user.username,
email: user.email
}
});
} catch (err) {
res.status(500).json({
message: 'Error during authentication',
error: err.message
});
}
});

Summary

Password hashing is a critical security measure for any application that manages user authentication. In Express.js applications:

  1. We use bcrypt to securely hash passwords before storing them
  2. We implement pre-save middleware to automatically hash passwords when they change
  3. We compare login attempts against stored hashes without exposing the original passwords
  4. We follow best practices like using unique salts and appropriate work factors

By implementing these techniques, you significantly enhance the security of your application and protect your users' credentials even if your database is compromised.

Additional Resources

Exercises

  1. Basic Implementation: Create a simple Express application with user registration and login using bcrypt password hashing.

  2. Password Strength Meter: Extend your registration form to include a password strength meter, giving users feedback on their password security.

  3. Migration Challenge: Write a script that would upgrade all existing user passwords from one hashing algorithm to another without requiring users to reset their passwords.

  4. Rate Limiting: Implement rate limiting on login attempts to prevent brute force attacks against your authentication endpoints.

  5. Two-Factor Authentication: Add an additional layer of security by implementing two-factor authentication alongside password hashing.



If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)