ProvenPin Install

NextAuth.js v4 credentials provider + JWT/session callbacks (mocked): working versions (tested 2026-10-15)

NextAuth v4 authOptions for Next.js with a Credentials provider, bcrypt-checked users and role claims flowing through jwt and session callbacks, unit-tested without a server.

passed4 passed · 0 failed2026-10-15 11:55 UTC
Status
passed
Last passed
2026-10-15 11:55 UTC
Tests
4 / 4
Language
TypeScript
Runtime
node v22.23.3
pinned versions7
next-auth@4.24.15next@16.4.1react@19.3.1react-dom@19.3.1bcryptjs@3.0.3tsx@4.23.15@types/node@24.19.2
run it
npm install --no-audit --no-fund --ignore-scripts=false
npm test
test environment
node v22.23.3
linux 6.17.0-1022-azure
x64
runner: github-actions 38730348195

The code

Exactly the files that passed. Your agent can pull them with get_recipe("nextauth-credentials-callbacks") (install), or see them on GitHub.

package.json
{
  "name": "nextauth-credentials-callbacks",
  "private": true,
  "type": "module",
  "scripts": {
    "test": "node --import tsx --test test/*.test.ts"
  },
  "dependencies": {
    "next-auth": "4.24.15",
    "next": "16.4.1",
    "react": "19.3.1",
    "react-dom": "19.3.1",
    "bcryptjs": "3.0.3"
  },
  "devDependencies": {
    "tsx": "4.23.15",
    "@types/node": "24.19.2"
  }
}
src/auth.ts
import type { NextAuthOptions, User } from "next-auth";
import CredentialsImport from "next-auth/providers/credentials";
import bcrypt from "bcryptjs";

// next-auth v4 ships CommonJS; under native ESM the default export arrives wrapped.
const CredentialsProvider = ((CredentialsImport as any).default ?? CredentialsImport) as typeof CredentialsImport;

export type DbUser = { id: string; email: string; passwordHash: string; role: "admin" | "member" };
export type UserRepo = { findByEmail: (email: string) => Promise<DbUser | null> };

export async function authorizeCredentials(repo: UserRepo, creds: Record<string, string> | undefined): Promise<User | null> {
  if (!creds?.email || !creds.password) return null;
  const u = await repo.findByEmail(creds.email.toLowerCase());
  if (!u || !(await bcrypt.compare(creds.password, u.passwordHash))) return null; // null => 401 CredentialsSignin
  return { id: u.id, email: u.email, role: u.role } as User;
}

export function buildAuthOptions(repo: UserRepo): NextAuthOptions {
  return {
    session: { strategy: "jwt" }, // Credentials provider requires JWT sessions
    providers: [CredentialsProvider({
      name: "Email",
      credentials: { email: { label: "Email", type: "email" }, password: { label: "Password", type: "password" } },
      authorize: (creds) => authorizeCredentials(repo, creds),
    })],
    callbacks: {
      async jwt({ token, user }) {
        if (user) { token.uid = user.id; token.role = (user as any).role; } // only present on sign-in
        return token;
      },
      async session({ session, token }) {
        if (session.user) Object.assign(session.user, { id: token.uid, role: token.role });
        return session;
      },
    },
  };
}
// app/api/auth/[...nextauth]/route.ts:  const h = NextAuth(buildAuthOptions(repo)); export { h as GET, h as POST };
test/auth.test.ts
import { test } from "node:test";
import assert from "node:assert/strict";
import bcrypt from "bcryptjs";
import { authorizeCredentials, buildAuthOptions, type UserRepo } from "../src/auth.ts";

const hash = bcrypt.hashSync("correct horse", 4);
const repo: UserRepo = { findByEmail: async (e) => (e === "ada@example.com" ? { id: "u1", email: e, passwordHash: hash, role: "admin" } : null) };

test("authorize accepts good credentials (case-insensitive email)", async () => {
  assert.deepEqual(await authorizeCredentials(repo, { email: "ADA@example.com", password: "correct horse" }), { id: "u1", email: "ada@example.com", role: "admin" });
});

test("authorize rejects bad password, unknown user and missing fields", async () => {
  assert.equal(await authorizeCredentials(repo, { email: "ada@example.com", password: "nope" }), null);
  assert.equal(await authorizeCredentials(repo, { email: "x@example.com", password: "correct horse" }), null);
  assert.equal(await authorizeCredentials(repo, undefined), null);
});

test("role flows user -> jwt -> session", async () => {
  const opts = buildAuthOptions(repo);
  assert.equal(opts.session?.strategy, "jwt");
  const token = await opts.callbacks!.jwt!({ token: { sub: "u1" }, user: { id: "u1", role: "admin" } as any } as any);
  assert.equal(token.role, "admin");
  const later = await opts.callbacks!.jwt!({ token } as any); // subsequent calls have no user
  assert.equal(later.role, "admin");
  const session = await opts.callbacks!.session!({ session: { user: { email: "ada@example.com" }, expires: "" }, token: later } as any);
  assert.deepEqual((session as any).user, { email: "ada@example.com", id: "u1", role: "admin" });
});

test("credentials provider is wired with our authorize", async () => {
  const p: any = buildAuthOptions(repo).providers[0];
  assert.equal(p.id, "credentials");
  const user = await p.options.authorize({ email: "ada@example.com", password: "correct horse" }, {});
  assert.equal(user.id, "u1");
});

Connect to ProvenPin

Add the free MCP server so your agent can pull this recipe and others like it. Same setup as the docs.

terminal
claude mcp add --transport http provenpin https://mcp.provenpin.com/mcp

Any other MCP client: Streamable HTTP at https://mcp.provenpin.com/mcp. No API key, no signup.