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
npm install --no-audit --no-fund --ignore-scripts=false npm test
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.
{
"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"
}
}
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 };
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.
claude mcp add --transport http provenpin https://mcp.provenpin.com/mcp
{ "mcpServers": { "provenpin": { "url": "https://mcp.provenpin.com/mcp" } } }{ "servers": { "provenpin": { "type": "http", "url": "https://mcp.provenpin.com/mcp" } } }{ "mcpServers": { "provenpin": { "serverUrl": "https://mcp.provenpin.com/mcp" } } }{ "mcpServers": { "provenpin": { "type": "streamableHttp", "url": "https://mcp.provenpin.com/mcp" } } }Any other MCP client: Streamable HTTP at https://mcp.provenpin.com/mcp. No API key, no signup.
Related stacks
bcryptjs password hashing + jose JWT sessions
Hash passwords with bcryptjs and issue and verify signed JWT session tokens with jose. Framework-agnostic auth core.
Next.js 16 App Router route handler + Zod validation
Validate JSON bodies and search params in a Next.js App Router route handler with Zod, returning typed 400s; tested by calling the handler with NextRequest.
Next.js 16 server actions + Zod with useActionState-shaped results
A 'use server' form action that validates FormData with Zod and returns field errors in the shape useActionState expects, tested by calling the action directly.