2 - 3. User Authentication
In this article, I’ll walk you through how I set up user authentication in my project. I’ll explain how the backend handles authentication with Prisma, PostgreSQL, and JWT, and how the front-end interacts with the API.
Key Features:
- Email/Password authentication only (no sign-ups, just a demo account).
- JWT tokens stored in HttpOnly cookies.
- Demo database seeding on each login for a fresh test environment.
- Zod for data validation, with a middleware to enforce strict typing.
1. Setting Up the Backend Authentication API
1.1 User model in Prisma
model User {
id String @id @default(uuid())
username String
email String
password String
company Company? @relation(fields: [companyId], references: [id])
companyId String?
}
Passwords are hashed using bcrypt before being stored in the database.
1.2 Authentication API
Since account creation is disabled, I have a login route with a shared demo account and the SignUp route has the same behaviour.
Demo Account Credentials:
- Email: demo@company-demo.com
- Password: password
The login flow is:
- The user submits their email and password
- We verify the credentials
- If successful, we generate a JWT token and store it in a HttpOnly cookie, created by the API
- We seed the database with demo data for this user only, so each test session starts fresh
router.post(
'/signin',
authRateLimiter,
validateData(signInSchema),
async (req, res, next) => {
try {
const { email, password } = req.body
const saltRounds = parseInt(process.env.SALT || '10', 10)
const hashedPassword = bcrypt.hashSync('password', saltRounds)
if (
email !== 'demo@company-demo.com' ||
!(await arePasswordsEqual(password, hashedPassword))
) {
res
.status(StatusCodes.BAD_REQUEST)
.json({ message: 'Invalid email or password' })
return
}
// Create a new demo company for each sign-in,
// ensuring that each connected user has their own isolated environment for interaction.
const user = await seedDemoData()
const token = generateToken(user)
res.cookie('auth_token', token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
path: '/',
expires: new Date(Date.now() + 86400000),
})
res.json({ success: true })
} catch (error) {
next(error)
}
}
)
Here the seed.ts
file:
import bcrypt from 'bcrypt'
import { PrismaClient } from './prismaClient.js'
const prisma = new PrismaClient()
export async function seedDemoData() {
try {
const company = await prisma.company.create({
data: {
name: 'Company Demo',
},
})
const companySlug = company.name.toLowerCase().replace(/ /g, '-')
const saltRounds = parseInt(process.env.SALT || '10', 10)
const hashedPassword = bcrypt.hashSync('password', saltRounds)
const demoUser = await prisma.user.create({
data: {
email: `demo@${companySlug}.com`,
password: hashedPassword,
username: 'demo',
companyId: company.id,
},
})
return demoUser
} catch (e) {
console.error('Error seeding demo data:', e)
throw e
}
}
1.3 Handling Authenticated Users
Once a user logs in, they need to access protected routes that require authentication. To simplify this, I created a middleware that:
- Extracts the JWT token from the request cookies.
- Verifies the token using the secret key.
- Adds the user ID and company ID to the request object for easy access in protected routes.
This ensures that only authenticated users can access certain endpoints.
import { NextFunction, Request, Response } from 'express'
import { StatusCodes } from 'http-status-codes'
import jwt from 'jsonwebtoken'
const secretKey = process.env.JWT_SECRET || 'secret'
export interface AuthenticatedRequest extends Request {
userId: string
companyId: string
}
export const authenticate = async (
req: Request,
res: Response,
next: NextFunction
) => {
try {
const token = req.cookies.auth_token
if (!token) {
res.status(StatusCodes.UNAUTHORIZED).json({ message: 'Unauthorized' })
return
}
const decoded = jwt.verify(token, secretKey) as {
id: string
companyId: string
}
if (!decoded.id || !decoded.companyId) {
res.status(StatusCodes.UNAUTHORIZED).json({ message: 'Invalid token' })
return
}
;(req as AuthenticatedRequest).userId = decoded.id
;(req as AuthenticatedRequest).companyId = decoded.companyId
next()
} catch (error) {
res
.status(StatusCodes.UNAUTHORIZED)
.json({ message: 'Invalid or expired token' })
return
}
}
With this middleware, I can now secure any route by simply adding authenticate as middleware.
router.get('/me', authenticate, async (req, res, next) => {
try {
const { userId, companyId } = req as AuthenticatedRequest
const user = await getUserById(userId, companyId)
res.status(StatusCodes.OK).json(user)
} catch (error) {
next(error)
}
})
2. Managing Authentication on the Frontend
2.1 Storing and Sending the JWT Token
Since JWT is stored in an HttpOnly cookie, it is not accessible from Javascript (Client) preventing XSS attacks, but you need to send it with each request.
With Axios, you can enable automatic cookie handling by setting withCredentials: true
.
To avoid adding this option manually in every request, I created an Axios instance and reused it across my project.
const axiosInstance = axios.create({
baseURL: API_URL,
withCredentials: true,
headers: {
'Content-Type': 'application/json',
},
})
// Every fetch requests will use this Class
export const HttpService = {
get: async <T = unknown>(
url: string,
headers?: Record<string, string>
): Promise<T> => {
const response = await axiosInstance.get<T>(url, { headers })
return response.data
}
}
2.2 Protected routes with Layout.tsx
With NextJS, to ensure that only authenticated users can access certain parts of the application, I use a protected route structure. This is done by wrapping all secure pages inside a (protected) directory, where the Layout.tsx component handles authentication checks.
app/
|-- (protected)/
|-- {every routes under (protected) needs authentication}
|-- Layout.tsx
|-- (public)/
|-- signin/
import { HttpService } from '@app/front/core/httpService'
import { AuthProvider } from '@app/front/provider/AuthProvider'
import { User } from '@prisma/client'
import { getCookie } from 'cookies-next/server'
import { cookies } from 'next/headers'
import { redirect } from 'next/navigation'
import React from 'react'
export const metadata = {
title: 'Welcome to front',
description: 'Generated by create-nx-workspace',
}
const fetchUser = async () => {
try {
// Because we are on a server component, we can get the HttpOnly cookies
const token = await getCookie('auth_token', { cookies })
if (!token) {
return undefined
}
return await HttpService.get<User>('/api/user/me', {
Cookie: `auth_token=${token}`,
})
} catch (error) {
return undefined
}
}
export default async function RootLayout({
children,
}: {
children: React.ReactNode
}) {
const user = await fetchUser()
if (!user) {
redirect('/auth/signin')
}
return <AuthProvider user={user}>{children}</AuthProvider>
}
Once the user is authenticated, I can access their data from anywhere in the app using a custom useAuth() hook.
'use client'
import { AuthContext } from '@app/front/provider/AuthProvider'
import { useContext } from 'react'
export const useAuth = () => {
return useContext(AuthContext)
}
// Get connected user
const user = useAuth()
3. Data Validation with Zod
Because you can never trust datas, I use Zod for request validation. Instead of manually checking each field, I created a middleware that validates the request body automatically.
Benefits of this approach:
- Type safety: Ensures the request body always matches the expected structure.
- Automatic error messages: Returns structured validation errors.
- Reusable: Works for any endpoint with different schemas.
import { NextFunction, Request, Response } from 'express'
import { StatusCodes } from 'http-status-codes'
import { z, ZodError } from 'zod'
export const validateData =
<Schema extends z.ZodType<unknown>>(schema: Schema) =>
(
req: Request<object, object, z.infer<Schema>>,
res: Response,
next: NextFunction
) => {
try {
req.body = schema.parse(req.body)
next()
} catch (error) {
if (error instanceof ZodError) {
const errorMessages = error.errors.map((issue) => ({
message: `${issue.path.join('.')} is ${issue.message}`,
}))
res
.status(StatusCodes.BAD_REQUEST)
.json({ error: 'Invalid data', details: errorMessages })
} else {
res
.status(StatusCodes.INTERNAL_SERVER_ERROR)
.json({ error: 'Internal Server Error' })
}
}
}
Now, we can easily apply validation to routes like this:
const loginSchema = z.object({
email: z.string().email(),
password: z.string().min(6),
})
app.post('/api/auth/login', validateData(loginSchema), async (req, res) => {
// Authentication logic here...
})
This makes sure the request body is always valid before reaching the actual logic.
Conclusion
In this article, I shared how I handle authentication in my project using Prisma, JWT, and Zod. Some key takeaways:
- Secure authentication with hashed passwords and HttpOnly cookies.
- Demo account with automatic database seeding on each login.
- Strict data validation using a reusable Zod middleware.
All the code shown in this article is available on my GitHub repository: Taskly (v0.4.4).