Case Study · 03

EchoPOST

A secure, full-featured social post-sharing platform built security-first — featuring OAuth authentication, Zod schema validation, and role-based access control.

Type
Full Stack Social Platform
Role
Solo Developer
Status
Completed
Stack
Next.js 15MongoDB NextAuth v4Mongoose ZodbcryptTailwind
01 — Problem

Why Build a
Post-Sharing App?

Most social platforms are either completely open or completely private — there's no middle ground. EchoPost was built to explore building a platform where security and access control are designed in from day one, not retrofitted.

Beyond the product goal, this project was a vehicle for learning NextAuth deeply, understanding OAuth flow end-to-end, and building production-quality input validation with Zod across a real application.

🔓
Auth Complexity
Most tutorial projects treat auth as an afterthought. EchoPost treats it as the foundation — OAuth + credentials combined.
💉
Input Injection
Social platforms are prime targets for XSS and injection attacks. Every input needed validation before touching the DB.
👥
Role Enforcement
Public posts, private posts, admin moderation — different users need different access without a messy conditional mess.
🔄
Session Management
OAuth sessions need to interop with credential sessions, custom JWT callbacks, and database adapter syncing.
02 — System Architecture

Authentication
Flow

EchoPost supports two authentication paths — OAuth (Google / GitHub) and credentials (email + password) — unified under NextAuth v4 with a custom MongoDB adapter via Mongoose.

01
User Arrives at Login
User chooses OAuth provider or email/password. OAuth users are redirected to the provider; credentials users submit through a Zod-validated form.
02
NextAuth Handles the Provider
NextAuth's authorize() callback validates credentials against the MongoDB user record. bcrypt compares the hashed password. OAuth tokens are exchanged server-side.
03
Custom JWT Callback Enriches Token
The jwt() callback queries the DB for the user's role and appends it to the JWT payload. This makes role available in every session without additional DB calls.
04
Session Callback Exposes Role
The session() callback maps the JWT role onto the session object, making session.user.role available in all client and server components.
05
Middleware Route Protection
Next.js middleware intercepts all protected routes, calls getToken(), and redirects unauthenticated or unauthorized users before the page ever renders.
03 — Database Design

MongoDB
Schema

MongoDB with Mongoose handles the two core collections — User and Post. The schema is intentionally lean: no unnecessary fields, every constraint is deliberate, and the provider enum on User is what enables the OAuth + credentials merge strategy to work cleanly.

Collection Relationship
User
1 — many
author ref
Post
author: ObjectId
ref: "User"
User mongoose.Schema
_idObjectId (auto)
nameString · required
handle String? unique · sparse
emailStringunique · required
passwordString? (nullable)
provider credentials | github | google
timestamps: true(createdAt, updatedAt)
Post mongoose.Schema
_idObjectId (auto)
titleString · required
descriptionString (optional)
author ObjectId ref: "User"
timestamps: true(createdAt, updatedAt)
Key Design Decisions
handle is unique + sparsesparse: true means MongoDB skips the unique index for null values, so OAuth users who haven't set a handle don't conflict with each other.
password is nullable — OAuth users have no password. Making it optional with required: false cleanly supports both auth paths in one schema without a separate collection.
provider enum["credentials", "github", "google"] stored on the User model is the key that enables the merge logic: if the same email already exists with a different provider, the system can detect and handle the conflict explicitly instead of silently creating duplicates.
author as ObjectId ref — Mongoose's populate() on ref: "User" allows fetching full author details in a single query when loading the post feed.
mongoose.models.X || model("X", schema) — The guard pattern prevents "Cannot overwrite model once compiled" errors in Next.js hot reload, where modules re-execute on every save.
04 — Features

What EchoPost
Does

🔑
OAuth + Credentials Auth
Login with Google, GitHub, or email/password — all unified under NextAuth. OAuth users auto-create accounts; credentials users go through bcrypt verification with Zod-validated inputs.
Zod Schema Validation
Every form submission — register, login, create post, comment — is validated against a Zod schema before any DB operation. Structured, client-friendly errors are returned on failure.
🛡️
Role-Based Access Control
ADMIN users can delete any post or ban users. Regular users can only modify their own content. Roles are checked at both middleware and API handler levels — two layers of defence.
🔒
Post Visibility Control
Authors mark posts as PUBLIC or PRIVATE. Private posts are only returned by the API for the authenticated author — not for other users, not for admins, not in search results.
🔔
Toast Notifications
Toastify-js provides lightweight, non-blocking notifications for auth events, post creation, errors, and admin actions — keeping the UI clean without blocking modals.
05 — Challenges

Hard Problems
I Solved

Custom JWT Callbacks With NextAuth
NextAuth's default JWT contains only basic session info. To include the user's role, I had to extend both the jwt() and session() callbacks — fetching from MongoDB on first sign-in and persisting the role in the token on every subsequent refresh. Getting the trigger conditions (trigger === "signIn") right took careful reading of the NextAuth source code.
Merging OAuth and Credentials Users
A user who first signs up with Google and later tries credentials with the same email shouldn't get two accounts. I built a pre-save hook in the Mongoose User schema that checks if an email already exists with a different provider and either merges the provider or returns an error prompting the user to use their original login method.
Zod Validation Across Client and Server
Zod schemas are defined once in /lib/validators and imported by both client-side forms (for instant feedback) and API route handlers (for enforcement). The challenge was transforming Zod's error output into a shape that React form state could consume cleanly — I built a small helper that flattens Zod's nested errors into a { field: message } map.
Private Post Leakage Prevention
MongoDB queries for the feed always include a visibility filter that appends { $or: [{ visibility: 'PUBLIC' }, { authorId: session.user.id }] }. This is applied at the Mongoose query level, not in application logic — meaning there's no way for a forgotten if statement to accidentally leak a private post.
06 — What I Learned

Key
Takeaways

🔑
NextAuth Deeply
Callbacks, adapters, custom providers, JWT enrichment — this project forced me to understand NextAuth beyond basic setup.
Validation as Architecture
Zod schemas in a shared /lib folder, used by both client and server, make validation a first-class architectural concern.
🛡️
Defence in Depth
Checking roles at middleware AND API handler levels means a bug in one layer doesn't expose everything. Two checks, zero single points of failure.
🧩
OAuth Provider Edge Cases
Merging OAuth and credentials accounts for the same email is a real-world problem most tutorials skip. Now I know exactly how to handle it.