Authentication is one of those topics that seems simple on the surface.
A user enters an email and password.
You check the credentials.
You log them in.
Done.
But once you start building a real application, you quickly discover that authentication involves security, authorization, database design, token management, middleware, and data consistency.
In this article, I'll walk through the authentication system and project creation workflow I built for a project management application using:
- Node.js
- Express
- Prisma ORM
- PostgreSQL
- JWT
- bcrypt
We'll cover:
- Password hashing
- JWT authentication
- Route protection middleware
- Login and registration endpoints
- Prisma relationships
- Authorization
- Database transactions
Let's dive in.
Understanding Password Hashing
One of the most common beginner questions is:
If bcrypt hashes a password, how does it know whether the password is correct during login?
The answer is:
bcrypt never decrypts anything.
Hashing is a one-way operation.
For example:
text
password123
might become:
text
$2a$10$Y7s8J...
The hash cannot be reversed back into the original password.
During login:
- User enters password
- Server retrieves stored hash
- bcrypt hashes the incoming password
- bcrypt compares both values securely
js
const match = await bcrypt.compare(
password,
user.password
)
If the comparison succeeds:
js
true
The user is authenticated.
This approach ensures passwords are never stored in plaintext.
Building the Registration Endpoint
The registration flow looks like this:
Client
↓
POST /register
↓
Validate Input
↓
Check Existing User
↓
Hash Password
↓
Create User
↓
Generate JWT
↓
Return Response
The first step is checking whether the email already exists.
Of course the frontend will ensure that an empty form is not submitted.
const existingUser =
await prisma.user.findUnique({
where: { email }
})
If a user already exists:
return res.status(400).json({
message: "Email already exists"
})
Next, hash the password.
const hashedPassword =
await bcrypt.hash(password, 10)
The number 10 represents the salt rounds.
Higher values increase security but require more computation.
The user can now be stored safely.
const user = await prisma.user.create({
data: {
name,
email,
password: hashedPassword
}
})
Generating JWT Tokens
After registration, we don't want the user to immediately log in again.
Instead, we generate a token.
const token = jwt.sign(
{ id: user.id },
process.env.JWT_SECRET,
{
expiresIn: "7d"
}
)
The payload contains:
{
id: user.id
}
The token is then returned to the client.
res.status(201).json({
user,
token
})
Building the Login Endpoint
Login follows a similar flow.
Email
Password
↓
Find User
↓
Compare Password
↓
Generate JWT
↓
Return User + Token
First locate the user.
const user =
await prisma.user.findUnique({
where: { email }
})
Never reveal whether the email exists.
Bad:
Email not found
Good:
Invalid credentials
This prevents attackers from enumerating valid accounts.
Next compare passwords.
const match =
await bcrypt.compare(
password,
user.password
)
If comparison fails:
return res.status(401).json({
message: "Invalid credentials"
})
Generate the token and return the user.
Before returning, remove the password.
const {
password: _,
...safeUser
} = user
This is object destructuring.
The password field is extracted and discarded.
Everything else becomes:
safeUser
Now return:
{
user: safeUser,
token
}
Protecting Routes with Middleware
Authentication alone isn't enough.
We also need authorization.
For example:
GET /projects
should only work for logged-in users.
This is where middleware comes in.
export function protect(
req,
res,
next
) {
const token =
req.headers.authorization
?.split(" ")[1]
if (!token) {
return res.status(401).json({
message: "Not authorized"
})
}
try {
const decoded =
jwt.verify(
token,
process.env.JWT_SECRET
)
req.user = decoded
next()
} catch {
return res.status(401).json({
message: "Invalid token"
})
}
}
A request header looks like:
Authorization:
Bearer eyJhbG...
The middleware extracts the token.
If valid:
req.user = decoded
This allows every protected route to access:
req.user.id
Why We Query Projects Through Memberships
A user can belong to many projects.
A project can contain many users.
This is a many-to-many relationship.
Example schema:
User
↕
ProjectMembership
↕
Project
To fetch projects belonging to a user:
await prisma.project.findMany({
where: {
memberships: {
some: {
userId: req.user.id
}
}
}
})
The keyword:
some
means:
At least one related record matches.
Prisma also supports:
every
and
none
for more advanced filtering.
Creating Projects Safely
Creating a project involves multiple database writes.
We need to:
- Create project
- Create owner membership
- Create default boards
Without protection, something could fail midway.
Imagine:
Project Created
Membership Failed
Boards Not Created
Now the database contains a broken project.
To solve this, Prisma provides transactions.
await prisma.$transaction(
async (tx) => {
...
}
)
Inside the transaction:
const project =
await tx.project.create(...)
Create owner membership.
await tx.projectMember.create(...)
Create default boards.
await tx.board.createMany(...)
Finally:
return project
If any operation fails:
ROLLBACK
Everything is undone automatically.
This is called an atomic operation.
Either all steps succeed.
Or none of them do.
Why Transactions Matter
Many beginners think transactions are only useful for banking systems.
In reality, they matter whenever multiple writes must stay consistent.
Examples:
- Creating projects
- Creating orders
- Processing payments
- Assigning team members
- Booking tickets
- Inventory management
If your business logic requires multiple dependent writes, transactions should be one of the first tools you consider.
Key Lessons Learned
Building authentication taught me several important lessons:
- Hash passwords, never store plaintext.
- JWTs authenticate users without storing session state.
- Middleware keeps protected routes clean and reusable.
- Authorization is different from authentication.
- Prisma relation filters make querying powerful.
- Transactions protect database consistency.
- Security decisions often matter more than the code itself.
Authentication is often the first serious backend system developers build, and it introduces many concepts that appear throughout software engineering.
Understanding these fundamentals makes it much easier to build larger applications confidently and securely.
Thanks for reading.
Top comments (0)