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
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