A secure, full-featured social post-sharing platform built security-first — featuring OAuth authentication, Zod schema validation, and role-based access control.
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.
EchoPost supports two authentication paths — OAuth (Google / GitHub) and credentials (email + password) — unified under NextAuth v4 with a custom MongoDB adapter via Mongoose.
authorize() callback validates
credentials against the MongoDB user record. bcrypt compares the
hashed password. OAuth tokens are exchanged server-side.
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.
session() callback maps the JWT role onto the
session object, making
session.user.role available in all
client and server components.
getToken(), and redirects unauthenticated or
unauthorized users before the page ever renders.
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.
unique + sparse
— sparse: 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.
required: false cleanly supports both auth paths in
one schema without a separate collection.
["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.
populate() on
ref: "User" allows fetching full author details in a
single query when loading the post feed.
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.
/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.
{ $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.