A server-rendered task management application built with Node.js, Express, MongoDB, and EJS, featuring cookie-based JWT authentication, bcrypt password hashing, and per-user data isolation for to-do items.
Note on the name: despite "Backend" in the repository name, this project is not a JSON REST API. It is a full server-rendered MVC web application — Express controllers respond with rendered EJS pages and redirects, not JSON payloads. That distinction is documented throughout this README.
- Project Overview
- Key Features
- Screenshots
- Tech Stack
- Architecture Overview
- Folder Structure
- Authentication Flow
- Middleware Pipeline
- Request Lifecycle
- Todo CRUD Flow
- Database Schema
- Installation
- API Routes
- Security Features
- Validation
- Error Handling
- Dependencies
- Development Decisions
- Project Highlights
- Known Limitations
- Future Improvements
- Contributing
- License
- Author
ToDo-Backend is a personal task manager where each visitor creates an account, logs in, and manages a private list of to-do items. The application follows a classic MVC layout: Mongoose models define the data shape, Express routes map URLs to controllers, controllers contain the business logic, and EJS views render the resulting HTML that is sent back to the browser. Authentication is stateless and cookie-based: a signed JWT is issued at login, stored in an httpOnly cookie, and verified on every request to a protected route by a custom middleware.
There is no single-page app or client-side framework here — pages are rendered server-side and the browser navigates via standard form submissions (POST) and links (GET). Small amounts of client-side JavaScript exist only for pre-submit form validation.
- User registration and login with hashed passwords (
controller/authController.js). - Stateless authentication via JWT stored in an
httpOnlycookie (no server-side session store). - Route protection — all
/todos*routes are gated behindmiddleware/authMiddleware.js, which verifies the JWT before allowing access. - Per-user data isolation — every Todo query is scoped with
{ user: req.user.id }, so one user can never read, edit, toggle, or delete another user's tasks (controller/todoController.js). - Full CRUD on tasks: create, list, edit, update, toggle complete/incomplete, and delete.
- Filtering —
/todos?filter=pendingand/todos?filter=completednarrow the list via a Mongoose query built fromreq.query.filter. - Server-rendered views for login, registration, the task list, and task editing (
views/*.ejs). - Client-side pre-submit validation on the registration form (email/password regex) and the add-task form (non-empty title), implemented as plain DOM scripts in
public/js/. - Logout clears the auth cookie and redirects to
/login.
No screenshots are currently committed to this repository. This section is a placeholder — recommended additions:
| Screen | Suggested filename |
|---|---|
| Login page | docs/screenshots/login.png |
| Register page | docs/screenshots/register.png |
| Task list (with filters) | docs/screenshots/todos.png |
| Edit task page | docs/screenshots/edit-todo.png |
| Layer | Technology | Where it's used |
|---|---|---|
| Runtime | Node.js (CommonJS) | Entire project ("type": "commonjs" in package.json) |
| Web framework | Express 5 | server.js, routes/*.js |
| Database | MongoDB | Accessed via Mongoose |
| ODM | Mongoose | config/db.js, models/User.js, models/Todo.js |
| Templating | EJS | views/*.ejs, set as view engine in server.js |
| Authentication | jsonwebtoken (JWT) | controller/authController.js, middleware/authMiddleware.js |
| Password hashing | bcrypt | controller/authController.js |
| Cookies | cookie-parser | server.js (parses req.cookies.token) |
| Environment config | dotenv | server.js loads .env at startup |
| Static assets | Express static middleware | Serves public/ (CSS + client JS) |
| Dev tooling | nodemon | devDependencies, used for local auto-reload |
flowchart TD
A[Browser] -->|HTTP request + cookie| B[Express App - server.js]
B --> C[cookie-parser]
C --> D[Static file middleware - /public]
D --> E[express.urlencoded body parser]
E --> F{Router}
F -->|"/login /signup /logout"| G[authRoutes.js]
F -->|"/todos*"| H[todoRoutes.js]
H --> I[authMiddleware.js]
I -->|valid JWT| J[todoController.js]
I -->|missing/invalid JWT| K[Redirect to /login]
G --> L[authController.js]
J --> M[(MongoDB via Mongoose)]
L --> M
J --> N[EJS Views]
L --> N
N --> A
Every request flows through Express's global middleware chain first (cookie parsing → static files → body parsing), then into route-specific handling. The /todos* routes add an additional authentication gate that the /login, /signup, and /logout routes do not need, since account creation and login must be reachable by unauthenticated visitors.
ToDo-Backend/
├── config/
│ └── db.js # Mongoose connection (reads MONGO_URI)
├── controller/
│ ├── authController.js # register, login, logout logic
│ └── todoController.js # CRUD + filtering logic for tasks
├── middleware/
│ └── authMiddleware.js # verifies JWT cookie, sets req.user
├── models/
│ ├── User.js # name, email, password (hash)
│ └── Todo.js # user ref, title, deadline, priority, notes, completed
├── public/
│ ├── css/
│ │ └── style.css # shared styling for all views
│ └── js/
│ ├── login.js # empty — no client-side logic wired up
│ ├── register.js # pre-submit validation for registration
│ └── todos.js # pre-submit validation for adding a task
├── routes/
│ ├── authRoutes.js # /login, /signup, /logout
│ └── todoRoutes.js # /todos and its sub-routes, all behind auth
├── views/
│ ├── login.ejs
│ ├── register.ejs
│ ├── todos.ejs
│ └── editTodo.ejs
├── .gitattributes
├── .gitignore # ignores node_modules/ and .env
├── package.json
├── package-lock.json
└── server.js # app bootstrap, middleware wiring, DB connect + listen
config/isolates the database connection from application logic, soserver.jsonly has to callconnectDb()without knowing how Mongoose is configured.models/contains the two Mongoose schemas.Todostores auserfield (ObjectId,ref: "User") which is the foreign key tying every task to its owner.controller/holds the business logic. It is namedcontroller(singular) rather thancontrollers— the folder contains one file per resource (authController.js,todoController.js).middleware/currently contains a single custom middleware,authMiddleware.js, which is the enforcement point for authentication.routes/defines URL-to-controller mappings only; no logic lives here beyond wiring theauthmiddleware into the/todos*chain.views/contains the four EJS templates that are actually rendered by the controllers (login,register,todos,editTodo).public/is served statically by Express and holds CSS and the small client-side validation scripts. It is not a client-side application — no bundler, no framework, no build step.
flowchart TD
A[User submits register form] --> B["Client-side regex validation (public/js/register.js)"]
B -->|passes| C["POST /signup"]
C --> D["authController.createUser"]
D --> E["bcrypt.hash(password, 10)"]
E --> F["User.create({ name, email, password: hash })"]
F --> G["redirect to /login"]
flowchart TD
A["POST /login"] --> B["authController.loginValidate"]
B --> C["User.findOne({ email })"]
C -->|not found| D["render login.ejs with error"]
C -->|found| E["bcrypt.compare(password, user.password)"]
E -->|no match| D
E -->|match| F["jwt.sign({ id, email }, JWT_SECRET, { expiresIn: '1d' })"]
F --> G["res.cookie('token', jwt, { httpOnly: true, maxAge: 86400000 })"]
G --> H["redirect to /todos"]
| Step | Detail |
|---|---|
| Issued | On successful login, signed with process.env.JWT_SECRET, payload { id, email } |
| Expiry | 1d (enforced by jsonwebtoken, backed by a matching cookie maxAge of 24 hours) |
| Storage | httpOnly cookie named token — not accessible to client-side JavaScript, which mitigates XSS token theft |
| Verified | On every request to a route wrapped with authMiddleware — jwt.verify(token, JWT_SECRET) |
| Cleared | POST /logout calls res.clearCookie("token") and redirects to /login |
flowchart LR
A["Plaintext password from form"] --> B["bcrypt.hash(password, 10)"]
B --> C["Salted hash stored in User.password"]
C -.->|"login later"| D["bcrypt.compare(inputPassword, storedHash)"]
Hashing happens explicitly inside authController.createUser — there is no Mongoose pre("save") hook on the User model. This is a deliberate (if implicit) design choice visible in the code: the model stays a plain schema definition, and all password handling logic lives in the controller layer alongside the rest of the authentication flow.
Why bcrypt? Bcrypt is a purpose-built, adaptive password-hashing algorithm (as opposed to a general-purpose hash like SHA-256). It incorporates a per-password salt automatically and a configurable cost factor (10 rounds here), making brute-force and rainbow-table attacks significantly more expensive. Storing a bcrypt hash instead of the plaintext password means a database leak does not directly expose user credentials.
Why JWT in an httpOnly cookie instead of localStorage? Storing the token in an httpOnly cookie keeps it inaccessible to JavaScript running on the page, which closes off the most common vector for token theft via XSS. The cookie is also sent automatically by the browser on every request, which fits naturally with a server-rendered app that navigates via full page loads rather than an SPA issuing fetch/XHR calls with a manually attached Authorization header.
Why JWT instead of server-side sessions? A JWT lets the server verify a request's identity (jwt.verify) without querying a session store on every request — the user's identity is encoded and cryptographically signed inside the token itself. This keeps config/db.js and the data layer focused solely on application data (User, Todo) rather than also managing session state.
flowchart TD
A[Incoming Request] --> B["cookie-parser"]
B --> C["view engine set: ejs"]
C --> D["express.static('public')"]
D --> E["express.urlencoded({ extended: true })"]
E --> F["todoRoutes / authRoutes"]
F -->|"/todos*"| G["authMiddleware (custom)"]
G --> H[Controller]
authMiddleware.js is the only custom middleware in the application. It exists to centralize the authentication check in one place rather than repeating a JWT-verification block at the top of every protected controller function. It is applied selectively — per-route, as a second argument in todoRoutes.js (e.g. router.get("/todos", auth, todoController.getTodo)) — rather than globally with app.use, because authRoutes.js (/login, /signup) must remain reachable by unauthenticated visitors.
function auth(req, res, next) {
const token = req.cookies.token;
if (!token) return res.redirect('/login');
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
req.user = decoded;
next();
} catch (err) {
return res.redirect('/login');
}
}If the cookie is missing or the token fails verification (expired, tampered, wrong secret), the middleware redirects to /login rather than returning a JSON error — consistent with this being a server-rendered app, not an API.
Example: a logged-in user requests their task list.
sequenceDiagram
participant Browser
participant Express as server.js
participant Router as todoRoutes.js
participant Auth as authMiddleware.js
participant Ctrl as todoController.js
participant DB as MongoDB
Browser->>Express: GET /todos?filter=pending (Cookie: token=...)
Express->>Express: cookie-parser, static, urlencoded
Express->>Router: match "/todos"
Router->>Auth: auth(req, res, next)
Auth->>Auth: jwt.verify(token, JWT_SECRET)
Auth->>Ctrl: next() — req.user = { id, email }
Ctrl->>DB: Todo.find({ user: req.user.id, completed: false })
DB-->>Ctrl: matching todos[]
Ctrl->>Browser: res.render("todos", { todos })
flowchart TD
subgraph Create
A1["POST /todos"] --> A2[auth middleware]
A2 --> A3["todoController.createTodo"]
A3 --> A4["Todo.create({ user: req.user.id, title, deadline, priority, notes })"]
A4 --> A5["redirect /todos"]
end
subgraph Read
B1["GET /todos?filter="] --> B2[auth middleware]
B2 --> B3["todoController.getTodo"]
B3 --> B4["Todo.find(query scoped to req.user.id)"]
B4 --> B5["render todos.ejs"]
end
subgraph Update
C1["GET /todos/edit/:id"] --> C2[auth middleware]
C2 --> C3["todoController.editTodo"]
C3 --> C4["Todo.findOne scoped to user"]
C4 --> C5["render editTodo.ejs"]
C6["POST /todos/edit/:id"] --> C7[auth middleware]
C7 --> C8["todoController.updateTodo"]
C8 --> C9["Todo.findOneAndUpdate scoped to user"]
C9 --> C10["redirect /todos"]
end
subgraph Toggle
D1["POST /todos/toggle/:id"] --> D2[auth middleware]
D2 --> D3["todoController.toggleTodo"]
D3 --> D4["flip todo.completed, save()"]
D4 --> D5["redirect /todos"]
end
subgraph Delete
E1["POST /todos/delete/:id"] --> E2[auth middleware]
E2 --> E3["todoController.deleteTodo"]
E3 --> E4["Todo.findOneAndDelete scoped to user"]
E4 --> E5["redirect /todos"]
end
Every read, update, toggle, and delete operation filters by { _id: req.params.id, user: req.user.id } (see controller/todoController.js). This is the application's core authorization rule: it guarantees a user can only ever act on their own tasks, even if they guess or manipulate another user's task ID in the URL.
erDiagram
USER ||--o{ TODO : owns
USER {
ObjectId _id
String name
String email "unique, lowercase"
String password "bcrypt hash"
Date createdAt
Date updatedAt
}
TODO {
ObjectId _id
ObjectId user FK "ref: User"
String title
Date deadline "nullable"
String priority "low | medium | high"
String notes
Boolean completed "default false"
Date createdAt
Date updatedAt
}
| Field | Type | Constraints |
|---|---|---|
name |
String | required, trimmed |
email |
String | required, unique, lowercase, trimmed |
password |
String | required (stores the bcrypt hash, not plaintext) |
timestamps |
— | createdAt / updatedAt auto-managed by Mongoose |
| Field | Type | Constraints |
|---|---|---|
user |
ObjectId | required, ref: "User" — the ownership link |
title |
String | required, trimmed |
deadline |
Date | optional, defaults to null |
priority |
String | enum ["low", "medium", "high"], defaults to "medium" |
notes |
String | optional, defaults to "" |
completed |
Boolean | defaults to false |
timestamps |
— | createdAt / updatedAt auto-managed by Mongoose |
Relationship: one User owns many Todo documents (one-to-many), enforced application-side via the user field on every query and write in todoController.js — there is no MongoDB-level cascade delete, so removing a user would not automatically remove their todos (see Known Limitations).
- Node.js (project uses CommonJS modules and Express 5)
- A running MongoDB instance — local (
mongod) or a hosted cluster (e.g. MongoDB Atlas) npm(ships with Node.js)
# 1. Clone the repository
git clone https://github.com/snkhn007/ToDo-Backend.git
cd ToDo-Backend
# 2. Install dependencies
npm install
# 3. Create a .env file in the project root (see Environment Variables below)
# 4. Start the server
npm startThe repository does not include a .env.example file. Based on what server.js, config/db.js, and authController.js/authMiddleware.js read from process.env, create a .env file in the project root with:
| Variable | Required | Used in | Purpose |
|---|---|---|---|
MONGO_URI |
Yes | config/db.js |
Mongoose connection string for your MongoDB instance |
JWT_SECRET |
Yes | authController.js, authMiddleware.js |
Secret used to sign and verify JWTs |
PORT |
Yes | server.js (app.listen(process.env.PORT, ...)) |
Port the Express server listens on |
Note:
server.jsdeclares a localconst PORT = 3000but never actually uses it —app.listenreadsprocess.env.PORTdirectly. IfPORTis not set in.env, the app will not start on a predictable port. SetPORTexplicitly (e.g.PORT=3000).
Example .env:
MONGO_URI=mongodb://localhost:27017/todo-backend
JWT_SECRET=replace-with-a-long-random-string
PORT=3000npm start # runs `node server.js`nodemon is listed as a dev dependency but there is no dev script defined in package.json; to use it for auto-reload during development, run it directly:
npx nodemon server.jsOnce running, visit http://localhost:<PORT>/signup to create an account.
These routes render server-side HTML (EJS) or issue redirects — they do not return JSON. There is no separate JSON API surface in this codebase.
| Method | Path | Auth required | Handler | Behavior |
|---|---|---|---|---|
| GET | /login |
No | inline route | Renders login.ejs |
| GET | /signup |
No | inline route | Renders register.ejs |
| POST | /signup |
No | authController.createUser |
Hashes password, creates User, redirects to /login |
| POST | /login |
No | authController.loginValidate |
Verifies credentials, issues JWT cookie, redirects to /todos |
| POST | /logout |
No* | authController.logout |
Clears the token cookie, redirects to /login |
* /logout is not wrapped with authMiddleware, though it is only linked from the authenticated task list view.
All routes below are protected by authMiddleware (auth).
| Method | Path | Handler | Behavior |
|---|---|---|---|
| GET | /todos |
todoController.getTodo |
Lists the current user's todos; supports ?filter=pending or ?filter=completed |
| POST | /todos |
todoController.createTodo |
Creates a todo owned by req.user.id |
| POST | /todos/delete/:id |
todoController.deleteTodo |
Deletes a todo, scoped to the current user |
| POST | /todos/toggle/:id |
todoController.toggleTodo |
Flips completed on a todo, scoped to the current user |
| GET | /todos/edit/:id |
todoController.editTodo |
Renders editTodo.ejs for a single todo, scoped to the current user |
| POST | /todos/edit/:id |
todoController.updateTodo |
Updates title, deadline, priority, notes on a todo, scoped to the current user |
| Feature | Implemented | Detail |
|---|---|---|
| Password hashing | Yes | bcrypt.hash(password, 10) before storage |
| Stateless auth token | Yes | JWT signed with JWT_SECRET, 1-day expiry |
httpOnly cookie |
Yes | Prevents client-side JS from reading the auth token |
| Per-user data scoping | Yes | Every Todo query includes user: req.user.id |
| Route-level access control | Yes | authMiddleware guards all /todos* routes |
| Secrets via environment variables | Yes | MONGO_URI and JWT_SECRET are never hard-coded; .env is git-ignored |
secure / sameSite cookie flags |
No | The auth cookie is set with only httpOnly and maxAge — no secure: true (HTTPS-only) or sameSite attribute |
| CSRF protection | No | No CSRF token middleware on state-changing POST forms |
| Rate limiting / brute-force protection | No | No throttling on /login or /signup |
| Server-side input validation | Partial | Enforced only via Mongoose schema constraints (required, enum, unique); no dedicated validation library |
- Registration (
public/js/register.js): client-side only, regex-checks email format, password strength (min 8 chars, upper/lowercase, digit, special character), and password/confirmation match. This runs in the browser before the form submits — it can be bypassed by submitting the form directly, sinceauthController.createUserperforms no equivalent server-side check. - Add Task (
public/js/todos.js): client-side only, blocks submission if the title field is empty. - Login (
views/login.ejs): no client-side script is wired up (public/js/login.jsis an empty file); all validation is the server-sideUser.findOne+bcrypt.comparecheck inauthController.loginValidate. - Schema-level validation: Mongoose enforces
requiredfields, thepriorityenum, andunique/lowercaseonemailat the database layer, independent of any client-side script.
Error handling is implemented locally, per controller function, using try/catch (for async handlers) or .catch() (for Promise-chain handlers):
- Database or unexpected errors are logged with
console.log(err)and answered withres.status(500).send(...). - Expected auth failures (wrong password, user not found) return
res.status(400).render("login", { error: ... }), surfacing a message in the login form rather than a generic error page. - A missing todo on edit/toggle returns
res.status(404).send("Todo not found"). authMiddlewaretreats any JWT failure (missing, expired, invalid) as "not authenticated" and redirects to/loginrather than returning a 401 status.
There is no centralized Express error-handling middleware (app.use((err, req, res, next) => ...)) — every handler manages its own error responses inline.
| Package | Version (declared) | Actually used? | Purpose |
|---|---|---|---|
express |
^5.2.1 | Yes | Web framework, routing |
mongoose |
^9.9.1 | Yes | MongoDB ODM, schemas |
ejs |
^6.0.1 | Yes | Server-side view templating |
jsonwebtoken |
^9.0.3 | Yes | JWT signing/verification |
bcrypt |
^6.0.0 | Yes | Password hashing (authController.js) |
cookie-parser |
^1.4.7 | Yes | Reads req.cookies |
dotenv |
^17.4.2 | Yes | Loads .env into process.env |
bcryptjs |
^3.0.3 | No | Listed in package.json but never required anywhere in the codebase |
cors |
^2.8.6 | No | Listed in package.json but never required or applied via app.use |
nodemon (dev) |
^3.1.14 | Available, not wired into a script | No dev script in package.json; must be invoked manually |
- MVC folder separation (
models/controller/routes/views) keeps data shape, business logic, URL wiring, and presentation independently testable and easy to navigate — a common convention for Express applications of this size. - Server-rendered EJS instead of a client-side framework matches the scope of the app: form-driven CRUD on a single resource type doesn't require a SPA, and rendering HTML directly from
req.user-scoped queries keeps authorization logic in one place (the controller) instead of duplicating it across an API layer and a frontend. - JWT in an
httpOnlycookie was chosen overlocalStoragetoken storage specifically because this is a cookie-driven, full-page-navigation app (see JWT Lifecycle above for the full reasoning). - Route-level middleware application (
authpassed per-route intodoRoutes.js) rather than globalapp.use(auth)keeps/loginand/signupreachable without an authenticated session, while still enforcing the check everywhere it's needed. - Ownership scoping baked into every query (
{ user: req.user.id }) rather than checked separately after fetching a record — this means an unauthorized document simply doesn't match the query at all, rather than being fetched and then rejected.
- Clean separation of concerns across five distinct layers (config, models, controllers, routes, views).
- Authentication and authorization implemented from first principles (no third-party auth service), covering hashing, token issuance, cookie storage, and verification middleware.
- Consistent per-user data isolation enforced at the query level across every CRUD operation.
- Environment-based configuration (
MONGO_URI,JWT_SECRET,PORT) with secrets excluded from version control via.gitignore.
These are gaps and inconsistencies visible directly in the current codebase, documented here for transparency:
- Edit form field mismatch:
views/editTodo.ejssubmits a field nameddescription, and readstodo.descriptionfor the textarea's current value — but theTodoschema (models/Todo.js) has nodescriptionfield, onlynotes.todoController.updateTodoreadsreq.body.notes(notdescription), so the "Notes" field on the edit page does not actually update a todo's stored notes. - Edit form is incomplete:
editTodo.ejsonly exposestitleand a (mismatched) notes field —deadlineandprioritycannot be changed from the edit view, even thoughupdateTodoaccepts them. - No
.env.example: environment variables required to run the app (MONGO_URI,JWT_SECRET,PORT) are not documented anywhere in the repository itself. - Unused dependencies:
bcryptjsandcorsare declared inpackage.jsonbut never imported or used. - Dead code:
const PORT = 3000inserver.jsis declared but never referenced — the actual listening port comes entirely fromprocess.env.PORT. - No automated tests: the
testscript inpackage.jsonis the default placeholder ("echo \"Error: no test specified\" && exit 1"); there is no test suite. - Client-side validation is bypassable: registration and add-task validation happen only in the browser; the server does not re-validate email format, password strength, or required fields beyond Mongoose's
requiredconstraint. - No CSRF protection on state-changing
POSTforms (create, update, delete, toggle, logout). - Cookie is not hardened for production: the auth cookie sets
httpOnlyandmaxAge, but notsecure(HTTPS-only) orsameSite. - No rate limiting on
/loginor/signup, leaving both open to brute-force attempts. - No cascade cleanup: deleting a
User(not currently exposed via any route) would not remove their associatedTododocuments. - No
LICENSEfile in the repository, despite"license": "ISC"being declared inpackage.json.
- Add a
.env.exampledocumenting all required environment variables. - Fix the
notes/descriptionfield mismatch and extendeditTodo.ejsto support editingdeadlineandpriority. - Add server-side validation (e.g. via
express-validatoror manual checks) mirroring the existing client-side rules, so validation isn't solely enforced in the browser. - Add
secureandsameSiteattributes to the auth cookie for production deployments behind HTTPS. - Introduce CSRF protection on all state-changing form submissions.
- Add rate limiting on authentication routes.
- Add an automated test suite (unit tests for controllers/middleware, integration tests for routes).
- Remove unused dependencies (
bcryptjs,cors) or wire them in if CORS support becomes necessary. - Add a
devscript inpackage.json("dev": "nodemon server.js") sincenodemonis already a dev dependency. - Add a
LICENSEfile matching the declaredISClicense.
This is currently a solo learning/portfolio project with a single contributor. If you'd like to suggest a change:
- Fork the repository.
- Create a feature branch (
git checkout -b feature/your-feature). - Commit your changes with a clear message.
- Open a pull request describing the change and why it's needed.
package.json declares this project under the ISC license. No LICENSE file is currently present in the repository — add one matching the declared license to make this legally explicit.
Sana Sadaf Khan GitHub: @snkhn007