DEV Community

Cover image for JWT Authentication in Node.js: Building a Production-Ready Login System from Scratch
Chinwuba
Chinwuba

Posted on

JWT Authentication in Node.js: Building a Production-Ready Login System from Scratch

Authentication is one of the first major milestones every backend developer encounters.

At first, it looks deceptively simple:

User enters email
User enters password
Server checks credentials
User gets logged in

But underneath that simple flow is an entire security architecture involving password hashing, token signing, cookie security, database validation, and route protection.

In this article, we'll walk through how JWT authentication actually works and how to build a complete authentication system using:

Node.js
Express
Prisma ORM
PostgreSQL
bcryptjs
jsonwebtoken
cookie-parser

By the end, you'll understand not just the code, but the reasoning behind every step.

What is JWT?

JWT stands for JSON Web Token.

It is a compact string that allows a server to verify a user's identity without storing session data on the server.

A JWT looks something like this:

xxxxx.yyyyy.zzzzz
Enter fullscreen mode Exit fullscreen mode

The token consists of three sections separated by dots.

Part 1: Header

The header contains metadata.

json

{
  "alg": "HS256",
  "typ": "JWT"
}
Enter fullscreen mode Exit fullscreen mode

This tells us:

  • Which algorithm was used
  • That the token is a JWT

Part 2: Payload

The payload contains claims.

json
{
  "userId": 1,
  "email": "test@example.com",
  "iat": 123456789,
  "exp": 123457789
}
Enter fullscreen mode Exit fullscreen mode

Typical claims include:

User ID
Email
Roles
Permissions
Expiration dates

Important:

The payload is NOT encrypted.

Anyone can decode it.

Never store passwords or sensitive information inside a JWT payload.


Part 3: Signature

This is where security comes from.

The server combines:

text
Header + Payload + Secret Key
Enter fullscreen mode Exit fullscreen mode

Then generates a cryptographic signature.

If someone modifies the payload:

json
{
  "userId": 999999,
  "role": "admin"
}
Enter fullscreen mode Exit fullscreen mode

the signature becomes invalid.

The server immediately detects the tampering.

This is what makes JWT trustworthy.


The Authentication Flow

Let's walk through a complete login lifecycle.

Registration

User submits:

json
{
  "email": "user@example.com",
  "password": "password123"
}
Enter fullscreen mode Exit fullscreen mode

Server:

  1. Checks if email already exists
  2. Hashes password
  3. Saves user

Database stores:

Email: user@example.com
Password: $2a$10$L6...
Enter fullscreen mode Exit fullscreen mode

Never:

Password: password123
Enter fullscreen mode Exit fullscreen mode

Why Password Hashing Matters

Passwords should never be stored directly.

Imagine your database leaks.

If passwords are plain text:

text
password123
qwerty
admin123
Enter fullscreen mode Exit fullscreen mode

Attackers instantly gain access.

Instead we hash passwords.

Using bcrypt:

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

Output:

$2b$10$A2K3...
Enter fullscreen mode Exit fullscreen mode

The original password cannot be recovered.


Logging In

When a user logs in:

json
{
  "email": "user@example.com",
  "password": "password123"
}
Enter fullscreen mode Exit fullscreen mode

The server:

Step 1

Finds the user.

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

Step 2

Compares passwords.

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

bcrypt hashes the submitted password and compares it to the stored hash.

No decryption happens.

Generating an Access Token

After successful login:

js
const accessToken = jwt.sign(
  {
    id: user.id,
    email: user.email,
  },
  process.env.JWT_SECRET,
  {
    expiresIn: "15m",
  }
);
Enter fullscreen mode Exit fullscreen mode

The token now becomes proof of authentication.

Why Access Tokens Expire

Many beginners ask:

"Why not make the token last forever?"

Because stolen tokens happen.

A leaked token with:

text
expiresIn: "365d"
Enter fullscreen mode Exit fullscreen mode

gives attackers a year of access.

A token with:

text
expiresIn: "15m"
Enter fullscreen mode Exit fullscreen mode

limits damage significantly.

This introduces a problem:

Users would constantly need to log in again.

That's where refresh tokens come in.


Refresh Tokens

A refresh token is a long-lived token.

Example:

js
const refreshToken = jwt.sign(
  payload,
  process.env.JWT_REFRESH_SECRET,
  {
    expiresIn: "7d",
  }
);
Enter fullscreen mode Exit fullscreen mode

Purpose:

Access token expires quickly
Refresh token issues new access tokens

Users stay logged in.

Security remains strong.

Why Store Refresh Tokens in Cookies?

Instead of sending refresh tokens in JSON responses:

js
res.cookie("refreshToken", refreshToken, {
  httpOnly: true,
  sameSite: "strict",
  maxAge: 7 * 24 * 60 * 60 * 1000,
});
Enter fullscreen mode Exit fullscreen mode

Benefits:

httpOnly

JavaScript cannot access the cookie.

Protection against XSS attacks.

sameSite: strict

Prevents many CSRF attacks.

maxAge

Automatically expires.


Prisma Integration

A simple User model:

prisma
model User {
  id         Int      @id @default(autoincrement())
  email      String   @unique
  password   String
  createdAt  DateTime @default(now())
}
Enter fullscreen mode Exit fullscreen mode

Generate client:

npx prisma generate
Enter fullscreen mode Exit fullscreen mode

Push schema:

npx prisma db push
Enter fullscreen mode Exit fullscreen mode

Or create migrations:

npx prisma migrate dev
Enter fullscreen mode Exit fullscreen mode

The Login Controller

A typical flow:

js
1. Find user
2. Verify password
3. Generate access token
4. Generate refresh token
5. Set cookie
6. Return response
Enter fullscreen mode Exit fullscreen mode

The controller becomes the central authentication orchestrator.

Protecting Routes

Authentication isn't useful until routes are protected.

Example:

http
GET /profile
Enter fullscreen mode Exit fullscreen mode

Only authenticated users should access it.

This is where middleware shines.

Building Auth Middleware

Every request arrives with:

http
Authorization: Bearer eyJhbGc...
Enter fullscreen mode Exit fullscreen mode

Middleware:

js
const authMiddleware = (
  req,
  res,
  next
) => {
  const authHeader =
    req.headers.authorization;

  if (!authHeader) {
    return res.status(401).json({
      message: "Unauthorized",
    });
  }

  const token =
    authHeader.split(" ")[1];

  const decoded = jwt.verify(
    token,
    process.env.JWT_SECRET
  );

  req.user = decoded;

  next();
};
Enter fullscreen mode Exit fullscreen mode

What Happens During Verification?

The server checks:

  1. Signature validity
  2. Secret key match
  3. Expiration date

If any check fails:

js
jwt.verify()
Enter fullscreen mode Exit fullscreen mode

throws an error.

Request gets rejected.


Accessing User Data

Because middleware attached the payload:

js
req.user
Enter fullscreen mode Exit fullscreen mode

controllers can access:

req.user.id
req.user.email
Enter fullscreen mode Exit fullscreen mode

without querying the database again.

This is one of JWT's biggest advantages.


JWT is Stateless

Traditional sessions:

Client → Session ID
Server → Session Store
Enter fullscreen mode Exit fullscreen mode

Server must remember every session.

JWT:

Client → Token
Server → Verify Token
Enter fullscreen mode Exit fullscreen mode

No session storage required.

Everything needed exists inside the token.

This is called stateless authentication.


Common Mistakes Beginners Make

Storing Passwords in JWTs

Never.

JWT payloads are readable.


Forgetting Expiration

Always use:

expiresIn
Enter fullscreen mode Exit fullscreen mode

Using decode() for Authentication

Wrong:

jwt.decode(token)
Enter fullscreen mode Exit fullscreen mode

Decode does NOT verify.

Use:

jwt.verify(token)
Enter fullscreen mode Exit fullscreen mode

for authentication.


Returning Passwords

Never send:

user.password
Enter fullscreen mode Exit fullscreen mode

back to clients.

Even hashed passwords should remain private.


Real-World Authentication Architecture

A production-ready setup usually looks like:

POST /auth/register
POST /auth/login
POST /auth/logout
POST /auth/refresh
GET  /auth/me
Enter fullscreen mode Exit fullscreen mode

Register:
Create user.

Login:
Issue tokens.

Refresh:
Issue new access token.

Logout:
Remove refresh token.

Me:
Return current user.

This architecture powers countless SaaS applications today.

Final Thoughts

JWT authentication seems complicated at first because multiple concepts are happening simultaneously:

Password hashing
Database validation
Token generation
Cookie security
Middleware protection
Route authorization

But once you understand the flow, everything starts to click.

The key realization is this:

Authentication is not about logging users in.

Authentication is about proving identity securely on every request.

JWT, bcrypt, Prisma, and Express work together to make that possible.

Master these fundamentals and you'll have the foundation needed to build secure APIs, SaaS products, client portals, and production-grade backend systems.

As usual be write code as art

Top comments (0)