← Back to Tutorials
Node.js

Build a Todo App with Authentication

Difficulty: Intermediate Est. Time: ~3 hours

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

Continue Learning

Try these tutorials:

  • Build a REST API
  • Build a Chat Application
  • Build a File Upload Server