DEV Community

Chinwuba
Chinwuba

Posted on

Building Authentication and Project Creation with Express, Prisma, JWT, and Database Transactions

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:

  1. Password hashing
  2. JWT authentication
  3. Route protection middleware
  4. Login and registration endpoints
  5. Prisma relationships
  6. Authorization
  7. 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
Enter fullscreen mode Exit fullscreen mode

might become:

text
$2a$10$Y7s8J...
Enter fullscreen mode Exit fullscreen mode

The hash cannot be reversed back into the original password.

During login:

  1. User enters password
  2. Server retrieves stored hash
  3. bcrypt hashes the incoming password
  4. bcrypt compares both values securely
js
const match = await bcrypt.compare(
  password,
  user.password
)
Enter fullscreen mode Exit fullscreen mode

If the comparison succeeds:

js
true
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 }
  })
Enter fullscreen mode Exit fullscreen mode

If a user already exists:

return res.status(400).json({
  message: "Email already exists"
})
Enter fullscreen mode Exit fullscreen mode

Next, hash the password.

const hashedPassword =
  await bcrypt.hash(password, 10)
Enter fullscreen mode Exit fullscreen mode

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
  }
})
Enter fullscreen mode Exit fullscreen mode

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"
  }
)
Enter fullscreen mode Exit fullscreen mode

The payload contains:

{
  id: user.id
}
Enter fullscreen mode Exit fullscreen mode

The token is then returned to the client.

res.status(201).json({
  user,
  token
})
Enter fullscreen mode Exit fullscreen mode

Building the Login Endpoint

Login follows a similar flow.

Email
Password
   ↓
Find User
   ↓
Compare Password
   ↓
Generate JWT
   ↓
Return User + Token
Enter fullscreen mode Exit fullscreen mode

First locate the user.

const user =
  await prisma.user.findUnique({
    where: { email }
  })
Enter fullscreen mode Exit fullscreen mode

Never reveal whether the email exists.

Bad:

Email not found
Enter fullscreen mode Exit fullscreen mode

Good:

Invalid credentials
Enter fullscreen mode Exit fullscreen mode

This prevents attackers from enumerating valid accounts.

Next compare passwords.

const match =
  await bcrypt.compare(
    password,
    user.password
  )
Enter fullscreen mode Exit fullscreen mode

If comparison fails:

return res.status(401).json({
  message: "Invalid credentials"
})
Enter fullscreen mode Exit fullscreen mode

Generate the token and return the user.

Before returning, remove the password.

const {
  password: _,
  ...safeUser
} = user
Enter fullscreen mode Exit fullscreen mode

This is object destructuring.

The password field is extracted and discarded.

Everything else becomes:

safeUser
Enter fullscreen mode Exit fullscreen mode

Now return:

{
  user: safeUser,
  token
}
Enter fullscreen mode Exit fullscreen mode

Protecting Routes with Middleware

Authentication alone isn't enough.

We also need authorization.

For example:

GET /projects
Enter fullscreen mode Exit fullscreen mode

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"
    })
  }
}
Enter fullscreen mode Exit fullscreen mode

A request header looks like:

Authorization:
Bearer eyJhbG...
Enter fullscreen mode Exit fullscreen mode

The middleware extracts the token.

If valid:

req.user = decoded
Enter fullscreen mode Exit fullscreen mode

This allows every protected route to access:

req.user.id
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

To fetch projects belonging to a user:

await prisma.project.findMany({
  where: {
    memberships: {
      some: {
        userId: req.user.id
      }
    }
  }
})
Enter fullscreen mode Exit fullscreen mode

The keyword:

some
Enter fullscreen mode Exit fullscreen mode

means:

At least one related record matches.

Prisma also supports:

every
Enter fullscreen mode Exit fullscreen mode

and

none
Enter fullscreen mode Exit fullscreen mode

for more advanced filtering.


Creating Projects Safely

Creating a project involves multiple database writes.

We need to:

  1. Create project
  2. Create owner membership
  3. Create default boards

Without protection, something could fail midway.

Imagine:

Project Created
Membership Failed
Boards Not Created
Enter fullscreen mode Exit fullscreen mode

Now the database contains a broken project.

To solve this, Prisma provides transactions.

await prisma.$transaction(
  async (tx) => {
    ...
  }
)
Enter fullscreen mode Exit fullscreen mode

Inside the transaction:

const project =
  await tx.project.create(...)
Enter fullscreen mode Exit fullscreen mode

Create owner membership.

await tx.projectMember.create(...)
Enter fullscreen mode Exit fullscreen mode

Create default boards.

await tx.board.createMany(...)
Enter fullscreen mode Exit fullscreen mode

Finally:

return project
Enter fullscreen mode Exit fullscreen mode

If any operation fails:

ROLLBACK
Enter fullscreen mode Exit fullscreen mode

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:

  1. Hash passwords, never store plaintext.
  2. JWTs authenticate users without storing session state.
  3. Middleware keeps protected routes clean and reusable.
  4. Authorization is different from authentication.
  5. Prisma relation filters make querying powerful.
  6. Transactions protect database consistency.
  7. 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)