Skip to Content
Lesson 1

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 bcrypt

Now, 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:

  1. We use a pre('save') hook, which tells Mongoose to run our function before any User document is saved.
  2. We check if the document is new (this.isNew) or if the password field has been modified. This prevents us from re-hashing the password on every save (e.g., when a user updates their email).
  3. 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 of 10 is 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:

  1. Define a plain-text password.
  2. Use bcrypt to hash the password asynchronously with 10 salt rounds.
  3. Log the resulting hash to the console.
  4. Use bcrypt’s compare method to check if the original password matches the hash. Log the result (which should be true).
  5. Use the compare method again, but this time with an incorrect password. Log the result (which should be false).

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:

  1. The specific line(s) with the vulnerability
  2. The type of vulnerability (e.g., information leakage, weak hashing, missing validation)
  3. How an attacker could exploit it
  4. 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:

VulnerabilityYou 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:

  1. Sample 1: Salt rounds of 5 is too low (use 10-12); no input validation; no password strength requirements
  2. 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
  3. Sample 3: Password not hashed before saving; no verification the requester owns the email; response confirms email exists
  4. Sample 4: No unique constraint on email; role field could be set during registration (mass assignment); password not marked as required

Reflection Questions:

  1. Which vulnerabilities did AI catch that you missed? Which did you catch that AI missed?
  2. How would you prioritize fixing these issues based on severity?
  3. What additional security measures would you add (rate limiting, account lockout, etc.)?
Important

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.


References

Additional Resources