Auth.js custom mongoose adapter pattern

activetype/resourcetech/authtech/mongoose

Auth.js custom mongoose adapter pattern

TL;DR

Variant pattern of [[Resources/Tech/Auth.js/Auth.js Next.js JWT Google MongoDB adapter pattern]]. Use when your application has a canonical user id that is not Mongo _id — typically because every other collection in the app joins on a hex string userId field, not on _id. Implement the Adapter interface from @auth/core/adapters against existing mongoose models. ~120 lines.

When to use

  • The app already runs on mongoose, with a canonical userId field on every domain collection (bankAccounts.userId, transactions.userId, …).
  • You don't want OAuth-created users to have an unpopulated userId and be orphaned from the rest of the data model.
  • You're using JWT session strategy (so you skip the database-session methods entirely — only ~10 methods to implement).
  • You want Auth.js's notion of "the user id" to match your app's notion of it, so auth() returns session.user.id === user.userId without translation layers.

When NOT to use

  • You're using @auth/mongodb-adapter happily and _id is your canonical id. Stick with the off-the-shelf adapter (the [[Resources/Tech/Auth.js/Auth.js Next.js JWT Google MongoDB adapter pattern|sibling pattern]] covers it).
  • You want the database session strategy. Adding createSession/getSessionAndUser/updateSession/deleteSession doubles the surface area; the off-the-shelf adapter handles them.
  • You can refactor your app to use _id. Almost always cheaper than maintaining a custom adapter long-term, but rarely an option for established codebases.

Sketch

The adapter is a function returning an Adapter. Only OAuth + verification-token methods are needed for the JWT-strategy + Credentials + OAuth combo:

// src/lib/auth/mongoose-adapter.ts
import { Types } from "mongoose";
import type { Adapter, AdapterUser, AdapterAccount } from "@auth/core/adapters";
import { User } from "@/lib/models/userModel";
import { Account } from "@/lib/models/accountModel";
import { VerificationToken } from "@/lib/models/verificationTokenModel";

function toAdapterUser(u: LeanUser | null): AdapterUser | null {
  if (!u) return null;
  return {
    id: u.userId,        // ← key move: canonical app id, not _id
    userId: u.userId,
    email: u.email,
    emailVerified: u.emailVerified ?? null,
    name: u.name ?? null,
    image: u.image ?? null,
    // …module-augmented fields (role, newUser, …)
  } as AdapterUser;
}

export function MongooseAdapter(): Adapter {
  return {
    async createUser(data) {
      const doc = await User.create({
        userId: new Types.ObjectId().toHexString(), // same scheme as Credentials createUser
        email: data.email.toLowerCase(),
        name: data.name ?? data.email,
        emailVerified: data.emailVerified ?? null,
        image: data.image ?? null,
        newUser: true,
      });
      return toAdapterUser(leanFromDoc(doc))!;
    },
    async getUser(id)        { /* User.findOne({ userId: id }) */ },
    async getUserByEmail(e)  { /* User.findOne({ email: e.toLowerCase() }) */ },
    async getUserByAccount({ provider, providerAccountId }) {
      const acct = await Account.findOne({ provider, providerAccountId });
      if (!acct) return null;
      return toAdapterUser(/* User.findOne({ userId: acct.userId }) */);
    },
    async updateUser({ id, ...rest }) {
      const doc = await User.findOneAndUpdate({ userId: id }, { $set: rest }, { new: true });
      return toAdapterUser(/* … */)!;
    },
    async deleteUser(id) {
      await Account.deleteMany({ userId: id });
      await User.deleteOne({ userId: id });
    },
    async linkAccount(account) {
      // account.userId arrives already set to our app userId (because we returned
      // it as AdapterUser.id earlier).
      await Account.create(account);
      return account as AdapterAccount;
    },
    async unlinkAccount({ provider, providerAccountId }) {
      await Account.deleteOne({ provider, providerAccountId });
    },
    async createVerificationToken(token) { await VerificationToken.create(token); return token; },
    async useVerificationToken({ identifier, token }) {
      return await VerificationToken.findOneAndDelete({ identifier, token });
    },
  };
}

The accounts collection needs (provider, providerAccountId) unique. verificationTokens needs (identifier, token) unique + a TTL index on expires.

Trade-offs vs @auth/mongodb-adapter

FactorCustom mongoose@auth/mongodb-adapter
Lines of code to maintain~120~0
Bug surface (your code)Yes — you own itNo — community-maintained
Schema controlFull (mongoose Schemas, indexes, validators, virtuals)Limited (native driver writes BSON)
Canonical id alignmentApp userIdMongo _id
Mongoose pre-save hooks (e.g. password hashing)Fire normallyBypassed (adapter uses native driver)
Edge-runtime safetySame constraints either way — keep adapter import in auth.ts, not auth.config.tsSame

Origins

  • [[Projects/personal-finance-notion/context/auth-as-built-2026-05-19]] — production use; ADR'd the choice in Phase B 2026-05-18 (revised from earlier "use mongodb-adapter")

Pitfalls

  • Adapter import must stay in auth.ts, not auth.config.ts. The mongoose connectDB() call pulls in the mongoose driver, which doesn't run on the edge runtime. Middleware uses NextAuth(authConfig) exclusively.
  • updateUser does a blind $set spread. Auth.js writes whatever fields it wants. In practice that's emailVerified + OAuth profile fields; if you're paranoid, allow-list them explicitly.
  • linkAccount trusts account.userId to be your canonical id. It IS — Auth.js sets it from the AdapterUser.id you returned — but there's no defensive check. Validate if you want to.
  • Email casing: getUserByEmail must lowercase the input to match how User stores email (assuming the rest of the app already normalises). Easy to forget.
  • Database session methods: leave them off entirely (return type is Adapter, not Required<Adapter> — Auth.js handles the absence). If you ever switch to database sessions, you have to add four more methods.
  • emailVerified field: Auth.js sets this on OAuth sign-up from profile.email_verified. For Credentials users, you set it via your own email-verify flow. Both paths converge on the same field — keep them consistent.

See also

  • [[Resources/Tech/Auth.js/Auth.js Next.js JWT Google MongoDB adapter pattern]] — the off-the-shelf path
  • [[Resources/Tech/Auth.js/Auth.js session revocation via sessionsValidAfter]] — complementary pattern; this adapter handles user persistence, that note handles JWT revocation
  • [[Resources/Tech/Auth.js/Auth.js auto-link OAuth on verified email]] — depends on this adapter's getUserByEmail returning the matching user