Foundations of Web Authentication & Password Security
You have been assigned to a new project at your company, “Innovate Inc.” The project is a new customer-facing portal that will require users to create accounts and log in to access their data. As a backend developer, your first task is to lay the groundwork for a secure authentication system. This involves understanding the core principles of web security and building a robust user model. Your senior developer has emphasized that getting security right from the beginning is critical to the project’s success.
Authentication vs. Authorization
Before we write a single line of code, it is crucial to understand the difference between authentication and authorization.
-
Authentication (AuthN) is the process of verifying who a user is. It’s like showing your ID at a security checkpoint. The system checks your credentials (like a username and password) and confirms that you are the user you claim to be. Authentication is about identity.
-
Authorization (AuthZ) is the process of determining what an authenticated user is allowed to do. Once you are past the security checkpoint, authorization dictates which doors you can open. It involves checking a user’s permissions or roles to grant or deny access to specific resources or actions. Authorization is about permissions.
A simple metaphor: Authentication is getting into the building. Authorization is which rooms you can enter once you’re inside.
In any secure system, authentication always comes before authorization. You can’t determine what a user can do until you know who they are.
Designing a Secure User Model
The foundation of any authentication system is the User model. When using Mongoose, a well-designed schema is your first line of defense. At a minimum, a user schema for an authentication system needs a way to uniquely identify the user and a place to store their credentials securely.
Let’s consider a basic userSchema:
import { Schema, model } from "mongoose";
const userSchema = new Schema({
username: {
type: String,
required: true,
unique: true,
trim: true,
},
email: {
type: String,
required: true,
unique: true,
match: [/.+@.+\..+/, "Must match an email address!"],
},
password: {
type: String,
required: true,
minlength: 8,
},
});
const User = model("User", userSchema);
export default User;In this schema, we define username and email as unique identifiers. The password field is a simple string, but we will never store the user’s actual password in this field. Doing so would be a massive security vulnerability. If your database were ever compromised, all user passwords would be exposed in plain text.
Instead, we must store a hashed version of the password.
Password Security: Hashing and Salting
A hash function is a one-way mathematical algorithm that takes an input (of any size) and produces a fixed-size string of characters, which is called the “hash” or “digest.” This process is irreversible; you cannot derive the original input from its hash.
However, simply hashing a password isn’t enough. If two users have the same password (“password123”), it will produce the same hash. Attackers can use pre-computed tables of common passwords and their hashes (called “rainbow tables”) to crack them easily.
To prevent this, we use a technique called salting. A “salt” is a random string of data that is added to the password before hashing. This salt is then stored alongside the hashed password in the database.
[Password] + [Random Salt] -> [Hash Function] -> [Secure Hash]
By using a unique salt for every user, we ensure that even identical passwords result in completely different hashes. This makes rainbow table attacks and other bulk password-cracking techniques ineffective.
Implementing Hashing with bcrypt
The bcrypt library is the industry standard for hashing passwords in Node.js applications. It handles both hashing and salting automatically and is designed to be slow, which makes it resistant to brute-force attacks.
First, install it:
npm install bcryptNow, let’s modify our userSchema to automatically hash the password before it’s saved to the database. We can use Mongoose “pre-save” middleware (or a “hook”) to execute this logic.
// models/User.js
import { Schema, model } from "mongoose";
import bcrypt from "bcrypt";
const userSchema = new Schema({
// ... (username and email fields)
password: {
type: String,
required: true,
minlength: 8,
},
});
// Set up pre-save middleware to create password
userSchema.pre("save", async function (next) {
if (this.isNew || this.isModified("password")) {
const saltRounds = 10;
this.password = await bcrypt.hash(this.password, saltRounds);
}
next();
});
const User = model("User", userSchema);
export default User;In this code:
- We use a
pre('save')hook, which tells Mongoose to run our function before anyUserdocument is saved. - We check if the document is new (
this.isNew) or if thepasswordfield has been modified. This prevents us from re-hashing the password on every save (e.g., when a user updates their email). bcrypt.hash()takes the plain-text password (this.password) and a “salt rounds” value. The salt rounds determine how computationally expensive the hash calculation is. A value of10is a good starting point.
Verifying a Password
When a user tries to log in, we need to compare the password they provided with the hashed password stored in our database. We do not hash the incoming password and compare the hashes directly. Instead, bcrypt provides a compare method for this purpose.
Let’s add an instance method to our userSchema to handle this comparison:
// models/User.js
// ... (userSchema definition and pre-save hook)
// Compare the incoming password with the hashed password
userSchema.methods.isCorrectPassword = async function (password) {
return bcrypt.compare(password, this.password);
};
const User = model("User", userSchema);
export default User;Now, when a user logs in, we can use this method to securely check their password:
// Example usage in a login route
const user = await User.findOne({ email: req.body.email });
if (!user) {
return res.status(400).json({ message: "Incorrect email or password" });
}
const correctPw = await user.isCorrectPassword(req.body.password);
if (!correctPw) {
return res.status(400).json({ message: "Incorrect email or password" });
}
// If both are true, the user is authenticated!By using bcrypt.compare(), we delegate the complex and critical task of securely checking the password to a battle-tested library, ensuring we never expose or mishandle user credentials.
Activity 1: Hashing and Comparing Passwords
Your task is to practice the core skills of this lesson. In a new Node.js script, perform the following steps:
- Define a plain-text password.
- Use
bcryptto hash the password asynchronously with 10 salt rounds. - Log the resulting hash to the console.
- Use
bcrypt’scomparemethod to check if the original password matches the hash. Log the result (which should betrue). - Use the
comparemethod again, but this time with an incorrect password. Log the result (which should befalse).
This exercise will solidify your understanding of the two most important functions in the bcrypt library.
Activity 2: AI-Assisted Security Code Review
Security code review is one of the most valuable applications of AI in professional development. Authentication systems are prime targets for attackers, and subtle bugs in password handling, user validation, or error messages can create serious vulnerabilities. AI tools excel at identifying common security anti-patterns, but human expertise is essential for evaluating context-specific risks.
If you haven't already done so, you should install an AI assistant.
We recommend Claude for VS Code , but you can use any AI assistant you prefer.
Scenario: You are conducting a security review of authentication code written by a junior developer. Your task is to identify vulnerabilities before this code goes to production.
Code Samples to Review:
// SAMPLE 1: User Registration Endpoint
app.post('/api/register', async (req, res) => {
const { username, email, password } = req.body;
// Check if user exists
const existingUser = await User.findOne({ email });
if (existingUser) {
return res.status(400).json({ error: 'A user with this email already exists' });
}
// Create user with hashed password
const hashedPassword = await bcrypt.hash(password, 5);
const user = new User({ username, email, password: hashedPassword });
await user.save();
res.status(201).json({ message: 'User created', userId: user._id });
});
// SAMPLE 2: User Login Endpoint
app.post('/api/login', async (req, res) => {
const { email, password } = req.body;
const user = await User.findOne({ email });
if (!user) {
return res.status(401).json({ error: 'User not found' });
}
if (user.password === password) {
const token = jwt.sign({ userId: user._id }, 'mysecretkey123');
return res.json({ token, user });
}
return res.status(401).json({ error: 'Incorrect password' });
});
// SAMPLE 3: Password Reset Endpoint
app.post('/api/reset-password', async (req, res) => {
const { email, newPassword } = req.body;
const user = await User.findOne({ email });
if (!user) {
return res.status(404).json({ error: 'No account found with this email' });
}
user.password = newPassword;
await user.save();
res.json({ message: `Password reset successful for ${email}` });
});
// SAMPLE 4: User Schema
const userSchema = new Schema({
username: { type: String, required: true },
email: { type: String, required: true },
password: { type: String },
role: { type: String, default: 'user' }
});
const User = model('User', userSchema);Part 1: Manual Security Audit (15 min)
Review each code sample and identify as many security vulnerabilities as you can. For each issue found, document:
- The specific line(s) with the vulnerability
- The type of vulnerability (e.g., information leakage, weak hashing, missing validation)
- How an attacker could exploit it
- The recommended fix
Hint: Look for issues related to:
- Password hashing practices
- Error message information leakage
- Input validation
- Secret management
- Authentication comparison methods
Part 2: AI-Assisted Security Analysis (10 min)
Now, use an AI assistant to analyze the same code.
Prompt the AI with:
“Perform a security audit of this authentication code. Identify all security vulnerabilities, explain how each could be exploited, and provide secure alternatives. Focus on: password handling, information leakage, input validation, secret management, and authentication logic.”
Paste the code samples above into your AI prompt.
Part 3: Compare and Evaluate (10 min)
Compare your manual audit with the AI’s analysis:
| Vulnerability | You Found? | AI Found? | Severity |
|---|---|---|---|
| Weak salt rounds (5 instead of 10+) | |||
| Plain-text password comparison | |||
| Hardcoded JWT secret | |||
| Information leakage in error messages | |||
| No password hashing on reset | |||
| Missing input validation | |||
| User data exposed in login response | |||
| Missing email uniqueness constraint | |||
| No password requirements | |||
| Role field vulnerable to mass assignment |
Key Vulnerabilities to Discuss:
- Sample 1: Salt rounds of 5 is too low (use 10-12); no input validation; no password strength requirements
- Sample 2: Comparing plain-text password instead of using
bcrypt.compare(); hardcoded JWT secret; different error messages leak whether email exists (enumeration attack); returning full user object exposes sensitive data - Sample 3: Password not hashed before saving; no verification the requester owns the email; response confirms email exists
- Sample 4: No unique constraint on email; role field could be set during registration (mass assignment); password not marked as required
Reflection Questions:
- Which vulnerabilities did AI catch that you missed? Which did you catch that AI missed?
- How would you prioritize fixing these issues based on severity?
- What additional security measures would you add (rate limiting, account lockout, etc.)?
AI-assisted security review is standard practice in security-conscious organizations. AI excels at catching common anti-patterns like weak hashing, information leakage, and missing validation. However, AI may miss context-specific vulnerabilities or suggest fixes that don’t fit your architecture. Always validate AI security recommendations against your specific threat model and never blindly trust AI-generated security code.
Knowledge Check
A user logs into your application. Your system checks their username and password to verify their identity. What is this process called?
- Select an answer to view feedback.
Why should you never store plain-text passwords in a database?
- Select an answer to view feedback.
In the context of bcrypt, what is the purpose of 'salt rounds'?
- Select an answer to view feedback.
Summary
In this lesson, you took the first critical steps into web security. You learned the fundamental difference between authentication (verifying identity) and authorization (granting permissions). We designed a basic but secure Mongoose User model and, most importantly, implemented password hashing using bcrypt. You now know how to take a plain-text password, apply a salt, and create a secure, one-way hash to store in the database. Finally, you learned to use bcrypt.compare() to safely validate a user’s credentials during a login attempt.