Guides8 min read

Auth.js v5 and Prisma in Next.js: What the Adapter Does Not Do

The Prisma adapter stores users, not sessions. The schema, the strategy that credentials sign-in picks for you, and how to revoke a token that cannot be revoked.

Every guide starts with the same five lines, and they are correct:

// auth.ts
import NextAuth from "next-auth"
import { PrismaAdapter } from "@auth/prisma-adapter"
import { prisma } from "@/lib/prisma"

export const { handlers, auth, signIn, signOut } = NextAuth({
  adapter: PrismaAdapter(prisma),
  providers: [Google],
})

What almost none of them say is what you just configured. The adapter is not authentication, and it is not sessions. It is a set of database functions that Auth.js calls when it needs to write something down. Knowing exactly which things those are is the difference between an auth setup you can reason about and one where a user gets signed out and nobody can explain why.

This is the setup running on the kit this site is built with: Auth.js v5, Prisma, Postgres, four providers, and one piece of machinery we had to build because the strategy we chose does not come with it.

The adapter is a database driver, not a policy

An Auth.js adapter implements a small interface: create a user, find a user by email, link an account, create and consume a verification token, and manage session rows if you use them. It answers the question where does this go, never who is allowed in and never how long does this last.

That distinction matters because of one default. Adding an adapter flips the default session strategy to database. So the adapter appears to decide the strategy, when in fact it only changed a default that something else in your configuration may override. When it does, the tables stay in place and one of them simply never fills up.

The four tables, and the three columns we added

The adapter expects User, Account, Session and VerificationToken. Their shape is fixed by the adapter, not by you: rename a column and the adapter stops finding it.

What is yours is everything else on the user. Here is our User, with the standard fields collapsed and the additions kept:

model User {
  id            String    @id @default(cuid())
  email         String    @unique
  emailVerified DateTime?
  name          String?
  image         String?

  // Ours, not the adapter's:
  passwordHash   String?   // set only for email and password accounts
  role           Role      @default(USER)
  sessionVersion Int       @default(1)

  accounts Account[]
  sessions Session[]
}

passwordHash is nullable on purpose. A user who signs in with Google has no password, and may add one later from settings. Making it required is a schema decision that quietly rules out OAuth.

role belongs here rather than in a separate table until you actually need per resource permissions. Two roles in an enum cost one column and no joins.

sessionVersion is the interesting one, and the rest of this guide explains why it exists.

The strategy, and the rule that picks it for you

Auth.js has two ways to remember who is signed in.

Database sessionsJWT sessions
Where the session livesa row in Sessiona signed cookie in the browser
Cost per requestone querynone
Revoking one sessiondelete the rownot possible directly
Works with a credentials providernoyes
Survives a database outagenoyes

The last two rows are not preferences, they are constraints. A credentials provider requires the JWT strategy. If your product has email and password sign-in, or a demo login, or a development shortcut, the decision is made: you are on JWT, whatever the adapter's default was.

Our configuration says it in one line, and the comment is there because we forgot it once:

export const { handlers, auth, signIn, signOut } = NextAuth({
  adapter: PrismaAdapter(prisma),
  // JWT is required for the credentials providers to work. OAuth and
  // magic link still persist users and accounts through the adapter.
  session: { strategy: "jwt" },
  providers: [Google, GitHub, magicLink, credentials],
})

So the Session table in our database is empty, and that is not a bug. The adapter is still doing real work on every OAuth sign-in: it creates the user, links the provider account with its tokens, and stores the verification token for magic links. Only the session row is unused.

If you never ship a credentials provider, take database sessions instead. They cost a query per request and give you revocation for free, which is the whole subject of the next section.

A JWT cannot be revoked, and one day you will need to

The trade you accepted is this: nothing is stored server side, so there is nothing to delete. Change a user's role and their token still claims the old one until it expires. Reset a password because the account was compromised and every stolen session keeps working. Delete the user and their token keeps opening the door.

You cannot invalidate the token. You can invalidate what it claims, and the mechanism is a version number.

sessionVersion Int @default(1)

Copy it into the token at sign-in, compare on the server, and bump it on the user row whenever every session must die. Password reset does exactly that in our code, which is why a reset ends sessions on other devices, the behaviour people expect from a reset and rarely get.

The comparison is where the details are:

callbacks: {
  async jwt({ token, user }) {
    const now = Date.now()

    // First sight of this token: stamp role and version from the database
    // WITHOUT invalidating, so shipping this feature does not sign
    // everyone out on deploy day.
    if (token.sub && (!token.role || token.sv === undefined)) {
      const dbUser = await prisma.user.findUnique({
        where: { id: token.sub },
        select: { role: true, sessionVersion: true },
      })
      token.role = token.role ?? dbUser?.role ?? "USER"
      token.sv = dbUser?.sessionVersion ?? 1
      token.svAt = now
      return token
    }

    // Re-verify at most once a minute.
    if (token.sub && now - (token.svAt ?? 0) > 60_000) {
      try {
        const dbUser = await prisma.user.findUnique({
          where: { id: token.sub },
          select: { sessionVersion: true },
        })
        // Returning null makes Auth.js clear the session cookie.
        // This also kills the sessions of deleted users.
        if (!dbUser || dbUser.sessionVersion !== token.sv) return null
        token.svAt = now
      } catch (error) {
        console.error("sessionVersion check failed, keeping session:", error)
      }
    }

    return token
  },
}

Three decisions in there are worth stating out loud, because each one is a place where a reasonable person would do the opposite.

The throttle is not an optimisation. The jwt callback runs on nearly every request that touches the session, including from route protection. Without the sixty second window you have reinvented database sessions, with extra steps and none of the benefits. With it, a revocation takes effect within about a minute. If your threat model cannot accept that minute, you do not want JWT.

It fails open. If the database is unreachable the check logs and keeps the session. That is a deliberate trade: for this threat model, signing out every user in the world during a database blip is worse than a compromised session surviving a few extra minutes. Write the trade down in a comment, because the next person to read that catch will assume it is sloppiness.

First sight stamps, it does not invalidate. When you deploy this, every existing token lacks sv. The obvious implementation treats a missing version as a mismatch and signs out your entire user base on deploy day. Stamping instead means nobody notices the feature arriving, which is the correct behaviour for a security improvement.

The errors you will actually meet

These four account for most of the confusion, and none of them are really about Prisma.

PrismaAdapter is not a function, or an import that resolves to undefined. You installed @next-auth/prisma-adapter, the v4 package, alongside next-auth@5. The v5 package is @auth/prisma-adapter. Nothing warns you at install time.

PrismaClient is unable to run in this browser environment. Something in a client component imported a module that reaches the Prisma client. In Next.js 16 this is less common in route protection than it used to be, because proxy.ts runs on the Node.js runtime, but it still catches people who put a database call in a file that also exports a component.

While you are there, make sure the client is a singleton. In development, hot reload creates a new PrismaClient on every save until the connection pool is exhausted:

const globalForPrisma = globalThis as typeof globalThis & { prisma?: PrismaClient }
export const prisma = globalForPrisma.prisma ?? createPrismaClient()
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma

OAuthAccountNotLinked. A user signed up with a magic link, came back with Google, and Auth.js refused to connect the two. The refusal is correct by default: if a provider does not verify that the person owns the address, automatic linking hands the account to whoever claims the email. When your providers do verify it, opt in explicitly:

Google({
  clientId: process.env.GOOGLE_CLIENT_ID!,
  clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
  // Google and GitHub both verify email ownership, so linking on a shared
  // address is safe here. The alarming name guards against providers
  // that do not.
  allowDangerousEmailAccountLinking: true,
})

A table that does not exist. The adapter does not create anything. Add the four models, run the migration, and remember that VerificationToken is only written by email based flows. It looks removable right up to the day you add magic links.

The one thing the adapter cannot decide for you

Where the check happens. The adapter stores, the strategy transports, and neither one authorises anything.

We put route level redirects in proxy.ts, gate each private area in its layout with await auth(), and check again inside every server action that touches user data. That is three checks on the same request, and it looks like too many until you remember that a critical advisory in March 2025 let a crafted header skip middleware entirely on unpatched versions of Next.js. We wrote about that layering, and about what changed with the rename to proxy.ts, in Next.js authentication middleware is now proxy.

The short version

  • The adapter persists users, linked accounts and verification tokens. It does not decide the session strategy, it only changes a default.
  • A credentials provider means JWT, which means an empty Session table and no built in revocation.
  • Version the session on the user row if you need revocation. Throttle the check, decide whether it fails open or closed, and never invalidate on first sight.
  • Install @auth/prisma-adapter, keep one PrismaClient, and treat OAuthAccountNotLinked as a question about your providers rather than a bug.

All of the above ships in the free starter kit this site is about, so the code in this guide is the code that runs in production, not a sketch.