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 CaseRecommendationWhy
Learning/Personal ProjectsJWT/CookiesGreat for understanding auth concepts
Traditional Web AppSession CookiesSimpler, more secure by default
API-first/SPAJWTStateless, perfect for modern apps
POCClerk/Auth0Focus on business logic, not auth
Budget-consciousJWT/CookiesLogic 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

  1. Start both servers:
    # Terminal 1: Backend npm run dev
    # Terminal 2: Frontend npm run dev
  2. 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

Security Best Practices

JWT Security Tips:

  1. Use strong secrets – Generate a random 256-bit key for production
  2. Short expiration times – Consider 15 minutes for access tokens, longer for refresh tokens
  3. Implement refresh tokens – For better security and user experience
  4. Validate tokens properly – Always verify signature and expiration
  5. Use HTTPS in production – Never send tokens over HTTP

Frontend Security:

  1. Don’t store tokens in localStorage for sensitive apps – Consider memory storage + refresh tokens
  2. Implement automatic token refresh – Better user experience
  3. Handle token expiration gracefully – Redirect to login when needed
  4. 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:

  1. Check browser dev tools for network errors
  2. Verify JWT tokens at jwt.io
  3. Test API endpoints with Postman first
  4. 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.