When I first started using NextAuth.js beta (next-auth@beta
) with the Credentials provider, I figured it would be a quick setup: collect the user’s email and password, hand them off to signIn
, and let NextAuth handle the rest. Instead, I spent four hours chasing down a confusing CallbackRouteError: Illegal arguments: string, undefined
, and sometimes CredentialsSignin Erorr.
I was frustrated because their documentation does not look good to me. I have shared my response in the GitHub issue here but I thought why not share it for other people to find. If you’re seeing the same error, this post will walk you through everything I learned: the root causes, the code you need, and a robust pattern for validation and error handling that you can help you work with Auth.js smoothly.
Table of Contents
1. What To Do If You See This “Illegal arguments: string, undefined” Happens
If you seeing this along with CallbackRouteError
. Please check your function arguments and see if you are passing the arguments correctly. If you are using bcrypt, then make sure the database table column names are correct.
2. Validate Early with zod
Before you ever hit your authentication endpoint, validate user input so you never send bad data to signIn
. I use zod on the server action (or you can use it in React before submitting):
import { z } from "zod";
const loginSchema = z.object({
email: z.string().email("Enter a valid email"),
password: z
.string()
.min(8, "Password must be at least 8 characters")
.regex(/[A-Z]/, "Include at least one uppercase letter")
.regex(/[a-z]/, "Include at least one lowercase letter")
.regex(/\d/, "Include at least one number"),
});
Code language: TypeScript (typescript)
In your server action:
export async function loginUser(formData: FormData) {
const email = formData.get("email")?.toString() ?? "";
const password = formData.get("password")?.toString() ?? "";
try {
loginSchema.parse({ email, password });
} catch (e) {
const err = e as z.ZodError;
return { error: err.errors[0].message, success: null };
}
// … proceed to signIn …
}
Code language: TypeScript (typescript)
Now malformed emails or weak passwords get caught immediately, and your authorize()
callback never sees undefined fields. So, don’t forget to deal with the formData or inputs to minimize the chances of getting the error by Auth.js.
3. Implementing authorize()
Correctly
Once input passes your zod schema, you’ll call the credentials provider’s authorize
function in auth.ts
(or auth.js
). In NextAuth.js beta, the authorize
callback must behave in a specific way: return null
for invalid credentials or a user object on success. Don’t throw an error inside authorize
, because in this case such thrown errors always manifest as a CallbackRouteError
on your callback route. Instead, check first that both email
and password
exist. If either is missing, immediately return null
. It may not be needed if you are already checking using zod. Next, fetch the user record from your database—whether it’s Supabase, Prisma, or another ORM—and verify that a hashed password field actually arrives in your query response.
import NextAuth from "next-auth";
import Credentials from "next-auth/providers/credentials";
import { supabase } from "./lib/supabaseClient";
import bcrypt from "bcryptjs";
export default NextAuth({
providers: [
Credentials({
name: "Credentials",
credentials: {
email: { label: "Email", type: "email" },
password: { label: "Password", type: "password" },
},
authorize: async (credentials) => {
// 1. Ensure credentials exist
if (!credentials?.email || !credentials?.password) {
return null;
}
// 2. Fetch user, including your exact hashed-password column
const { data: user, error } = await supabase
.from("users")
.select("id, email, password, username")
.eq("email", credentials.email)
.single();
if (error || !user) {
// user not found
return null;
}
// 3. Compare passwords (both must be strings)
const isValid = await bcrypt.compare(
credentials.password,
user.password
);
if (!isValid) {
// wrong password
return null;
}
// 4. Return minimal user object for session callback
return {
id: user.id,
email: user.email,
name: user.username,
};
},
}),
],
session: { strategy: "jwt" },
pages: { signIn: "/login" }, // here you can have your own page,
callbacks: {
async jwt({ token, user }) {
if (user) {
token.id = user.id;
}
return token;
},
async session({ session, token }) {
if (session.user) {
session.user.id = token.id as string;
}
return session;
},
},
});
Code language: TypeScript (typescript)
4. Handling signIn
and Redirects in a Server Action
On the client side (or in your server action), wrap your signIn("credentials", …)
call in a try/catch
block. NextAuth’s signIn
can reject in two common ways: a “CredentialsSignin” error when credentials don’t match, or a CallbackRouteError
when you accidentally threw inside authorize
or if something happens unexpected. In your catch block, detect if the caught error is an instance of Auth.js’s AuthError
(imported from "next-auth"
), and check its type
. For both "CredentialsSignin"
and "CallbackRouteError"
, return a user‑friendly message like “Invalid email or password. Please try again.” For any other AuthError
types, show a generic “Authentication error occurred.”
A final subtlety comes when you want to redirect on successful login. Next.js server actions use a special NEXT_REDIRECT
error under the hood to signal a redirect. If you catch all errors blindly, you’ll also catch this redirect signal, preventing it from working. To handle this, inspect the caught error’s message
for the substring "NEXT_REDIRECT"
. If you see it, re‑throw the error so that Next.js can perform the intended redirect. Alternatively, you could handle redirects entirely on the frontend with useRouter
, but re‑throwing in your server action keeps your login logic self‑contained. check the code below.
Now let say you have login page. The idea is to deal with things like incorrect email format or password using zod before you send details to signIn method.
// Rest of the code above ......
const [state, formAction, isPending] = useActionState(loginUser, {
error: null,
success: null,
});
<form id="login" action={formAction}>
// Rest of the code below ......
Code language: TypeScript (typescript)
export async function loginUser(
prevState: { error: string | null; success: string | null },
formData: FormData
): Promise<{ error: string | null; success: string | null }> {
const email = formData.get("email")?.toString();
const password = formData.get("password")?.toString();
// Here you can validate the formData using zod and sendi errors......
// zod validation logic......
// ......
try {
// Once you have verified the expected schema of your formData use signIn method.
await signIn("credentials", {
email,
password,
redirectTo: "/dashboard",
});
return {
error: null,
success: "Login successful! Redirecting...",
};
} catch (error) {
// Here I am checking the error type to pass the signal to client.
if (error instanceof AuthError) {
switch (error.type) {
case "CredentialsSignin":
case "CallbackRouteError":
return {
error: "Invalid email or password. Try again!",
success: null,
};
default:
return { error: "An authentication error occurred.", success: null };
}
}
// Here I need to rethrow the "Next_DIRECT" error so that redirection can work inside the server action, otherwise catch block will prevent it, and you will see another error. You can use useRouter if not this.
if (
error &&
typeof error === "object" &&
"message" in error &&
typeof error.message === "string" &&
error.message.includes("NEXT_REDIRECT")
) {
throw error;
}
// For something unexpected
return {
error: "An unexpected error occurred. Please try again.",
success: null,
};
}
}
Code language: TypeScript (typescript)
Here’s the high‑level flow:
- Validate form input with zod before calling
signIn
. - In
authorize()
, returnnull
for any missing or invalid data; never throw. - Ensure your database query includes the precise hashed password column.
- Compare passwords with
bcrypt.compare
, returningnull
if it fails. - Return a clean user object on success.
- Wrap
signIn
intry/catch
, handleAuthError
types for bad credentials, re‑throwNEXT_REDIRECT
, and catch any other unexpected errors gracefully.
This pattern prevents the dreaded CallbackRouteError
and gives your users clear, friendly feedback as schema validation happens in one place, authorization logic in another, and error handling in a concise try/catch
. Although the current beta of Auth.js makes this a bit more complex, but following these steps will save you hours of debugging.
In the future, I hope the NextAuth team provides a built‑in mechanism for returning custom error messages from authorize()
without resorting to throwing or returning null
. Meanwhile, this approach works consistently with the beta, at least for me. If you have any other solution, let me know.