Build a Complete Auth System with Auth.js v5: Registration, Login, and Password Reset in Next.js

Step-by-step guide to building a full email/password authentication system with Auth.js v5 and Next.js App Router. Covers registration, login, password reset, route protection, rate limiting, and production deployment.

Building a production-grade authentication system in Next.js has always required more than just dropping in a provider. With the Auth.js v5 credentials provider, you can build a fully custom email and password authentication flow — but honestly, the official docs only scratch the surface. If you've searched for a next-auth v5 custom login tutorial that covers registration, password reset, and database integration in a single guide, you've probably been disappointed. Most tutorials stop at the basic sign-in form and leave you to figure out the rest on your own.

This guide is different.

We're building a complete Auth.js email/password setup that covers every piece a real application needs: user registration with duplicate email detection, login with custom error messages, password reset via email tokens, route protection with middleware, TypeScript type augmentation for custom user fields, rate limiting to prevent brute force attacks, and a production deployment checklist. We'll use Prisma as our database ORM, bcryptjs for password hashing, Zod for input validation, and server actions for all form handling. By the end, you'll have a hardened, type-safe authentication system ready for production.

What You'll Build

Before diving into code, here's an overview of every feature we'll implement:

  • User registration — Server action with Zod validation, bcryptjs hashing, Prisma user creation, and auto sign-in
  • Custom login form — Email and password login using the Credentials provider with proper redirect handling
  • Password reset flow — Token generation, email delivery with Resend, secure password update, and token expiration
  • Custom error handling — Solving the notorious CallbackRouteError problem with user-friendly error messages
  • TypeScript type augmentation — Extending the User, Session, and JWT types with custom fields like role and emailVerified
  • Route protection — Edge-compatible middleware with role-based access control
  • Rate limiting — In-memory and production-grade rate limiting to prevent brute force login attempts
  • Production hardening — Environment variables, secure cookies, CSRF protection, and database connection pooling

The architecture follows a clean separation: an edge-compatible auth.config.ts for middleware, a full auth.ts file with the Prisma adapter and Credentials provider, server actions for all mutations, and React client components for forms. This pattern ensures compatibility with Next.js edge middleware while keeping full database access in server-side code.

How to Set Up Prisma for Auth.js v5 User Management

Auth.js v5 works with any database through its adapter system. Prisma's the most popular choice in the Next.js ecosystem because of its type safety and migration tooling. We need to install both Prisma and the official Auth.js Prisma adapter.

Start by installing the required packages:

npm install prisma @prisma/client @auth/prisma-adapter
npx prisma init

This creates a prisma/schema.prisma file. Replace its contents with the following schema that includes all the models Auth.js needs plus a custom model for password reset tokens:

// prisma/schema.prisma
generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

enum UserRole {
  USER
  ADMIN
}

model User {
  id            String    @id @default(cuid())
  name          String?
  email         String?   @unique
  emailVerified DateTime?
  image         String?
  password      String?
  role          UserRole  @default(USER)
  failedLogins  Int       @default(0)
  lockedUntil   DateTime?
  accounts      Account[]
  sessions      Session[]
  createdAt     DateTime  @default(now())
  updatedAt     DateTime  @updatedAt
}

model Account {
  id                String  @id @default(cuid())
  userId            String
  type              String
  provider          String
  providerAccountId String
  refresh_token     String? @db.Text
  access_token      String? @db.Text
  expires_at        Int?
  token_type        String?
  scope             String?
  id_token          String? @db.Text
  session_state     String?
  user              User    @relation(fields: [userId], references: [id], onDelete: Cascade)

  @@unique([provider, providerAccountId])
}

model Session {
  id           String   @id @default(cuid())
  sessionToken String   @unique
  userId       String
  expires      DateTime
  user         User     @relation(fields: [userId], references: [id], onDelete: Cascade)
}

model VerificationToken {
  identifier String
  token      String   @unique
  expires    DateTime

  @@unique([identifier, token])
}

model PasswordResetToken {
  id      String   @id @default(cuid())
  email   String
  token   String   @unique
  expires DateTime
  createdAt DateTime @default(now())

  @@unique([email, token])
}

The User model extends the standard Auth.js schema with a password field for credentials-based auth, a role enum for authorization, and failedLogins plus lockedUntil fields for account lockout protection. The PasswordResetToken model stores time-limited tokens for the password reset flow.

Push the schema to your database:

npx prisma db push

Next, create a reusable Prisma client instance. This is one of those things that trips people up in development — without the global singleton pattern, you'll end up with dozens of database connections during hot reloads:

// lib/db.ts
import { PrismaClient } from "@prisma/client"

const globalForPrisma = globalThis as unknown as {
  prisma: PrismaClient | undefined
}

export const db = globalForPrisma.prisma ?? new PrismaClient()

if (process.env.NODE_ENV !== "production") {
  globalForPrisma.prisma = db
}

Optionally, create a seed script to populate test users for development. Add a prisma/seed.ts file and run it with npx prisma db seed to quickly set up test accounts with known credentials.

How to Configure Auth.js v5 with the Credentials Provider

Here's where things get interesting. Auth.js v5 configuration is split into two files — and there's a good reason for it. The first, auth.config.ts, contains edge-safe configuration that can run in Next.js middleware. The second, auth.ts, includes everything that requires Node.js APIs like database access and password hashing.

Start with the edge-compatible config:

// auth.config.ts
import type { NextAuthConfig } from "next-auth"

export default {
  providers: [],
  pages: {
    signIn: "/login",
    error: "/auth/error",
  },
} satisfies NextAuthConfig

Now create the full auth configuration with the Credentials provider:

// auth.ts
import NextAuth from "next-auth"
import Credentials from "next-auth/providers/credentials"
import { PrismaAdapter } from "@auth/prisma-adapter"
import bcryptjs from "bcryptjs"
import { z } from "zod"
import { db } from "@/lib/db"
import authConfig from "./auth.config"

const LoginSchema = z.object({
  email: z.string().email("Invalid email address"),
  password: z.string().min(1, "Password is required"),
})

export const {
  handlers: { GET, POST },
  auth,
  signIn,
  signOut,
} = NextAuth({
  ...authConfig,
  adapter: PrismaAdapter(db),
  session: { strategy: "jwt" },
  providers: [
    Credentials({
      async authorize(credentials) {
        const validated = LoginSchema.safeParse(credentials)

        if (!validated.success) {
          return null
        }

        const { email, password } = validated.data

        const user = await db.user.findUnique({
          where: { email },
        })

        if (!user || !user.password) {
          return null
        }

        // Check account lockout
        if (user.lockedUntil && user.lockedUntil > new Date()) {
          throw new Error("AccountLocked")
        }

        const passwordMatch = await bcryptjs.compare(password, user.password)

        if (!passwordMatch) {
          // Increment failed login counter
          await db.user.update({
            where: { id: user.id },
            data: {
              failedLogins: { increment: 1 },
              lockedUntil:
                user.failedLogins + 1 >= 5
                  ? new Date(Date.now() + 15 * 60 * 1000)
                  : null,
            },
          })
          return null
        }

        // Reset failed login counter on success
        await db.user.update({
          where: { id: user.id },
          data: { failedLogins: 0, lockedUntil: null },
        })

        return {
          id: user.id,
          email: user.email,
          name: user.name,
          role: user.role,
          emailVerified: user.emailVerified,
        }
      },
    }),
  ],
  callbacks: {
    async jwt({ token, user }) {
      if (user) {
        token.role = user.role
        token.emailVerified = user.emailVerified
      }
      return token
    },
    async session({ session, token }) {
      if (token.sub && session.user) {
        session.user.id = token.sub
        session.user.role = token.role as string
        session.user.emailVerified = token.emailVerified as Date | null
      }
      return session
    },
  },
})

A critical detail here (and I can't stress this enough): you must set session: { strategy: "jwt" } when using the Credentials provider with a database adapter. By default, Auth.js uses database sessions when an adapter is present, but the Credentials provider doesn't support database sessions. Omitting this setting is the single most common configuration error developers run into — and the error message you get isn't exactly helpful.

Finally, create the API route handler:

// app/api/auth/[...nextauth]/route.ts
export { GET, POST } from "@/auth"

Install the required dependencies if you haven't already:

npm install next-auth@beta bcryptjs zod
npm install -D @types/bcryptjs

We're using bcryptjs rather than bcrypt here because bcryptjs is a pure JavaScript implementation. It avoids native compilation issues across platforms and works reliably in all deployment environments — no more fighting with node-gyp.

How to Build User Registration with Server Actions

Auth.js doesn't provide built-in registration. You need to build it yourself using a server action that validates input, checks for existing users, hashes the password, and creates the user record in your database.

So, let's build the registration server action:

// actions/register.ts
"use server"

import bcryptjs from "bcryptjs"
import { z } from "zod"
import { db } from "@/lib/db"
import { signIn } from "@/auth"
import { AuthError } from "next-auth"

const RegisterSchema = z.object({
  name: z.string().min(2, "Name must be at least 2 characters"),
  email: z.string().email("Invalid email address"),
  password: z
    .string()
    .min(8, "Password must be at least 8 characters")
    .regex(/[A-Z]/, "Password must contain an uppercase letter")
    .regex(/[0-9]/, "Password must contain a number"),
})

export async function register(values: z.infer<typeof RegisterSchema>) {
  const validated = RegisterSchema.safeParse(values)

  if (!validated.success) {
    return { error: validated.error.errors[0].message }
  }

  const { name, email, password } = validated.data

  const existingUser = await db.user.findUnique({
    where: { email },
  })

  if (existingUser) {
    return { error: "An account with this email already exists" }
  }

  const hashedPassword = await bcryptjs.hash(password, 12)

  await db.user.create({
    data: {
      name,
      email,
      password: hashedPassword,
    },
  })

  // Auto sign-in after registration
  try {
    await signIn("credentials", {
      email,
      password,
      redirectTo: "/dashboard",
    })
  } catch (error) {
    if (error instanceof AuthError) {
      return { error: "Something went wrong during sign-in" }
    }
    throw error // Re-throw redirect errors (expected behavior)
  }

  return { success: "Account created successfully" }
}

Now build the registration form component:

// components/register-form.tsx
"use client"

import { useState, useTransition } from "react"
import { register } from "@/actions/register"

export function RegisterForm() {
  const [error, setError] = useState<string | undefined>()
  const [isPending, startTransition] = useTransition()

  function onSubmit(event: React.FormEvent<HTMLFormElement>) {
    event.preventDefault()
    setError(undefined)

    const formData = new FormData(event.currentTarget)
    const values = {
      name: formData.get("name") as string,
      email: formData.get("email") as string,
      password: formData.get("password") as string,
    }

    startTransition(async () => {
      const result = await register(values)
      if (result?.error) {
        setError(result.error)
      }
    })
  }

  return (
    <form onSubmit={onSubmit}>
      <div>
        <label htmlFor="name">Name</label>
        <input
          id="name"
          name="name"
          type="text"
          required
          disabled={isPending}
        />
      </div>
      <div>
        <label htmlFor="email">Email</label>
        <input
          id="email"
          name="email"
          type="email"
          required
          disabled={isPending}
        />
      </div>
      <div>
        <label htmlFor="password">Password</label>
        <input
          id="password"
          name="password"
          type="password"
          required
          disabled={isPending}
        />
      </div>
      {error && <p className="text-red-500">{error}</p>}
      <button type="submit" disabled={isPending}>
        {isPending ? "Creating account..." : "Create account"}
      </button>
    </form>
  )
}

A few key design decisions worth noting here. We return plain objects with error or success fields from the server action rather than throwing errors — this makes error handling predictable on the client side. The useTransition hook gives us a pending state without blocking the UI. And here's one that catches a lot of people off guard: we must re-throw non-AuthError exceptions because Next.js uses thrown errors internally for redirects.

How to Build a Custom Login Form with Auth.js v5

The login flow follows a similar pattern to registration but calls signIn directly. Let's create a login server action that wraps the Auth.js signIn function with proper error handling:

// actions/login.ts
"use server"

import { signIn } from "@/auth"
import { AuthError } from "next-auth"
import { z } from "zod"

const LoginSchema = z.object({
  email: z.string().email("Invalid email address"),
  password: z.string().min(1, "Password is required"),
})

export async function login(
  values: z.infer<typeof LoginSchema>,
  callbackUrl?: string | null
) {
  const validated = LoginSchema.safeParse(values)

  if (!validated.success) {
    return { error: validated.error.errors[0].message }
  }

  const { email, password } = validated.data

  try {
    await signIn("credentials", {
      email,
      password,
      redirectTo: callbackUrl || "/dashboard",
    })
  } catch (error) {
    if (error instanceof AuthError) {
      switch (error.type) {
        case "CredentialsSignin":
          return { error: "Invalid email or password" }
        case "CallbackRouteError":
          return { error: error.cause?.err?.message || "Something went wrong" }
        default:
          return { error: "Something went wrong" }
      }
    }
    throw error
  }

  return { success: "Logged in successfully" }
}

And the login form component:

// components/login-form.tsx
"use client"

import { useState, useTransition } from "react"
import { useSearchParams } from "next/navigation"
import { login } from "@/actions/login"

export function LoginForm() {
  const searchParams = useSearchParams()
  const callbackUrl = searchParams.get("callbackUrl")
  const [error, setError] = useState<string | undefined>()
  const [isPending, startTransition] = useTransition()

  function onSubmit(event: React.FormEvent<HTMLFormElement>) {
    event.preventDefault()
    setError(undefined)

    const formData = new FormData(event.currentTarget)
    const values = {
      email: formData.get("email") as string,
      password: formData.get("password") as string,
    }

    startTransition(async () => {
      const result = await login(values, callbackUrl)
      if (result?.error) {
        setError(result.error)
      }
    })
  }

  return (
    <form onSubmit={onSubmit}>
      <div>
        <label htmlFor="email">Email</label>
        <input id="email" name="email" type="email" required disabled={isPending} />
      </div>
      <div>
        <label htmlFor="password">Password</label>
        <input id="password" name="password" type="password" required disabled={isPending} />
      </div>
      {error && <p className="text-red-500">{error}</p>}
      <button type="submit" disabled={isPending}>
        {isPending ? "Signing in..." : "Sign in"}
      </button>
      <a href="/forgot-password">Forgot your password?</a>
    </form>
  )
}

The callbackUrl parameter preserves the user's intended destination. When middleware redirects an unauthenticated user to the login page, it appends a callbackUrl query parameter. After successful authentication, the user gets redirected back to where they were actually trying to go instead of some generic dashboard page. It's a small thing, but it makes the experience feel much more polished.

How to Handle Custom Error Messages in Auth.js v5

Alright, let's talk about error handling — honestly, this is the biggest pain point in Auth.js v5 when using the Credentials provider. The core problem? When the authorize function returns null or throws an error, Auth.js wraps it in a CallbackRouteError. Your original error message gets buried inside nested properties, and the default behavior is to redirect to an error page rather than returning anything useful to your code.

To solve this, you need to catch errors at the server action level, not inside the authorize function. Here's the pattern that works reliably:

// lib/auth-errors.ts
export class AccountLockedError extends Error {
  constructor() {
    super("AccountLocked")
    this.name = "AccountLockedError"
  }
}

export class EmailNotVerifiedError extends Error {
  constructor() {
    super("EmailNotVerified")
    this.name = "EmailNotVerifiedError"
  }
}

export function mapAuthError(error: unknown): string {
  if (error instanceof Error) {
    const message = error.message || ""
    const causeMessage =
      (error as any).cause?.err?.message || ""

    if (message.includes("AccountLocked") || causeMessage.includes("AccountLocked")) {
      return "Your account has been temporarily locked due to too many failed login attempts. Please try again in 15 minutes."
    }

    if (message.includes("EmailNotVerified") || causeMessage.includes("EmailNotVerified")) {
      return "Please verify your email address before signing in. Check your inbox for a verification link."
    }

    if (message.includes("CredentialsSignin")) {
      return "Invalid email or password. Please try again."
    }
  }

  return "An unexpected error occurred. Please try again."
}

In your authorize function, throw these custom errors when specific conditions are met:

// Inside the authorize function in auth.ts
if (user.lockedUntil && user.lockedUntil > new Date()) {
  throw new Error("AccountLocked")
}

if (!user.emailVerified) {
  throw new Error("EmailNotVerified")
}

Then in your server action, use the mapAuthError helper:

// In your login server action
import { mapAuthError } from "@/lib/auth-errors"

try {
  await signIn("credentials", { email, password, redirectTo: "/dashboard" })
} catch (error) {
  if (error instanceof AuthError) {
    return { error: mapAuthError(error) }
  }
  throw error
}

The critical insight here is that Auth.js wraps authorize errors inside CallbackRouteError, and the original message is accessible through error.cause.err.message. By using string-based error codes in the authorize function and a centralized mapping function in your server actions, you can deliver clear, user-friendly error messages for every failure scenario. It feels a bit roundabout, but it's way better than fighting the Auth.js error handling system.

How to Add TypeScript Type Augmentation for Custom User Fields

By default, the Auth.js User type only includes id, name, email, and image. If you've added custom fields like role or emailVerified to your Prisma schema, TypeScript will complain when you try to access them on the session or token objects. You need to augment the types using module declaration.

Create a type definition file:

// types/next-auth.d.ts
import { UserRole } from "@prisma/client"
import type { DefaultSession } from "next-auth"

declare module "next-auth" {
  interface User {
    role: UserRole
    emailVerified: Date | null
  }

  interface Session {
    user: {
      id: string
      role: UserRole
      emailVerified: Date | null
    } & DefaultSession["user"]
  }
}

declare module "next-auth/jwt" {
  interface JWT {
    role: UserRole
    emailVerified: Date | null
  }
}

This file extends three interfaces. The User interface affects what the authorize function can return. The JWT interface defines what can be stored in the token. And the Session interface defines what's available on the client through useSession or the auth() function.

The jwt and session callbacks in your auth.ts file (shown earlier) are responsible for propagating these custom fields from the user object into the token and then from the token into the session. Without these callbacks, the custom fields would exist in the types but always be undefined at runtime — which is the kind of bug that'll have you staring at your screen for hours.

Make sure your tsconfig.json includes the types directory in its compilation scope, either through the include array or by placing the file in a location already covered by your TypeScript configuration.

How to Implement Password Reset with Auth.js v5 and Prisma

Auth.js doesn't provide a built-in password reset mechanism for the Credentials provider. You need to build three components yourself: a token generation system, a request endpoint that sends reset emails, and a reset page that accepts the new password.

First, create a utility for generating and managing reset tokens:

// lib/tokens.ts
import crypto from "crypto"
import { db } from "@/lib/db"

export async function generatePasswordResetToken(email: string) {
  const token = crypto.randomUUID()
  const expires = new Date(Date.now() + 3600 * 1000) // 1 hour

  // Delete any existing tokens for this email
  await db.passwordResetToken.deleteMany({
    where: { email },
  })

  const passwordResetToken = await db.passwordResetToken.create({
    data: {
      email,
      token,
      expires,
    },
  })

  return passwordResetToken
}

Now create the server action that handles the reset request:

// actions/forgot-password.ts
"use server"

import { z } from "zod"
import { db } from "@/lib/db"
import { generatePasswordResetToken } from "@/lib/tokens"
import { Resend } from "resend"

const resend = new Resend(process.env.RESEND_API_KEY)

const ForgotPasswordSchema = z.object({
  email: z.string().email("Invalid email address"),
})

export async function forgotPassword(values: z.infer<typeof ForgotPasswordSchema>) {
  const validated = ForgotPasswordSchema.safeParse(values)

  if (!validated.success) {
    return { error: "Invalid email address" }
  }

  const { email } = validated.data

  const existingUser = await db.user.findUnique({
    where: { email },
  })

  if (!existingUser) {
    // Return success even if user doesn't exist to prevent email enumeration
    return { success: "If an account exists, a reset link has been sent" }
  }

  const resetToken = await generatePasswordResetToken(email)
  const resetLink = `${process.env.NEXT_PUBLIC_APP_URL}/reset-password?token=${resetToken.token}`

  await resend.emails.send({
    from: "[email protected]",
    to: email,
    subject: "Reset your password",
    html: `<p>Click <a href="${resetLink}">here</a> to reset your password. This link expires in 1 hour.</p>`,
  })

  return { success: "If an account exists, a reset link has been sent" }
}

And here's the server action that processes the actual password reset:

// actions/reset-password.ts
"use server"

import bcryptjs from "bcryptjs"
import { z } from "zod"
import { db } from "@/lib/db"

const ResetPasswordSchema = z.object({
  token: z.string().min(1),
  password: z
    .string()
    .min(8, "Password must be at least 8 characters")
    .regex(/[A-Z]/, "Password must contain an uppercase letter")
    .regex(/[0-9]/, "Password must contain a number"),
})

export async function resetPassword(values: z.infer<typeof ResetPasswordSchema>) {
  const validated = ResetPasswordSchema.safeParse(values)

  if (!validated.success) {
    return { error: validated.error.errors[0].message }
  }

  const { token, password } = validated.data

  const existingToken = await db.passwordResetToken.findUnique({
    where: { token },
  })

  if (!existingToken) {
    return { error: "Invalid or expired reset link" }
  }

  if (existingToken.expires < new Date()) {
    await db.passwordResetToken.delete({
      where: { id: existingToken.id },
    })
    return { error: "Reset link has expired. Please request a new one." }
  }

  const existingUser = await db.user.findUnique({
    where: { email: existingToken.email },
  })

  if (!existingUser) {
    return { error: "User not found" }
  }

  const hashedPassword = await bcryptjs.hash(password, 12)

  await db.user.update({
    where: { id: existingUser.id },
    data: {
      password: hashedPassword,
      failedLogins: 0,
      lockedUntil: null,
    },
  })

  // Delete the used token
  await db.passwordResetToken.delete({
    where: { id: existingToken.id },
  })

  return { success: "Password updated successfully. You can now sign in." }
}

A few security considerations that are really important here. Always return the same success message regardless of whether the email exists in your database — this prevents attackers from enumerating valid email addresses. Delete tokens after use to prevent replay attacks. Set a reasonable expiration window (one hour is standard). And when resetting the password, also reset the failed login counter and account lockout — otherwise users might reset their password only to find they're still locked out, which would be a pretty frustrating experience.

How to Protect Routes with Auth.js v5 Middleware

Auth.js v5 provides middleware integration for protecting routes at the edge. The middleware runs before the page renders, so unauthenticated users get redirected to the login page before any server component code even executes.

Create the middleware file:

// middleware.ts
import NextAuth from "next-auth"
import authConfig from "./auth.config"

const { auth } = NextAuth(authConfig)

export default auth((req) => {
  const { nextUrl } = req
  const isLoggedIn = !!req.auth

  const isPublicRoute = ["/", "/login", "/register", "/forgot-password", "/reset-password"].includes(
    nextUrl.pathname
  )
  const isApiAuthRoute = nextUrl.pathname.startsWith("/api/auth")
  const isAdminRoute = nextUrl.pathname.startsWith("/admin")

  // Always allow auth API routes
  if (isApiAuthRoute) return

  // Redirect logged-in users away from auth pages
  if (isLoggedIn && ["/login", "/register"].includes(nextUrl.pathname)) {
    return Response.redirect(new URL("/dashboard", nextUrl))
  }

  // Protect private routes
  if (!isLoggedIn && !isPublicRoute) {
    const callbackUrl = encodeURIComponent(nextUrl.pathname + nextUrl.search)
    return Response.redirect(
      new URL(`/login?callbackUrl=${callbackUrl}`, nextUrl)
    )
  }

  // Role-based protection for admin routes
  if (isAdminRoute && req.auth?.user?.role !== "ADMIN") {
    return Response.redirect(new URL("/dashboard", nextUrl))
  }
})

export const config = {
  matcher: ["/((?!_next/static|_next/image|favicon.ico|public).*)"],
}

The middleware uses the edge-safe auth.config.ts file rather than the full auth.ts because edge middleware doesn't have access to Node.js APIs like Prisma or bcryptjs. That's exactly why the configuration is split across two files — it's not just organizational preference, it's a technical requirement.

The auth wrapper provides the session information through req.auth based on the JWT token in the cookie.

How to Add Rate Limiting to Prevent Brute Force Attacks

The account lockout mechanism in our authorize function provides one layer of protection, but you also need rate limiting at the request level to prevent automated attacks from overwhelming your server.

For development and simple deployments, a basic in-memory rate limiter works just fine:

// lib/rate-limit.ts
const attempts = new Map<string, { count: number; resetTime: number }>()

export function rateLimit(key: string, maxAttempts = 5, windowMs = 60000) {
  const now = Date.now()
  const entry = attempts.get(key)

  if (!entry || now > entry.resetTime) {
    attempts.set(key, { count: 1, resetTime: now + windowMs })
    return { allowed: true, remaining: maxAttempts - 1 }
  }

  if (entry.count >= maxAttempts) {
    return {
      allowed: false,
      remaining: 0,
      retryAfter: Math.ceil((entry.resetTime - now) / 1000),
    }
  }

  entry.count++
  return { allowed: true, remaining: maxAttempts - entry.count }
}

Apply it in your login server action:

// At the top of your login server action
const { allowed, retryAfter } = rateLimit(`login:${values.email}`)

if (!allowed) {
  return {
    error: `Too many login attempts. Please try again in ${retryAfter} seconds.`,
  }
}

For production applications with multiple server instances or serverless functions, the in-memory approach won't work because each instance has its own memory space. Use @upstash/ratelimit with an Upstash Redis instance for distributed rate limiting:

npm install @upstash/ratelimit @upstash/redis
// lib/rate-limit-production.ts
import { Ratelimit } from "@upstash/ratelimit"
import { Redis } from "@upstash/redis"

export const loginRateLimit = new Ratelimit({
  redis: Redis.fromEnv(),
  limiter: Ratelimit.slidingWindow(5, "60 s"),
  analytics: true,
  prefix: "ratelimit:login",
})

Think of it as defense in depth: rate limiting prevents high-volume automated attacks at the network level, while account lockout protects individual accounts from slower, more targeted attacks. You really want both layers working together.

Production Deployment Checklist for Auth.js v5

Before deploying your authentication system to production, run through this checklist. I've seen too many projects skip these steps and regret it later:

  • AUTH_SECRET — Generate a strong secret with npx auth secret or openssl rand -base64 33. This signs your JWT tokens and must be kept confidential.
  • AUTH_TRUST_HOST — Set to true when deploying behind a reverse proxy or on platforms like Vercel. This replaces the old NEXTAUTH_URL variable for most deployment scenarios.
  • NEXTAUTH_URL — Still required in some deployment environments. Set it to your production URL including the protocol (e.g., https://yourdomain.com).
  • Secure cookies — Auth.js automatically uses secure cookies in production when your site is served over HTTPS. Verify this by inspecting cookies in your browser dev tools.
  • CSRF protection — Built into Auth.js by default. Don't disable it. All POST requests to auth endpoints include a CSRF token.
  • Database connection pooling — Use a connection pooler like PgBouncer or Prisma Accelerate to handle high-traffic scenarios without exhausting database connections.
  • Monitor failed login attempts — Query the failedLogins field in your User table periodically. Unusual spikes can indicate an ongoing attack.
  • Set password hashing cost — We used a salt round of 12 for bcryptjs. This provides a good balance between security and performance. Don't go below 10 in production.
  • Token cleanup — Schedule a periodic job to delete expired PasswordResetToken records from your database to prevent table bloat.

It's also worth noting that the Auth.js project recently joined forces with the Better Auth team. This collaboration aims to unify the JavaScript authentication ecosystem and may bring new features and improved APIs in future releases. Keep an eye on the official Auth.js blog and GitHub repository for updates on how this partnership affects the Credentials provider.

Frequently Asked Questions

Can I use the Credentials Provider with database sessions instead of JWT?

No, and this is a fundamental limitation of Auth.js. The Credentials provider doesn't support database sessions when used with a database adapter. You must set session: { strategy: "jwt" } in your Auth.js configuration. The reason is that the Credentials provider's authorize function runs during the sign-in flow but doesn't create a session record in the database the way OAuth providers do. If you need database sessions for other providers in the same application, you can technically use JWT specifically for credentials while maintaining database sessions for OAuth — but this requires careful configuration and is generally not recommended.

Why does Auth.js v5 throw CallbackRouteError instead of my custom error?

Auth.js wraps all errors from the authorize function inside a CallbackRouteError for security reasons. The library doesn't want to expose internal error details to the client by default. Your original error message is preserved in error.cause.err.message, but you need to explicitly extract it in your server action's catch block. The pattern shown in this guide — using string-based error codes in authorize and a mapping function in server actions — is the most reliable way to surface custom error messages. Don't try to modify Auth.js's internal error handling; work with the pattern instead of against it.

How do I use multiple Credentials Providers for login and registration?

You don't need multiple Credentials providers for login and registration. Registration should be handled entirely in a server action — it creates the user in the database and then optionally calls signIn("credentials", ...) for auto-login. The Credentials provider's authorize function should only handle authentication (verifying existing credentials), not user creation. If you have genuinely different authentication methods (e.g., email/password and phone/PIN), you can register multiple Credentials providers by giving each a unique id:

Credentials({
  id: "email-login",
  name: "Email",
  authorize: async (credentials) => { /* ... */ }
}),
Credentials({
  id: "phone-login",
  name: "Phone",
  authorize: async (credentials) => { /* ... */ }
})

Is Auth.js v5 stable enough for production in 2026?

Auth.js v5 has been in beta for quite a while now, and it's widely used in production by thousands of applications. The core APIs — including the Credentials provider, JWT sessions, middleware integration, and the adapter system — are stable and well-tested. The next-auth@beta npm tag is the correct install target for v5. The recent partnership with Better Auth signals continued investment in the project. That said, always pin your dependency versions in production, test upgrades in staging, and subscribe to the Auth.js GitHub releases to stay informed about breaking changes. For mission-critical applications with very complex requirements, it's worth evaluating whether a dedicated auth service like Clerk, Lucia, or Better Auth might be a better fit.

How do I migrate from NextAuth v4 to Auth.js v5 Credentials Provider?

The migration involves several key changes. First, install next-auth@beta instead of the v4 package. Second, rename your configuration file: the v4 [...nextauth].ts API route gets replaced by an auth.ts file at the project root that exports handlers, auth, signIn, and signOut. Third, update your imports — useSession still comes from next-auth/react, but server-side session access now uses the auth() function exported from your auth.ts file instead of getServerSession. Fourth, the callbacks API is largely the same but the session callback now receives a token property instead of user when using JWT strategy. Fifth, middleware is now built-in: export the auth function from your edge-safe config and use it directly as middleware. Finally, review your environment variables — NEXTAUTH_SECRET becomes AUTH_SECRET, and NEXTAUTH_URL can often be replaced with AUTH_TRUST_HOST=true on modern hosting platforms.

About the Author Editorial Team

Our team of expert writers and editors.