Build a Todo App with Authentication
Introduction
Todo applications are a classic programming project that helps you learn essential concepts. In this tutorial, we'll build a todo app with user authentication, allowing each user to have their own private list of tasks.
By the end of this tutorial, you'll have a fully functional todo application where users can register, login, and manage their personal tasks.
What You'll Build
- User registration and login system
- JWT-based authentication
- Create, read, update, delete todos
- Mark todos as complete/incomplete
- Protected routes that require authentication
What You'll Learn
- User authentication with JWT
- Password hashing with bcrypt
- Protected API routes
- MongoDB with Mongoose
- Frontend-backend integration
Project Overview
Our todo application will have two main components: authentication and todo management.
Authentication Features
- User registration with email and password
- Secure login with JWT tokens
- Password hashing for security
- Protected routes
Todo Features
- Create new todos
- View all todos
- Update todo (title, completion status)
- Delete todos
- Filter by completed/pending
Prerequisites
- Node.js installed - Version 14 or higher
- MongoDB - Local or Atlas account
- Code editor - VS Code recommended
- Basic JavaScript knowledge
Project Setup
Bash
# Create project directory
mkdir todo-auth-app
cd todo-auth-app
# Initialize Node.js
npm init -y
# Install dependencies
npm install express mongoose bcryptjs jsonwebtoken cors dotenv express-validator
# Install dev dependency
npm install --save-dev nodemon
Project Structure
File Structure
todo-auth-app/
├── config/
│ └── db.js
├── middleware/
│ └── auth.js
├── models/
│ ├── User.js
│ └── Todo.js
├── routes/
│ ├── auth.js
│ └── todos.js
├── .env
├── server.js
└── package.json
Database Models
Let's create the MongoDB models for users and todos.
JavaScript
// models/User.js
const mongoose = require('mongoose');
const bcrypt = require('bcryptjs');
const userSchema = mongoose.Schema({
name: {
type: String,
required: [true, 'Name is required'],
trim: true
},
email: {
type: String,
required: [true, 'Email is required'],
unique: true,
lowercase: true,
trim: true,
match: [/^\S+@\S+\.\S+$/, 'Please use a valid email']
},
password: {
type: String,
required: [true, 'Password is required'],
minlength: [6, 'Password must be at least 6 characters'],
select: false
}
}, {
timestamps: true
});
// Hash password before saving
userSchema.pre('save', async function(next) {
if (!this.isModified('password')) return next();
const salt = await bcrypt.genSalt(10);
this.password = await bcrypt.hash(this.password, salt);
next();
});
// Compare password method
userSchema.methods.matchPassword = async function(enteredPassword) {
return await bcrypt.compare(enteredPassword, this.password);
};
module.exports = mongoose.model('User', userSchema);
JavaScript
// models/Todo.js
const mongoose = require('mongoose');
const todoSchema = mongoose.Schema({
user: {
type: mongoose.Schema.Types.ObjectId,
required: true,
ref: 'User'
},
title: {
type: String,
required: [true, 'Todo title is required'],
trim: true
},
completed: {
type: Boolean,
default: false
}
}, {
timestamps: true
});
// Index for faster queries
todoSchema.index({ user: 1, completed: 1 });
module.exports = mongoose.model('Todo', todoSchema);
Authentication System
Now let's create the authentication routes and middleware.
JavaScript
// routes/auth.js
const express = require('express');
const router = express.Router();
const jwt = require('jsonwebtoken');
const { check, validationResult } = require('express-validator');
const User = require('../models/User');
// Generate JWT token
const generateToken = (id) => {
return jwt.sign({ id }, process.env.JWT_SECRET, {
expiresIn: '30d'
});
};
// @route POST /api/auth/register
// @desc Register user
// @access Public
router.post('/register', [
check('name', 'Name is required').not().isEmpty(),
check('email', 'Please include a valid email').isEmail(),
check('password', 'Password must be 6+ characters').isLength({ min: 6 })
], async (req, res) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
const { name, email, password } = req.body;
try {
let user = await User.findOne({ email });
if (user) {
return res.status(400).json({ message: 'User already exists' });
}
user = new User({ name, email, password });
await user.save();
res.status(201).json({
_id: user._id,
name: user.name,
email: user.email,
token: generateToken(user._id)
});
} catch (err) {
console.error(err.message);
res.status(500).json({ message: 'Server error' });
}
});
// @route POST /api/auth/login
// @desc Login user
// @access Public
router.post('/login', [
check('email', 'Please include a valid email').isEmail(),
check('password', 'Password is required').exists()
], async (req, res) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
const { email, password } = req.body;
try {
const user = await User.findOne({ email }).select('+password');
if (!user || !(await user.matchPassword(password))) {
return res.status(401).json({ message: 'Invalid credentials' });
}
res.json({
_id: user._id,
name: user.name,
email: user.email,
token: generateToken(user._id)
});
} catch (err) {
console.error(err.message);
res.status(500).json({ message: 'Server error' });
}
});
module.exports = router;
JavaScript
// middleware/auth.js
const jwt = require('jsonwebtoken');
const User = require('../models/User');
const protect = async (req, res, next) => {
let token;
if (req.headers.authorization &&
req.headers.authorization.startsWith('Bearer')) {
token = req.headers.authorization.split(' ')[1];
}
if (!token) {
return res.status(401).json({ message: 'Not authorized, no token' });
}
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
req.user = await User.findById(decoded.id);
next();
} catch (err) {
res.status(401).json({ message: 'Not authorized, token failed' });
}
};
module.exports = { protect };
Security Best Practices
- Always hash passwords before storing
- Use environment variables for secrets
- Set appropriate token expiration
- Never expose sensitive data in responses
Todo Routes
Now let's create the CRUD operations for todos.
JavaScript
// routes/todos.js
const express = require('express');
const router = express.Router();
const { check, validationResult } = require('express-validator');
const Todo = require('../models/Todo');
const { protect } = require('../middleware/auth');
// @route GET /api/todos
// @desc Get all todos for user
// @access Private
router.get('/', protect, async (req, res) => {
try {
const { completed } = req.query;
let query = { user: req.user.id };
if (completed !== undefined) {
query.completed = completed === 'true';
}
const todos = await Todo.find(query).sort({ createdAt: -1 });
res.json(todos);
} catch (err) {
console.error(err.message);
res.status(500).json({ message: 'Server error' });
}
});
// @route POST /api/todos
// @desc Create new todo
// @access Private
router.post('/', [
protect,
check('title', 'Title is required').not().isEmpty()
], async (req, res) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
try {
const { title } = req.body;
const todo = new Todo({
user: req.user.id,
title
});
await todo.save();
res.status(201).json(todo);
} catch (err) {
console.error(err.message);
res.status(500).json({ message: 'Server error' });
}
});
// @route PUT /api/todos/:id
// @desc Update todo
// @access Private
router.put('/:id', protect, async (req, res) => {
try {
let todo = await Todo.findById(req.params.id);
if (!todo) {
return res.status(404).json({ message: 'Todo not found' });
}
if (todo.user.toString() !== req.user.id.toString()) {
return res.status(401).json({ message: 'Not authorized' });
}
const { title, completed } = req.body;
if (title) todo.title = title;
if (completed !== undefined) todo.completed = completed;
await todo.save();
res.json(todo);
} catch (err) {
console.error(err.message);
res.status(500).json({ message: 'Server error' });
}
});
// @route DELETE /api/todos/:id
// @desc Delete todo
// @access Private
router.delete('/:id', protect, async (req, res) => {
try {
const todo = await Todo.findById(req.params.id);
if (!todo) {
return res.status(404).json({ message: 'Todo not found' });
}
if (todo.user.toString() !== req.user.id.toString()) {
return res.status(401).json({ message: 'Not authorized' });
}
await todo.deleteOne();
res.json({ message: 'Todo removed' });
} catch (err) {
console.error(err.message);
res.status(500).json({ message: 'Server error' });
}
});
module.exports = router;
Building the Frontend
Let's create a simple frontend to interact with our API.
JavaScript
// Simple frontend logic (app.js)
const API_URL = 'http://localhost:5000/api';
let token = localStorage.getItem('token');
// Auth functions
async function register(name, email, password) {
const res = await fetch(`${API_URL}/auth/register`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, email, password })
});
const data = await res.json();
if (data.token) {
localStorage.setItem('token', data.token);
token = data.token;
return true;
}
return false;
}
async function login(email, password) {
const res = await fetch(`${API_URL}/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password })
});
const data = await res.json();
if (data.token) {
localStorage.setItem('token', data.token);
token = data.token;
return true;
}
return false;
}
function logout() {
localStorage.removeItem('token');
token = null;
}
// Todo functions
async function getTodos() {
const res = await fetch(`${API_URL}/todos`, {
headers: { 'Authorization': `Bearer ${token}` }
});
return await res.json();
}
async function createTodo(title) {
const res = await fetch(`${API_URL}/todos`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({ title })
});
return await res.json();
}
async function toggleTodo(id, completed) {
const res = await fetch(`${API_URL}/todos/${id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({ completed })
});
return await res.json();
}
async function deleteTodo(id) {
const res = await fetch(`${API_URL}/todos/${id}`, {
method: 'DELETE',
headers: { 'Authorization': `Bearer ${token}` }
});
return await res.json();
}
Testing the Application
Bash
# Start the server
node server.js
# Test with curl
# Register
curl -X POST http://localhost:5000/api/auth/register \
-H "Content-Type: application/json" \
-d '{"name":"John","email":"john@example.com","password":"123456"}'
# Login
curl -X POST http://localhost:5000/api/auth/login \
-H "Content-Type: application/json" \
-d '{"email":"john@example.com","password":"123456"}'
# Get todos (with token)
curl http://localhost:5000/api/todos \
-H "Authorization: Bearer YOUR_TOKEN"
Testing Checklist
- ✓ User can register
- ✓ User can login
- ✓ Protected routes require token
- ✓ Can create todos
- ✓ Can toggle todo completion
- ✓ Can delete todos
- ✓ Users only see their own todos
Summary
Congratulations! You've built a complete Todo application with authentication.
What You Built
- User Authentication - Register, login, JWT tokens
- Password Security - bcrypt hashing
- Protected Routes - Middleware for auth
- CRUD Operations - Full todo management
Next Steps
- Add a frontend UI
- Implement todo categories/tags
- Add due dates
- Add email verification