Building a Real Auth System for a Learning App
Development node.js express authentication passport.js mongodb oauth devlogI'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:
- Auth: A single hardcoded
admin/adminlogin, checked via session.isAdmin - Sessions: In-memory store -- every server restart logged everyone out
- Progress: All learning stats lived in
localStorage-- switch browsers and your data's gone - Route security: Blog CRUD routes were completely unprotected. Anyone could POST/PUT/DELETE articles
- No user model at all -- 8 Mongoose models for Italian content, zero for users
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:
- Email/password auth (bcrypt, 12 rounds)
- Google OAuth fields (
googleId, sparse unique index) - Role-based access (
learner/admin) - Provider tracking (
local/google)
The pre-save hook handles hashing, and a comparePassword() instance method keeps the auth logic clean.
Passport.js Integration
Two strategies wired up:
- LocalStrategy: Email + password lookup with constant-time comparison
- GoogleStrategy: Find-or-create by Google ID, with smart email merging -- if someone registers locally first and later signs in with Google using the same email, the accounts get linked rather than duplicated
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:
ensureAuthenticated()-- checksreq.isAuthenticated(), returns 401 JSON for API routes or redirects to/loginfor pagesensureAdmin()-- checksreq.user.role === 'admin', 403 for API or redirect for pages
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:
- Login: Email/password form + Google OAuth button with SVG icon
- Register: Display name, email, password, confirm password + Google OAuth
- Profile: Edit display name, change password (local accounts), Google linked indicator
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.