Dev Dissection — Week 4: Adding Auth, Cookies vs JWT, Auth0/Clerk Overview
Welcome back to Dev Dissection! In Week 3, we built a sleek React frontend that connects to our TODO API. This week, we’re adding the missing piece that turns our app from a prototype into a real application: user authentication.
By the end of this lesson, users will be able to sign up, log in, and see only their own TODOs.
Prerequisites
Make sure you have:
- Your Week 2 TODO API running (Express + MongoDB)
- Your Week 3 Next.js frontend working
- Basic understanding of HTTP headers and cookies
- A REST client like Postman or Hoppscotch for testing
Authentication Fundamentals: Why It Matters
Authentication answers the question: “Who is this user?”
Without auth, your TODO app is like a shared notebook—anyone can see and modify anything. With auth, each user gets their own private space.
Cookies vs JWT vs Auth Providers: The Complete Picture
Before we dive into code, let’s understand your options:
1. Session Cookies (Traditional Approach)
How it works:
- Server creates a session ID when user logs in
- Session ID stored in database with user info
- Browser stores session ID in HTTP-only cookie
- Every request includes the cookie automatically
Pros:
- Simple to implement
- Secure by default (HTTP-only cookies)
- Server has full control (can revoke sessions instantly)
- Smaller payload size
Cons:
- Requires server-side session storage
- Not ideal for microservices/distributed systems
- CSRF attacks possible (though preventable)
Best for: Traditional web apps, server-rendered applications
2. JWT (JSON Web Tokens)
How it works:
- Server creates a signed token containing user info
- Token sent to client (usually stored in localStorage/memory)
- Client sends token in Authorization header
- Server verifies token signature (no database lookup needed)
Pros:
- Stateless (no server-side storage needed)
- Perfect for APIs and microservices
- Self-contained (includes user info)
- Works great with SPAs and mobile apps
Cons:
- Larger payload size
- Hard to revoke before expiration
- Client-side storage security concerns
- Token replay attacks if not handled properly
Best for: APIs, SPAs, mobile apps, microservices
3. Auth Providers (Auth0, Clerk)
How it works:
- Third-party service handles all auth complexity
- Your app redirects to provider for login
- Provider redirects back with tokens/user info
- You focus on your business logic
Auth0/Clerk:
- Enterprise-focused, highly customizable
- Supports every auth method imaginable
- Great for complex requirements
Pros:
- No auth code to maintain
- Security handled by experts
- Features like MFA, social login out-of-the-box
- Compliance (SOC 2, GDPR, HIPAA) handled
Cons:
- Third-party dependency
- Monthly costs
- Less control over auth flow
- Potential vendor lock-in
Best for: Startups, teams wanting to move fast, complex auth requirements
Decision Matrix: Which Should You Choose?
| Use Case | Recommendation | Why |
|---|---|---|
| Learning/Personal Projects | JWT/Cookies | Great for understanding auth concepts |
| Traditional Web App | Session Cookies | Simpler, more secure by default |
| API-first/SPA | JWT | Stateless, perfect for modern apps |
| POC | Clerk/Auth0 | Focus on business logic, not auth |
| Budget-conscious | JWT/Cookies | Logic happening on your server, scales with your product |
For this tutorial, we’re implementing JWT because:
- It’s educational and widely used
- Perfect for our API + SPA architecture
- Stateless design is great for learning modern patterns
Building JWT Authentication: Backend
Let’s start by adding authentication to our Express API.
Step 1: Install Dependencies
npm install bcryptjs jsonwebtoken
npm install -D @types/bcryptjs @types/jsonwebtoken
Step 2: Create User Model
Create src/models/user.ts:
import mongoose from 'mongoose';
import bcrypt from 'bcryptjs';
interface IUser extends mongoose.Document {
email: string;
password: string;
name: string;
createdAt: Date;
comparePassword(candidatePassword: string): Promise<boolean>;
}
const userSchema = new mongoose.Schema({
email: {
type: String,
required: true,
unique: true,
lowercase: true,
trim: true,
},
password: {
type: String,
required: true,
minlength: 6,
},
name: {
type: String,
required: true,
trim: true,
},
}, { timestamps: true });
// Hash password before saving
userSchema.pre('save', async function(next) {
if (!this.isModified('password')) return next();
const salt = await bcrypt.genSalt(12);
this.password = await bcrypt.hash(this.password, salt);
next();
});
// Compare password method
userSchema.methods.comparePassword = async function(candidatePassword: string) {
return bcrypt.compare(candidatePassword, this.password);
};
export const User = mongoose.model<IUser>('User', userSchema);
Step 3: Update Todo Model
Update your existing Todo model to associate todos with users:src/index.ts :
// Add this to your existing todo schema
const todoSchema = new mongoose.Schema(
{
task: { type: String, required: true },
completed: { type: Boolean, default: false },
user: {
type: mongoose.Schema.Types.ObjectId,
ref: 'User',
required: true
}, // NEW: Link todos to users
},
{ timestamps: true },
);
Step 4: Create JWT Utilities
Create src/utils/jwt.ts:
import jwt from 'jsonwebtoken';
const JWT_SECRET = 'your-dev-secret-key';
const JWT_EXPIRES_IN = '1d';
export interface JWTPayload {
userId: string;
email: string;
}
export const generateToken = (payload: JWTPayload): string => {
return jwt.sign(payload, JWT_SECRET, { expiresIn: JWT_EXPIRES_IN });
};
export const verifyToken = (token: string): JWTPayload => {
return jwt.verify(token, JWT_SECRET) as JWTPayload;
};
Step 5: Create Auth Middleware
Create src/middleware/auth.ts:
import { Request, Response, NextFunction } from 'express';
import { verifyToken } from '../utils/jwt';
export interface AuthRequest extends Request {
user?: {
userId: string;
email: string;
};
}
export const authenticate = (
req: AuthRequest,
res: Response,
next: NextFunction,
) => {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
res.status(401).json({ error: 'Access token required' });
return;
}
const token = authHeader.substring(7); // Remove "Bearer " prefix
try {
const decoded = verifyToken(token);
req.user = decoded;
next();
} catch (error) {
res.status(401).json({ error: 'Invalid or expired token' });
}
};
Step 6: Create Auth Routes
Create src/routes/auth.ts:
import express, { Request, Response } from 'express';
import { User } from '../models/user';
import { generateToken } from '../utils/jwt';
const router = express.Router();
interface RegisterBody {
email: string;
password: string;
name: string;
}
interface LoginBody {
email: string;
password: string;
}
// Register
router.post(
'/register',
async (req: Request<{}, {}, RegisterBody>, res: Response) => {
try {
const { email, password, name } = req.body;
// Validation
if (!email || !password || !name) {
res.status(400).json({ error: 'All fields are required' });
return;
}
if (password.length < 6) {
res
.status(400)
.json({ error: 'Password must be at least 6 characters' });
return;
}
// Check if user exists
const existingUser = await User.findOne({ email });
if (existingUser) {
res.status(400).json({ error: 'User already exists' });
return;
}
// Create user
const user = await User.create({ email, password, name });
if (!user) {
res.status(400).json({ error: 'Failed to create user' });
return;
}
// Generate token
const token = generateToken({
userId: user.id,
email: user.email,
});
res.status(201).json({
message: 'User created successfully',
token,
user: {
id: user.id,
email: user.email,
name: user.name,
},
});
} catch (error) {
console.error('Register error:', error);
res.status(500).json({ error: 'Internal server error' });
}
},
);
// Login
router.post(
'/login',
async (req: Request<{}, {}, LoginBody>, res: Response) => {
try {
const { email, password } = req.body;
if (!email || !password) {
res.status(400).json({ error: 'Email and password are required' });
return;
}
// Find user
const user = await User.findOne({ email });
if (!user) {
res.status(400).json({ error: 'Invalid credentials' });
return;
}
// Check password
const isMatch = await user.comparePassword(password);
if (!isMatch) {
res.status(400).json({ error: 'Invalid credentials' });
return;
}
// Generate token
const token = generateToken({
userId: user.id,
email: user.email,
});
res.json({
message: 'Login successful',
token,
user: {
id: user.id,
email: user.email,
name: user.name,
},
});
} catch (error) {
console.error('Login error:', error);
res.status(500).json({ error: 'Internal server error' });
}
},
);
export { router as authRouter };
Step 7: Update Main Server File
Update your src/index.ts:
import cors from 'cors';
import dotenv from 'dotenv';
import express, { Response } from 'express';
import mongoose from 'mongoose';
import { authenticate, AuthRequest } from './middleware/auth';
import { authRouter } from './routes/auth';
dotenv.config();
const app = express();
const PORT = 4000;
app.use(cors());
app.use(express.json());
// Connect to MongoDB
mongoose
.connect(process.env.MONGO_URI!)
.then(() => console.log('Connected to MongoDB'))
.catch((err) => console.error('MongoDB connection error:', err));
// Auth routes (public)
app.use('/auth', authRouter);
const todoSchema = new mongoose.Schema(
{
task: { type: String, required: true },
completed: { type: Boolean, default: false },
user: { type: mongoose.Schema.Types.ObjectId, ref: 'User', required: true },
},
{ timestamps: true },
);
const Todo = mongoose.model('Todo', todoSchema);
// Protected todo routes
app.post('/todos', authenticate, async (req: AuthRequest, res: Response) => {
try {
const task = req.body.task;
if (!task) {
res.status(400).json({ error: 'Task is required' });
return;
}
const newTodo = await Todo.create({
task: task,
user: req.user!.userId,
});
res.status(201).json(newTodo);
} catch (err) {
res.status(400).json({ error: 'Failed to create TODO' });
}
});
app.get('/todos', authenticate, async (req: AuthRequest, res: Response) => {
const todos = await Todo.find({ user: req.user!.userId });
res.status(200).json(todos);
});
app.put('/todos/:id', authenticate, async (req: AuthRequest, res: Response) => {
try {
if (!mongoose.Types.ObjectId.isValid(req.params.id)) {
res.status(400).json({ error: 'Invalid ID format' });
return;
}
const todo = await Todo.findOne({
_id: req.params.id,
user: req.user!.userId,
});
if (!todo) {
res.status(404).json({ error: 'Todo not found' });
return;
}
const updated = await Todo.findByIdAndUpdate(req.params.id, req.body, {
new: true,
});
res.json(updated);
} catch (err) {
res.status(400).json({ error: 'Failed to update TODO' });
}
});
app.delete(
'/todos/:id',
authenticate,
async (req: AuthRequest, res: Response) => {
try {
if (!mongoose.Types.ObjectId.isValid(req.params.id)) {
res.status(400).json({ error: 'Invalid ID format' });
return;
}
const deleted = await Todo.findOneAndDelete({
_id: req.params.id,
user: req.user!.userId,
});
if (!deleted) {
res.status(404).json({ error: 'Todo not found' });
return;
}
res.status(204).send();
} catch (err) {
res.status(400).json({ error: 'Failed to delete TODO' });
}
},
);
app.listen(PORT, () => {
console.log(`Server running at http://localhost:${PORT}`);
});
Step 8: Update Environment Variables
Add to your .env file:
MONGO_URI=mongodb://localhost:27017/todos-dev
Building JWT Authentication: Frontend
Now let’s update our Next.js frontend to handle authentication.
Step 1: Create Auth Context
Create lib/auth-context.tsx:
'use client';
import { createContext, useContext, useEffect, useState } from 'react';
import { TodoAPI } from './api';
interface User {
id: string;
email: string;
name: string;
}
interface AuthContextType {
user: User | null;
token: string | null;
login: (email: string, password: string) => Promise<void>;
register: (email: string, password: string, name: string) => Promise<void>;
logout: () => void;
isLoading: boolean;
}
const AuthContext = createContext<AuthContextType | null>(null);
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [token, setToken] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
// Check for stored token on mount
const storedToken = localStorage.getItem('token');
const storedUser = localStorage.getItem('user');
if (storedToken && storedUser) {
setToken(storedToken);
setUser(JSON.parse(storedUser));
}
setIsLoading(false);
}, []);
const login = async (email: string, password: string) => {
const response = await TodoAPI.login(email, password);
setToken(response.token);
setUser(response.user);
localStorage.setItem('token', response.token);
localStorage.setItem('user', JSON.stringify(response.user));
};
const register = async (email: string, password: string, name: string) => {
const response = await TodoAPI.register(email, password, name);
setToken(response.token);
setUser(response.user);
localStorage.setItem('token', response.token);
localStorage.setItem('user', JSON.stringify(response.user));
};
const logout = () => {
setToken(null);
setUser(null);
localStorage.removeItem('token');
localStorage.removeItem('user');
};
return (
<AuthContext.Provider
value={{ user, token, login, register, logout, isLoading }}
>
{children}
</AuthContext.Provider>
);
}
export const useAuth = () => {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used within AuthProvider');
}
return context;
};
Step 2: Update API Layer
Update lib/api.ts:
import type { Todo, CreateTodoRequest, UpdateTodoRequest } from './types';
const API_URL = 'http://localhost:4000';
// Auth response types
interface AuthResponse {
token: string;
user: {
id: string;
email: string;
name: string;
};
message: string;
}
export class TodoAPI {
private static getAuthHeaders() {
const token = localStorage.getItem('token');
return {
'Content-Type': 'application/json',
...(token && { Authorization: `Bearer ${token}` }),
};
}
// Auth methods
static async register(
email: string,
password: string,
name: string
): Promise<AuthResponse> {
const response = await fetch(`${API_URL}/auth/register`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password, name }),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Registration failed');
}
return response.json();
}
static async login(email: string, password: string): Promise<AuthResponse> {
const response = await fetch(`${API_URL}/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Login failed');
}
return response.json();
}
static async getTodos(): Promise<Todo[]> {
const response = await fetch(`${API_URL}/todos`, {
headers: this.getAuthHeaders(),
});
if (!response.ok) throw new Error('Failed to fetch todos');
return response.json();
}
static async createTodo(data: CreateTodoRequest): Promise<Todo> {
const response = await fetch(`${API_URL}/todos`, {
method: 'POST',
headers: this.getAuthHeaders(),
body: JSON.stringify(data),
});
if (!response.ok) throw new Error('Failed to create todo');
return response.json();
}
static async toggleTodo(id: string, data: UpdateTodoRequest): Promise<Todo> {
const response = await fetch(`${API_URL}/todos/${id}`, {
method: 'PUT',
headers: this.getAuthHeaders(),
body: JSON.stringify(data),
});
if (!response.ok) throw new Error('Failed to update todo');
return response.json();
}
static async deleteTodo(id: string): Promise<void> {
const response = await fetch(`${API_URL}/todos/${id}`, {
method: 'DELETE',
headers: this.getAuthHeaders(),
});
if (!response.ok) throw new Error('Failed to delete todo');
}
}
Step 3: Create Auth Components
Create components/auth-form.tsx:
'use client';
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { useAuth } from '@/lib/auth-context';
interface AuthFormProps {
mode: 'login' | 'register';
onToggleMode: () => void;
}
export function AuthForm({ mode, onToggleMode }: AuthFormProps) {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [name, setName] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState('');
const { login, register } = useAuth();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsLoading(true);
setError('');
try {
if (mode === 'register') {
await register(email, password, name);
} else {
await login(email, password);
}
} catch (err) {
setError(err instanceof Error ? err.message : 'An error occurred');
} finally {
setIsLoading(false);
}
};
return (
<div className="max-w-md mx-auto">
<div className="bg-white p-6 rounded-lg shadow-md">
<h2 className="text-2xl font-bold mb-6 text-center">
{mode === 'login' ? 'Sign In' : 'Create Account'}
</h2>
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded mb-4">
{error}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
{mode === 'register' && (
<div>
<label htmlFor="name" className="block text-sm font-medium mb-2">
Full Name
</label>
<Input
id="name"
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
required
disabled={isLoading}
/>
</div>
)}
<div>
<label htmlFor="email" className="block text-sm font-medium mb-2">
Email
</label>
<Input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
disabled={isLoading}
/>
</div>
<div>
<label
htmlFor="password"
className="block text-sm font-medium mb-2"
>
Password
</label>
<Input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={6}
disabled={isLoading}
/>
</div>
<Button type="submit" className="w-full" isLoading={isLoading}>
{mode === 'login' ? 'Sign In' : 'Create Account'}
</Button>
</form>
<div className="mt-4 text-center">
<button
type="button"
onClick={onToggleMode}
className="text-blue-600 hover:underline cursor-pointer"
disabled={isLoading}
>
{mode === 'login'
? "Don't have an account? Sign up"
: 'Already have an account? Sign in'}
</button>
</div>
</div>
</div>
);
}
Step 4: Create Navigation Component
Create components/navbar.tsx:
'use client';
import { useAuth } from '@/lib/auth-context';
import { Button } from '@/components/ui/button';
import { LogOut, User } from 'lucide-react';
export function Navbar() {
const { user, logout } = useAuth();
if (!user) return null;
return (
<nav className="bg-white shadow-sm border-b">
<div className="container mx-auto px-4 py-3 flex justify-between items-center">
<h1 className="text-xl font-semibold text-gray-900">My Todos</h1>
<div className="flex items-center gap-4">
<div className="flex items-center gap-2 text-sm text-gray-600">
<User className="h-4 w-4" />
{user.name}
</div>
<Button variant="ghost" size="sm" onClick={logout}>
<LogOut className="h-4 w-4" />
Sign Out
</Button>
</div>
</div>
</nav>
);
}
Step 5: Update Main App
Update app/layout.tsx:
import type { Metadata } from 'next';
import './globals.css';
import { AuthProvider } from '@/lib/auth-context';
export const metadata: Metadata = {
title: 'Todo App',
description: 'A secure todo application with authentication',
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body>
<AuthProvider>{children}</AuthProvider>
</body>
</html>
);
}
Update app/page.tsx:
'use client';
import { useState } from 'react';
import { useAuth } from '@/lib/auth-context';
import { TodoList } from '@/components/todo-list';
import { AuthForm } from '@/components/auth-form';
import { Navbar } from '@/components/navbar';
import { Loader2 } from 'lucide-react';
export default function Home() {
const { user, isLoading } = useAuth();
const [authMode, setAuthMode] = useState<'login' | 'register'>('login');
if (isLoading) {
return (
<div className="min-h-screen flex items-center justify-center">
<Loader2 className="h-6 w-6 animate-spin" />
</div>
);
}
if (!user) {
return (
<div className="min-h-screen bg-gray-50 py-12">
<div className="container mx-auto px-4">
<div className="text-center mb-8">
<h1 className="text-3xl font-bold text-gray-900 mb-2">
Welcome to Todo App
</h1>
<p className="text-gray-600">Sign in to manage your tasks</p>
</div>
<AuthForm
mode={authMode}
onToggleMode={() =>
setAuthMode(authMode === 'login' ? 'register' : 'login')
}
/>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-gray-50">
<Navbar />
<div className="container mx-auto px-4 py-8">
<div className="text-center mb-8">
<h2 className="text-2xl font-bold text-gray-900 mb-2">Your Tasks</h2>
<p className="text-gray-600">Stay organized and productive</p>
</div>
<TodoList />
</div>
</div>
);
}
Testing Your Authenticated App
- Start both servers:
# Terminal 1: Backend npm run dev# Terminal 2: Frontend npm run dev - Test the flow:
- Visit
http://localhost:3000 - Create an account
- Add some todos
- Sign out and sign back in
- Verify your todos are still there
- Create another account and verify you don’t see the first user’s todos
- Visit
Security Best Practices
JWT Security Tips:
- Use strong secrets – Generate a random 256-bit key for production
- Short expiration times – Consider 15 minutes for access tokens, longer for refresh tokens
- Implement refresh tokens – For better security and user experience
- Validate tokens properly – Always verify signature and expiration
- Use HTTPS in production – Never send tokens over HTTP
Frontend Security:
- Don’t store tokens in localStorage for sensitive apps – Consider memory storage + refresh tokens
- Implement automatic token refresh – Better user experience
- Handle token expiration gracefully – Redirect to login when needed
- Validate user input – Always sanitize data before sending to API
When to Use Each Approach
Choose JWT when:
- Building APIs or microservices
- Creating SPAs or mobile apps
- Need stateless authentication
- Scaling across multiple servers
- Working with third-party APIs
Choose Session Cookies when:
- Building traditional web apps
- Security is paramount
- Need immediate session revocation
- Working with server-rendered pages
- Want simpler implementation
Choose Auth0/Clerk when:
- Want to focus on business logic
- Need enterprise features (SSO, MFA)
- Have compliance requirements (HIPAA, GDRP)
- Building an MVP quickly
What You Learned
This week, you:
✅ Understood the authentication landscape – JWT vs Cookies vs Auth providers
✅ Implemented JWT authentication – From scratch with secure best practices
✅ Protected your API routes – Only authenticated users can access their data
✅ Built authentication UI – Login, register, and user management
✅ Learned security best practices – How to handle tokens safely
Coming Up: Deployment & Production
In Week 5, we’ll take your app from localhost to the real world:
- Database hosting – MongoDB Atlas setup and migration
- API deployment – GCP App Engine
- Frontend deployment – Vercel deployment with environment variables
By the end, you’ll have a fully deployed, production-ready TODO app that you can share with the world!
Need help? The authentication flow can be tricky. If you get stuck:
- Check browser dev tools for network errors
- Verify JWT tokens at jwt.io
- Test API endpoints with Postman first
- Make sure MongoDB is running locally
Your TODO app is now a real application with user authentication. Each user has their own private space, and their data is secure.

Hey, I’m Bahroze, I specialize in helping startups build and launch MVPs, making sure you get your customers/clients onboarded fast. My approach mixes tech expertise with startup knowledge, making it easy to work with any tech stack. When I’m not coding, you’ll find me traveling, gaming, or listening to podcasts.
