NextAuth.js Authentication Series — Part 6 ~ Secure Password Authentication with bcrypt in Next.js
In the previous article, we connected our Next.js authentication system to MongoDB and learned how to retrieve users from the database using the Credentials...

In the previous article, we connected our Next.js authentication system to MongoDB and learned how to retrieve users from the database using the Credentials Provider.
However, there is still a serious security problem.
We are currently storing passwords as plain text.
Storing passwords this way is extremely dangerous. If the database is compromised, anyone with access to it could potentially see the users' passwords.
In this article, we'll solve this problem by using bcrypt to hash passwords before storing them in MongoDB and securely compare passwords during authentication.
By the end of this article, our Credentials authentication flow will be much closer to a production-ready implementation.
What Is Password Hashing?
Password hashing converts a password into a one-way cryptographic representation.
For example:
Original password:
myPassword123
↓ bcrypt
Hashed password:
$2b$12$................................
The important thing is that we don't store the original password.
When a user logs in, we compare the entered password with the stored hash.
User Password
│
▼
bcrypt
│
▼
Compare with stored hash
│
┌──┴──┐
│ │
Match No Match
│ │
▼ ▼
Login Reject
A password hash is not intended to be reversed back into the original password.
Step 1 — Install bcrypt
Install bcrypt in your project:
npm install bcrypt
If you're using TypeScript, also install the type definitions:
npm install -D @types/bcrypt
Step 2 — Create a Password Hash
Let's create a small helper function.
Create:
lib/password.ts
Add:
import bcrypt from "bcrypt";
export async function hashPassword(password: string) {
return bcrypt.hash(password, 12);
}
The second argument, 12, is the bcrypt cost factor.
A higher cost generally means stronger computational resistance but also requires more processing time.
Step 3 — Compare Passwords
We'll also create a helper for password verification.
import bcrypt from "bcrypt";
export async function hashPassword(password: string) {
return bcrypt.hash(password, 12);
}
export async function comparePassword(
password: string,
hashedPassword: string
) {
return bcrypt.compare(password, hashedPassword);
}
Now we have two reusable functions:
hashPassword()
comparePassword()
Step 4 — Update the User Document
Instead of storing:
{
"email": "john@example.com",
"password": "123456"
}
we should store:
{
"email": "john@example.com",
"password": "$2b$12$..."
}
The password field now contains the bcrypt hash rather than the original password.
Step 5 — Hash a Password Before Creating a User
We'll create a simple registration example.
import { connectDB } from "@/lib/mongodb";
import { hashPassword } from "@/lib/password";
const db = await connectDB();
const hashedPassword = await hashPassword("123456");
await db.collection("users").insertOne({
name: "John Doe",
email: "john@example.com",
password: hashedPassword,
createdAt: new Date(),
});
The database will only receive the hash.
The original password is never stored.
Step 6 — Update the Credentials Provider
Now let's update our authentication configuration.
Open:
lib/auth.ts
Import the password comparison helper:
import { comparePassword } from "@/lib/password";
Then update the authorize() function:
async authorize(credentials) {
if (!credentials?.email || !credentials?.password) {
return null;
}
const db = await connectDB();
const user = await db.collection("users").findOne({
email: credentials.email,
});
if (!user || !user.password) {
return null;
}
const isPasswordValid = await comparePassword(
credentials.password as string,
user.password
);
if (!isPasswordValid) {
return null;
}
return {
id: user._id.toString(),
name: user.name,
email: user.email,
};
}
Now the authentication process works like this:
Email + Password
│
▼
Find user in MongoDB
│
▼
User exists?
│ │
No Yes
│ │
Reject ▼
bcrypt.compare()
│
┌────┴────┐
Match No Match
│ │
▼ ▼
Login Reject
Step 7 — Why We Don't Decrypt the Password
A common question is:
"If the password is hashed, how does the application know the original password?"
It doesn't.
That's the important part.
bcrypt doesn't work by encrypting a password and later decrypting it.
Instead:
Password
↓
bcrypt
↓
Hash
During login:
Entered Password
↓
bcrypt.compare()
↓
Stored Hash
↓
true / false
The original password doesn't need to be recovered.
Step 8 — Never Return the Password
Even though the password is hashed, you should still never include it in the returned user object.
Avoid:
return {
id: user._id.toString(),
name: user.name,
email: user.email,
password: user.password,
};
Instead:
return {
id: user._id.toString(),
name: user.name,
email: user.email,
};
Only return information that the authentication system actually needs.
Step 9 — Add a Unique Index to Email
Users shouldn't be able to create multiple accounts using the same email address.
MongoDB can enforce this at the database level.
You can create a unique index:
await db.collection("users").createIndex(
{ email: 1 },
{ unique: true }
);
Now MongoDB will prevent duplicate email addresses.
This is an important database-level protection because checking only in application code can still result in race conditions.
Security Improvements
Our authentication system is now significantly better.
Previously:
Database
└── Plain-text passwords ❌
Now:
Database
└── bcrypt password hashes ✅
However, password hashing alone isn't enough for a secure authentication system.
A production application should also consider:
Input validation
Rate limiting
Account lockout policies where appropriate
Secure session configuration
Email verification
Password reset
Secure cookies
CSRF protections where applicable
Proper error handling
We'll gradually implement these throughout the series.
Important: Existing Plain-Text Passwords
If you followed the previous article and inserted a test user with a plain-text password, don't continue using that document as-is.
For example, this is unsafe:
{
"email": "john@example.com",
"password": "123456"
}
Replace it with a bcrypt hash generated by your application.
For a real application, you should also consider how to safely migrate any existing users from a legacy plain-text password system rather than leaving those passwords exposed.
Complete Password Helper
At this point, our helper can look like this:
import bcrypt from "bcrypt";
export async function hashPassword(password: string) {
return bcrypt.hash(password, 12);
}
export async function comparePassword(
password: string,
hashedPassword: string
) {
return bcrypt.compare(password, hashedPassword);
}
This keeps password-related logic in one place and makes it reusable across registration, login, and password-reset functionality.
Key Takeaways
We've learned several important concepts in this article:
Passwords should never be stored as plain text.
bcrypt can be used to securely hash passwords.
bcrypt.compare()verifies passwords without decrypting them.Password hashes should never be returned to the client.
Email addresses should be unique.
Password security is only one part of a complete authentication system.
What's Next?
Our authentication system can now securely verify passwords, but users still have to be manually inserted into MongoDB.
That's not how a real application should work.
In the next article, we'll build a User Registration System.
We'll create a registration form and API, validate user input, check for duplicate emails, hash the password, and save the new user securely in MongoDB.
This will complete the basic Sign Up → Sign In authentication flow.
Series Navigation
NextAuth.js Authentication Series
✅ Part 1 — Introduction to Authentication with NextAuth.js
✅ Part 2 — Installing & Configuring NextAuth.js
✅ Part 3 — Google OAuth Authentication
✅ Part 4 — Credentials Provider with Email & Password
✅ Part 5 — Integrating MongoDB with Credentials Authentication
✅ Part 6 — Secure Password Authentication with bcrypt ← You are here


