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.