Building a Real Auth System for a Learning App

4/19/2026 by Robb | 5 min read
Development node.js express authentication passport.js mongodb oauth devlog
All Articles Edit

I'm in the middle of a pretty big upgrade to my Italian learning app -- going from a toy project with hardcoded admin credentials and localStorage-only progress to a real, multi-user platform with authentication, server-side tracking, adaptive learning, and AI-driven insights.

This post covers Phase 1: Authentication & User System -- the foundation everything else is built on.

Where We Started

The app was functional but fragile:

The stack is Express + Mongoose + EJS + Bootstrap 5, deployed on a DigitalOcean droplet with Nginx + PM2.

What We Built

User Model

A proper User schema with:

The pre-save hook handles hashing, and a comparePassword() instance method keeps the auth logic clean.

Passport.js Integration

Two strategies wired up:

The Google strategy only registers if GOOGLE_CLIENT_ID is set, so the app degrades gracefully without OAuth config.

Session Store to MongoDB

Swapped the default in-memory session store for connect-mongo. Sessions survive server restarts and PM2 deploys. 14-day TTL keeps the collection from growing forever.

Auth Middleware

Two reusable middleware functions:

Both are now applied across the article router and Italian admin routes, replacing the old local requireAdmin() that only checked session.isAdmin.

Rate Limiting

express-rate-limit on /login, /register, and /auth/* -- 10 attempts per 15 minutes. Simple but effective against brute force on a small deployment.

Auth Views

Three new pages:

All views got a navbar update -- 8 EJS templates updated to show user state (display name + profile link when logged in, login link when not).

The Gotchas

Load order matters. config/passport.js checks process.env.GOOGLE_CLIENT_ID at require time. If dotenv.config() runs after the require, the env var doesn't exist yet, and the Google strategy never registers. Moving require('dotenv').config() to line 1 of server.js fixed it.

connect-mongo v5+ export changed. It's require('connect-mongo').MongoStore, not require('connect-mongo'). The default export is now an object, not the class directly.

Duplicate route definitions. The old server.js had a GET /blog/new route with admin middleware, but the articles router (mounted at /blog) also defined GET /new. After adding ensureAdmin to the router, the server.js duplicate was creating a redirect loop.

The Roadmap

This is Phase 1 of 4:

Phase Focus Status
1. Auth & Users Email/password + Google OAuth, role-based access, session persistence Done
2. Server-Side Progress UserProgress model, streak logic, XP/levels, achievements Next
3. Engagement Dashboard redesign, daily goals, leaderboard, gamification Planned
4. Adaptive Learning SM-2 spaced repetition, weakness detection, OpenAI weekly insights Planned

Phase 2: Progress Tracking

Replace localStorage with a UserProgress model -- per-user stats across sections (vocab, verbs, grammar, sentences, reading, idioms), real streak logic that actually compares dates, XP with a level curve, and an achievements system with toast notifications.

The key API endpoints: save quiz results, update progress, check/update streak, return XP delta and any newly unlocked achievements.

Phase 3: Engagement & Gamification

Dashboard redesign with streak flame widget, XP progress bar, daily goal meter, and an achievement shelf. Small-group leaderboard sorted by weekly XP.

Phase 4: Adaptive Learning (The Big One)

SM-2 spaced repetition at the individual item level -- every vocab word, verb conjugation, and grammar question gets its own ReviewItem with ease factor, interval, and next review date.

A weakness detection engine that analyzes ease factors and error rates to identify specific pain points: "You frequently miss -ire verb conjugations in presente indicativo".

And OpenAI-powered weekly insight reports -- feed the user's stats, weak items, and engagement data to GPT for a personalized natural language summary with actionable recommendations.

The SRS recording integrates invisibly into existing quizzes -- no UI change needed. Users also get a dedicated "Smart Review" mode that fetches due items and builds a mixed quiz.

Production Drama

In the middle of all this, the live site went down with a 504 gateway error. Turns out a zombie node process from 5 days earlier was holding port 5000, which caused PM2's restart loop (191,000+ restarts). That crash loop ate all 771MB of RAM on the droplet -- plus setroubleshootd (SELinux) was consuming 135MB and 42% CPU on top of it.

The fix: reboot, kill the SELinux troubleshooter (systemctl mask), add 1GB swap (fallocate), and restart PM2 fresh. Load went from 5.9 to 0.05.

Lesson learned: always have swap on a small VPS, and keep an eye on pm2 status restart counts.

What's Next

Merging Phase 1 to main and starting on Phase 2 -- the progress tracking and streak system. That's where the app starts to feel like a real learning platform instead of just a quiz tool.


Built with Express.js, Mongoose, Passport.js, and a lot of SSH debugging. The Italian learning app is at intentionalowl.io/italian.