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
The token consists of three sections separated by dots.
Part 1: Header
The header contains metadata.
json
{
"alg": "HS256",
"typ": "JWT"
}
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
}
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
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.
The Authentication Flow
Let's walk through a complete login lifecycle.
Registration
User submits:
json
{
"email": "user@example.com",
"password": "password123"
}
Server:
- Checks if email already exists
- Hashes password
- Saves user
Database stores:
Email: user@example.com
Password: $2a$10$L6...
Never:
Password: password123
Why Password Hashing Matters
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.
Logging In
When a user logs in:
json
{
"email": "user@example.com",
"password": "password123"
}
The server:
Step 1
Finds the user.
js
const user = await prisma.user.findUnique({
where: {
email,
},
});
Step 2
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.
Generating an Access Token
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.
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"
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.
Refresh Tokens
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.
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,
});
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())
}
Generate client:
npx prisma generate
Push schema:
npx prisma db push
Or create migrations:
npx prisma migrate dev
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
The controller becomes the central authentication orchestrator.
Protecting Routes
Authentication isn't useful until routes are protected.
Example:
http
GET /profile
Only authenticated users should access it.
This is where middleware shines.
Building Auth Middleware
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();
};
What Happens During Verification?
The server checks:
- Signature validity
- Secret key match
- Expiration date
If any check fails:
js
jwt.verify()
throws an error.
Request gets rejected.
Accessing User Data
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.
JWT is Stateless
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.
Common Mistakes Beginners Make
Storing Passwords in JWTs
Never.
JWT payloads are readable.
Forgetting Expiration
Always use:
expiresIn
Using decode() for Authentication
Wrong:
jwt.decode(token)
Decode does NOT verify.
Use:
jwt.verify(token)
for authentication.
Returning Passwords
Never send:
user.password
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
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)