I'm currently 9 weeks into a 16-week Express.js roadmap I set for myself. The goal at the end is to build a full client portal for my agency, Velto — with client login, project tracking, invoices, and Paystack payments.
This week I hit the JWT auth checkpoint. Register, login, protected routes, refresh tokens, logout. All of it from scratch, no Auth0, no Supabase magic auth, no shortcuts.
This post is everything — the concepts, the code, the mistakes, and the things most tutorials gloss over.
What is a JWT, actually?
A JWT (JSON Web Token) is a string with three base64-encoded parts separated by dots:
header.payload.signature
The header tells you the algorithm used. The payload is your actual data — userId, email, whatever you put in. The signature is the header and payload hashed together with your secret key.
Here's the critical thing most tutorials don't hammer home enough: the header and payload are not encrypted. Anyone can decode them. What they cannot do is forge the signature without your secret key. That's the entire security model. The server doesn't need to store the token or look it up in a database on every request — it just checks if the signature is valid.
Think of it like a government-issued ID. Anyone can read what's on the card. Nobody can fake the hologram.
Access Tokens vs Refresh Tokens — Why You Need Both
This is where most beginner JWT tutorials fall apart. They give you one token, store it in localStorage, call it done. That's a problem.
If you make your access token last 30 days, a stolen token is a 30-day problem. If you make it last 15 minutes, users have to log in every 15 minutes. Neither is acceptable.
The solution is two tokens:
Access token — short-lived (15 minutes). Sent with every request in the Authorization header. Small blast radius if stolen.
Refresh token — long-lived (7 days). Stored in an httpOnly cookie. Used only to get a new access token when the current one expires. Never touches localStorage.
httpOnly cookies cannot be accessed by JavaScript at all — not by your code, not by malicious scripts. This is why we use them for the refresh token. XSS attacks can't steal what they can't read.
Project Structure
jwt-auth-api/
src/
controllers/
authController.js
middleware/
authMiddleware.js
routes/
authRoute.js
app.js
prismaClient.js
index.js
prisma/
schema.prisma
.env
]
Clean separation from the start. Controllers handle logic. Routes handle mapping. Middleware handles gatekeeping.
The Prisma Setup
One thing that burned me early: Prisma 6 changed the generator syntax. If you're on Prisma 6 and you see provider = "prisma-client" auto-generated in your schema, that generates TypeScript output files into a local folder — which Node.js can't run directly in a CommonJS project.
The fix is to use the classic generator:
js
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
This puts the compiled client in node_modules/@prisma/client where Node can find it normally. Import it like:
js
const { PrismaClient } = require('@prisma/client')
Also — if you're on Supabase free tier, use npx prisma db push instead of npx prisma migrate dev. The migrate command requires a shadow database that Supabase free tier doesn't provide. db push syncs your schema directly and is fine for development.
The User model for this project:
prisma
model User {
id Int @id @default(autoincrement())
email String @unique
password String
createdAt DateTime @default(now())
}
One shared Prisma instance across the entire app:
js
// src/prismaClient.js
const { PrismaClient } = require('@prisma/client')
const prisma = new PrismaClient()
module.exports = prisma
Never instantiate PrismaClient in multiple files — each instance opens a connection pool. One file, one instance, export it everywhere.
The Register Route
js
async function register(req, res, next) {
const { email, password } = req.body
try {
const existingUser = await prisma.user.findUnique({
where: { email }
})
if (existingUser) {
return res.status(409).json('That email is already taken')
}
const hash = await bcrypt.hash(password, 10)
const newUser = await prisma.user.create({
data: { email, password: hash }
})
res.status(201).json({
userId: newUser.id,
email: newUser.email
})
} catch (err) {
next(err)
}
}
A few things worth noting:
bcrypt.hash() is asynchronous — it returns a Promise. Forgetting await here means you store the string [object Promise] in your database. Your users will never be able to log in again because their password is now literally a Promise object.
Salt rounds of 10 is the standard. It controls how computationally expensive the hash is. Higher = more secure, slower. 10 is the industry default balance.
Never return the hashed password in your response. Return only what the client actually needs — id and email.
The Login Route
js
async function login(req, res, next) {
const { email, password } = req.body
try {
const user = await prisma.user.findUnique({ where: { email } })
if (!user) {
return res.status(404).json('No account found with that email')
}
const passwordMatch = await bcrypt.compare(password, user.password)
if (!passwordMatch) {
return res.status(401).json('Invalid credentials')
}
const accessToken = jwt.sign(
{ id: user.id, email: user.email },
process.env.JWT_SECRET,
{ expiresIn: '15m' }
)
const refreshToken = jwt.sign(
{ id: user.id, email: user.email },
process.env.JWT_REFRESH_SECRET,
{ expiresIn: '7d' }
)
res.cookie('refreshToken', refreshToken, {
httpOnly: true,
sameSite: 'strict',
maxAge: 7 * 24 * 60 * 60 * 1000
})
return res.status(200).json({
id: user.id,
email: user.email,
accessToken
})
} catch (err) {
next(err)
}
}
bcrypt.compare() is what does the verification. You never decrypt a bcrypt hash — it's a one-way function. Instead, compare() hashes the submitted password the same way and checks if the outputs match.
Notice the two different secrets. Access token uses JWT_SECRET. Refresh token uses JWT_REFRESH_SECRET. This is intentional — if your access token secret leaks, an attacker cannot forge refresh tokens. They're completely independent.
The cookie options matter:
httpOnly: true — JavaScript cannot read this cookie. Period.
sameSite: 'strict' — the cookie is never sent on cross-site requests, which prevents CSRF attacks
maxAge — set to match the refresh token expiry so they expire together
Auth Middleware
This is the gatekeeper. Every protected route runs through this function first.
js
const jwt = require('jsonwebtoken')
function authMiddleware(req, res, next) {
const authHeader = req.headers.authorization
if (!authHeader) {
return res.status(401).json('No authorization header')
}
const token = authHeader.split(' ')[1]
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET)
req.user = decoded
next()
} catch (err) {
return res.status(401).json('Invalid or expired token')
}
}
module.exports = authMiddleware
The Authorization header arrives as a string: Bearer eyJhbGci.... Split on the space, grab index 1.
jwt.verify() is synchronous by default. It does two things: validates the signature and checks if the token has expired. If either fails, it throws. That's why the try/catch is essential — you can't use if (!decoded) because verify doesn't return null on failure, it throws an error.
Once verified, the decoded payload — which contains the id and email you signed in during login — gets attached to req.user. Every protected route then has access to the current user without a database lookup.
The Refresh Route
js
function refresh(req, res, next) {
const refreshToken = req.cookies.refreshToken
try {
if (!refreshToken) {
return res.status(401).json('No refresh token')
}
const decoded = jwt.verify(refreshToken, process.env.JWT_REFRESH_SECRET)
const newAccessToken = jwt.sign(
{ id: decoded.id, email: decoded.email },
process.env.JWT_SECRET,
{ expiresIn: '15m' }
)
return res.status(200).json(newAccessToken)
} catch (err) {
next(err)
}
}
The secret rule: verify with the same secret you signed with. The refresh token was signed with JWT_REFRESH_SECRET during login, so you verify it with JWT_REFRESH_SECRET here. The new access token is signed with JWT_SECRET because that's what the auth middleware expects.
req.cookies.refreshToken — this is why cookie-parser is registered in app.js. Without it, req.cookies doesn't exist.
Logout
js
function logout(req, res) {
res.clearCookie('refreshToken')
res.status(200).json({ message: 'Logged out' })
}
Simple. Clear the cookie. The access token will naturally expire in 15 minutes — there's no server-side revocation needed at this stage.
The Routes
js
const express = require('express')
const router = express.Router()
const { register, login, logout, refresh } = require('../controllers/authController')
const authMiddleware = require('../middleware/authMiddleware')
router.post('/register', register)
router.post('/login', login)
router.post('/logout', logout)
router.post('/refresh', refresh)
router.get('/me', authMiddleware, (req, res) => res.json(req.user))
module.exports = router
/me is the protected route. authMiddleware sits between the route and the handler — Express runs them in order. If the middleware calls next(), the handler runs. If it returns a response, the handler never runs.
What the .env needs
DATABASE_URL=your_supabase_connection_string
JWT_SECRET=make_this_long_and_random
JWT_REFRESH_SECRET=make_this_different_and_also_long
PORT=3000
Generate your secrets with something like:
node -e "console.log(require('crypto').randomBytes(64).toString('hex'))"
If you're building something similar, the thing most tutorials skip is the two-token architecture. Get that right and everything else falls into place.
Remember write code as art
Top comments (0)