cd /news/developer-tools/jwt-authentication-in-node-js-buildi… · home topics developer-tools article
[ARTICLE · art-28679] src=dev.to ↗ pub= topic=developer-tools verified=true sentiment=· neutral

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

A developer built a production-ready JWT authentication system in Node.js using Express, Prisma ORM, PostgreSQL, bcryptjs, jsonwebtoken, and cookie-parser. The system implements short-lived access tokens (15 minutes) and long-lived refresh tokens (7 days) stored in httpOnly cookies for security. The developer emphasized password hashing with bcrypt and the importance of token expiration to limit damage from token leaks.

read5 min views1 publishedJun 15, 2026

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.

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

The token consists of three sections separated by dots.

The header contains metadata.

json

{
  "alg": "HS256",
  "typ": "JWT"
}

This tells us:

The payload contains claims.

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

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.

This is where security comes from.

The server combines:

text
Header + Payload + Secret Key

Then generates a cryptographic signature.

If someone modifies the payload:

json
{
  "userId": 999999,
  "role": "admin"
}

the signature becomes invalid.

The server immediately detects the tampering.

This is what makes JWT trustworthy.

Let's walk through a complete login lifecycle.

User submits:

json
{
  "email": "user@example.com",
  "password": "password123"
}

Server:

Database stores:

Email: user@example.com
Password: $2a$10$L6...

Never:

Password: password123

Passwords should never be stored directly.

Imagine your database leaks.

If passwords are plain text:

text
password123
qwerty
admin123

Attackers instantly gain access.

Instead we hash passwords.

Using bcrypt:

const hash = await bcrypt.hash(password, 10);

Output:

$2b$10$A2K3...

The original password cannot be recovered.

When a user logs in:

json
{
  "email": "user@example.com",
  "password": "password123"
}

The server:

Finds the user.

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

Compares passwords.

const validPassword = await bcrypt.compare(
  password,
  user.password
);

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

No decryption happens.

After successful login:

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

The token now becomes proof of authentication.

Many beginners ask:

"Why not make the token last forever?"

Because stolen tokens happen.

A leaked token with:

text
expiresIn: "365d"

gives attackers a year of access.

A token with:

text
expiresIn: "15m"

limits damage significantly.

This introduces a problem:

Users would constantly need to log in again.

That's where refresh tokens come in.

A refresh token is a long-lived token.

Example:

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

Purpose:

Access token expires quickly

Refresh token issues new access tokens

Users stay logged in.

Security remains strong.

Instead of sending refresh tokens in JSON responses:

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

Benefits:

JavaScript cannot access the cookie.

Protection against XSS attacks.

Prevents many CSRF attacks.

Automatically expires.

A simple User model:

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

Generate client:

npx prisma generate

Push schema:

npx prisma db push

Or create migrations:

npx prisma migrate dev

A typical flow:

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

The controller becomes the central authentication orchestrator.

Authentication isn't useful until routes are protected.

Example:

http
GET /profile

Only authenticated users should access it.

This is where middleware shines.

Every request arrives with:

http
Authorization: Bearer eyJhbGc...

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();
};

The server checks:

If any check fails:

js
jwt.verify()

throws an error.

Request gets rejected.

Because middleware attached the payload:

js
req.user

controllers can access:

req.user.id
req.user.email

without querying the database again.

This is one of JWT's biggest advantages.

Traditional sessions:

Client → Session ID
Server → Session Store

Server must remember every session.

JWT:

Client → Token
Server → Verify Token

No session storage required.

Everything needed exists inside the token.

This is called stateless authentication.

Never.

JWT payloads are readable.

Always use:

expiresIn

Wrong:

jwt.decode(token)

Decode does NOT verify.

Use:

jwt.verify(token)

for authentication.

Never send:

user.password

back to clients.

Even hashed passwords should remain private.

A production-ready setup usually looks like:

POST /auth/register
POST /auth/login
POST /auth/logout
POST /auth/refresh
GET  /auth/me

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.

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

── more in #developer-tools 4 stories · sorted by recency
── more on @node.js 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
Live at https://your-agent.zahid.host
Get free account → Pricing
from €0/mo · no card required
LIVE [news/jwt-authentication-i…] indexed:0 read:5min 2026-06-15 ·