Implementing Multi-User Authentication
After completing this topic:
You will be able to securely hash passwords during user registration, understand the principles of bcrypt, and implement role-based access control.
Why Storing Passwords in Plain Text is a Bad Idea
// NEVER do this
const users = [
{ username: 'alice', password: 'mySecret123' }
];If the database is compromised, all users' passwords will be exposed. The 2012 LinkedIn hack and the 2013 Adobe hack exposed hundreds of millions of passwords stored in plain text (or with weak hashing).
Never store passwords in plain text. Store them using hashing.
What is Hashing?
Hashing is a one-way transformation. It's possible to go from the original to the hash, but not from the hash back to the original.
"mySecret123" β hashing β "$2b$10$X7kG..." (possible)
"$2b$10$X7kG..." β ??? β "mySecret123" (impossible)During login, the entered password is hashed and compared to the stored hash.
User input: "mySecret123"
Stored hash: "$2b$10$X7kG..."
"mySecret123" β hashing β "$2b$10$X7kG..." (match β authentication successful)
"wrongPass" β hashing β "$2b$10$Qm1Y..." (no match β authentication failed)bcrypt β The Standard for Password Hashing
npm install bcryptWhy bcrypt is better than general hash functions like SHA-256 for passwords:
- Designed to be slow: Prevents hackers from trying billions of hashes per second.
- Automatically includes salt: Generates a different hash each time, even for the same password.
- Configurable cost: Adjust the speed/security trade-off with the
roundsvalue.
const bcrypt = require('bcrypt');
const SALT_ROUNDS = 10;
// Hashing (during registration)
const hash = await bcrypt.hash('mySecret123', SALT_ROUNDS);
console.log(hash);
// $2b$10$X7kG.fDlUqVnPq5RvWEYq.ZM3Y4Fs8qD1vSrKLhwEcFjnp2fW9Xm.
// Comparison (during login)
const isMatch = await bcrypt.compare('mySecret123', hash);
console.log(isMatch); // true
const isWrong = await bcrypt.compare('wrongPass', hash);
console.log(isWrong); // falseSALT_ROUNDS = 10 means 2^10 = 1,024 iterations. A higher value is more secure but slower. 10-12 is the current recommended range.
Implementing Registration
const express = require('express');
const bcrypt = require('bcrypt');
const app = express();
app.use(express.json());
const users = []; // In a real application, use a database.
app.post('/register', async (req, res) => {
const { username, password, displayName } = req.body;
// Input validation
if (!username || !password) {
return res.status(400).json({ error: 'Username and password required' });
}
if (password.length < 8) {
return res.status(400).json({ error: 'Password must be at least 8 characters' });
}
// Check for duplicates
if (users.find(u => u.username === username)) {
return res.status(409).json({ error: 'Username already exists' });
}
// Hash the password
const hashedPassword = await bcrypt.hash(password, 10);
// Store the user
const newUser = {
id: users.length + 1,
username,
password: hashedPassword,
displayName: displayName || username,
role: 'user',
createdAt: new Date().toISOString()
};
users.push(newUser);
res.status(201).json({
message: 'Registration successful',
user: { id: newUser.id, username: newUser.username }
});
});Never include the password (including the hash) in the response.
Integrating with Passport
const passport = require('passport');
const LocalStrategy = require('passport-local').Strategy;
passport.use(new LocalStrategy(
async (username, password, done) => {
const user = users.find(u => u.username === username);
if (!user) {
return done(null, false, { message: 'User not found' });
}
// Compare using bcrypt
const isValid = await bcrypt.compare(password, user.password);
if (!isValid) {
return done(null, false, { message: 'Wrong password' });
}
return done(null, user);
}
));
passport.serializeUser((user, done) => done(null, user.id));
passport.deserializeUser((id, done) => {
const user = users.find(u => u.id === id);
done(null, user);
});The only difference from the Strategy in the previous topic is using bcrypt.compare() instead of user.password !== password.
Role-Based Access Control (RBAC)
function requireRole(...roles) {
return (req, res, next) => {
if (!req.isAuthenticated()) {
return res.status(401).json({ error: 'Login required' });
}
if (!roles.includes(req.user.role)) {
return res.status(403).json({ error: 'Insufficient permissions' });
}
next();
};
}
// All logged-in users
app.get('/profile', requireRole('user', 'admin'), (req, res) => {
res.json({ user: req.user.displayName });
});
// Only administrators
app.get('/admin/users', requireRole('admin'), (req, res) => {
const safeUsers = users.map(u => ({
id: u.id, username: u.username, role: u.role
}));
res.json(safeUsers);
});
// Administrator - change role
app.patch('/admin/users/:id/role', requireRole('admin'), (req, res) => {
const user = users.find(u => u.id === parseInt(req.params.id));
if (!user) return res.status(404).json({ error: 'User not found' });
user.role = req.body.role;
res.json({ message: `${user.username} role changed to ${user.role}` });
});requireRole(...roles) is a higher-order function (a function that returns a function). It takes the allowed roles as arguments and creates a middleware that returns 403 if the user does not have the required role.
Security Checklist
| Item | Description |
|---|---|
| Password Hashing | bcrypt, SALT_ROUNDS 10+ |
| Minimum Length | 8 characters or more (NIST recommendation) |
| Do not include hash in response | Never include the password field (including the hash) in the API response |
| Generalize error messages | "Invalid username or password" (do not indicate which one is incorrect) |
| Rate limiting | Limit the number of login attempts (prevent brute force) |
| HTTPS | Ensure that passwords are not transmitted in plain text over the network |
The reason for generalizing error messages: if you say "username not found", an attacker can determine the existence of accounts. This is called a user enumeration attack.
Real-World Pattern β Account Deletion
app.delete('/account', requireRole('user', 'admin'), async (req, res) => {
const valid = await bcrypt.compare(req.body.password, req.user.password);
if (!valid) {
return res.status(401).json({ error: 'Password confirmation failed' });
}
const index = users.findIndex(u => u.id === req.user.id);
users.splice(index, 1);
req.logout((err) => {
if (err) return res.status(500).json({ error: 'Logout failed' });
res.json({ message: 'Account deleted' });
});
});Account deletion is a risky operation, so it requires password re-confirmation. After deletion, be sure to clear the session with req.logout().
Key Takeaways
| Concept | Summary |
|---|---|
| Hashing | One-way transformation. Possible to go from original to hash, but not from hash to original |
| bcrypt | Password-specific hash. Slow and includes salt |
| Salt | Random value that generates a different hash, even for the same password |
| RBAC | Role-based access control |
bcrypt.hash() | Hashing password during registration |
bcrypt.compare() | Comparing input with stored hash during login |
Authentication is about more than just "is the password correct"; it's about how to handle passwords securely. Storing passwords in plain text is a disaster, weak hashes (MD5, SHA-1) are meaningless, and bcrypt is the current standard.