NextAuth login failed: handle CredentialsSignin, v4 and v5
Reproduced on next-auth 4.24.15 and 5.0.0-beta.32: what signIn() returns on a wrong password, why the accepted if (ok) check breaks on v5, and how to get a usable error code.
TL;DR#
- With
redirect: false, the clientsignIn("credentials", …)fromnext-auth/reactdoes not reject when the password is wrong. It resolves with an object whoseerrorfield names the failure, so a.catch()toast never fires. - Branch on
error, not onok. On next-auth 4.24.15 a failed login comes back as HTTP 401 withok: false, but on 5.0.0-beta.32 it comes back as HTTP 200 withok: trueanderror: "CredentialsSignin". - On 5.0.0-beta.32, a plain
throw new Error("…")insideauthorizereaches the client asConfiguration. To get your own code to the client, throw a subclass ofCredentialsSignin. On the server-sidesignInof v5, catchAuthErrorand rethrow everything else.
The question, and the answer that is half right#
The Stack Overflow question (28 votes, about 44,000 views) describes the classic symptom: the login form works, but a wrong password shows nothing. The code waits for the error in .catch():
signIn("credentials", {
...values,
redirect: false,
})
.then(async () => {
await router.push("/dashboard");
})
.catch((e) => {
toast("Credentials do not match!", { type: "error" });
});The accepted answer explains the important part: with redirect: false, the promise resolves to { error, status, ok, url } and "won't throw an error", so the failure has to be handled inside .then(). Its code branches on ok. That check still works on 4.24.15, where a failed login returns HTTP 401, and it fails on 5.0.0-beta.32, as the reproduction below shows. The doc it links is the v3 page. The rest of this article shows what each current version actually returns.
The npm registry lists two lines today: latest is 4.24.15 and the beta tag is 5.0.0-beta.32 (checked on 11 October 2026 at registry.npmjs.org/next-auth). A plain npm install next-auth therefore still gives you v4. Run npm ls next-auth before picking a fix below.
Why the promise resolves instead of rejecting#
Both versions of the client signIn() do the same thing. They POST the form to /api/auth/callback/credentials, read the JSON body, and pull the error parameter out of the URL the server put in it. No branch throws for a failed login.
- In next-auth 4.24.15, the request carries
json: trueand the function returns{ error, status: res.status, ok: res.ok, url: error ? null : data.url }. - In next-auth 5.0.0-beta.32, the request carries the header
X-Auth-Return-Redirect: 1and the function returns the same shape plus acodefield.
The v4 docs describe the returned object in Using the redirect: false option, and note that the option exists only for the credentials and email providers. Without redirect: false, signIn() sets window.location.href to the URL the server returned, which is how a failed login turns into a full navigation to the error or sign-in page.
Where ok: true on a failed v5 login comes from#
ok is not computed from the login result. It is res.ok of the fetch call, which is true for any 2xx response. The HTTP status is the server's choice, and the two versions choose differently:
- The v4 credentials route returns status 401 with a redirect to
/error?error=CredentialsSigninwhenauthorizereturnsnull, and with the error message itself whenauthorizethrows. The JSON answer keeps that 401. - In v5, the credentials route throws a
CredentialsSigninerror. The catch block ofAuth()in @auth/core 0.41.3 builds the error URL and, when theX-Auth-Return-Redirectheader is present, answersResponse.json({ url }). No status is passed, so the response is a 200.
The same pattern was reported for the SvelteKit client in issue #10931 ("always status = 200 and ok = true even when the client submits invalid credentials"), which is still open.
What each version returns: reproduced#
The test ran two Next.js 16.4.0 apps in a node:22-bookworm container on 11 October 2026: one on next-auth 4.24.15, one on next-auth 5.0.0-beta.32 (which depends on @auth/core 0.41.3). Each has one Credentials provider whose authorize returns a user for the password right, returns null otherwise, and throws new Error("Invalid credentials.") when a mode field says so. A script then replayed the requests the client signIn() sends with redirect: false (CSRF token, form POST, json: true for v4, the X-Auth-Return-Redirect header for v5) and derived the result exactly as the client source above does. It replays the HTTP calls; it does not run next-auth/react in a browser.
next-auth 4.24.15:
v4 password=wrong mode=- -> HTTP 401, body {"url":"http://localhost:3000/api/auth/error?error=CredentialsSignin&provider=credentials"}
v4 signIn() would resolve to {"error":"CredentialsSignin","status":401,"ok":false,"url":null}
v4 password=wrong mode=throw -> HTTP 401, body {"url":"http://localhost:3000/api/auth/error?error=Invalid%20credentials."}
v4 signIn() would resolve to {"error":"Invalid credentials.","status":401,"ok":false,"url":null}next-auth 5.0.0-beta.32:
v5 password=wrong mode=- -> HTTP 200, body {"url":"http://localhost:3005/api/auth/signin?error=CredentialsSignin&code=credentials"}
v5 signIn() would resolve to {"error":"CredentialsSignin","code":"credentials","status":200,"ok":true,"url":null}
v5 password=wrong mode=throw -> HTTP 200, body {"url":"http://localhost:3005/api/auth/error?error=Configuration"}
v5 signIn() would resolve to {"error":"Configuration","status":200,"ok":true,"url":null}Three things follow. On v5 the accepted answer's if (ok) takes the success branch for a wrong password and pushes to the dashboard. On v4 a thrown message travels to the client verbatim, which is what the v4 Credentials docs promise ("the user will be sent to the error page with the error message as a query parameter"). On v5 that message is gone: the server logs it, and the client only learns Configuration.
The v5 server log shows why, for the two failures:
[auth][error] CredentialsSignin: Read more at https://errors.authjs.dev#credentialssignin
[auth][error] CallbackRouteError: Read more at https://errors.authjs.dev#callbackrouteerror
[auth][cause]: Error: Invalid credentials.A plain Error thrown from authorize is wrapped in CallbackRouteError, which is not in the list of errors @auth/core sends to the client; anything outside that list is reported as Configuration. The current Auth.js Credentials guide still uses throw new Error("Invalid credentials.") in its Next.js example, so code copied from it shows a configuration error for a mistyped password.
Fix#
1. Keep the redirect off and branch on error#
This works on 4.24.15 and on 5.0.0-beta.32, because both put the failure in error and set url to null:
"use client";
import { signIn } from "next-auth/react";
import { useRouter } from "next/navigation";
import { toast } from "react-toastify";
export function useCredentialsLogin() {
const router = useRouter();
return async function login(values: { email: string; password: string }) {
const result = await signIn("credentials", { ...values, redirect: false });
if (!result || result.error) {
toast("Credentials do not match!", { type: "error" });
return;
}
router.push("/dashboard");
};
}result can be undefined: when the provider id is unknown, both versions send the browser to the sign-in page and return nothing.
2. On v4, return null or throw a message the user may read#
In 4.24.15 the error field carries either CredentialsSignin (when authorize returns null) or the exact message of the error you threw:
// pages/api/auth/[...nextauth].ts or app/api/auth/[...nextauth]/route.ts (next-auth 4.24.15)
CredentialsProvider({
credentials: { email: {}, password: {} },
async authorize(credentials) {
const user = await findUser(credentials?.email, credentials?.password);
if (!user) throw new Error("Email or password is incorrect");
return user;
},
});The message ends up in the URL and in the client, so keep it generic: do not say whether the email or the password was wrong.
3. On v5, throw a CredentialsSignin subclass with a code#
CredentialsSignin has a public code property, "credentials" by default, that @auth/core copies into the code query parameter. The CredentialsSignin source warns that this code is included in the URL, so it should not hint at sensitive errors:
// auth.ts (next-auth 5.0.0-beta.32)
import NextAuth, { CredentialsSignin } from "next-auth";
import Credentials from "next-auth/providers/credentials";
class InvalidLoginError extends CredentialsSignin {
code = "invalid_credentials";
}
export const { handlers, signIn, signOut, auth } = NextAuth({
providers: [
Credentials({
credentials: { email: {}, password: {} },
async authorize(credentials) {
const user = await findUser(credentials.email, credentials.password);
if (!user) throw new InvalidLoginError();
return user;
},
}),
],
});On the client, read result.code next to result.error:
v5 password=wrong mode=custom -> HTTP 200, body {"url":"http://localhost:3005/api/auth/signin?error=CredentialsSignin&code=invalid_credentials"}
v5 signIn() would resolve to {"error":"CredentialsSignin","code":"invalid_credentials","status":200,"ok":true,"url":null}4. On v5 server actions, catch AuthError, rethrow the rest#
The signIn exported from auth.ts does not return an error object. In the same test, called from a route handler with redirect: false, it threw:
mode=-: {"thrown":"CredentialsSignin","type":"CredentialsSignin","code":"credentials","instanceofAuthError":true,"instanceofCredentialsSignin":true}
mode=throw: {"thrown":"CallbackRouteError","type":"CallbackRouteError","instanceofAuthError":true,"instanceofCredentialsSignin":false}
mode=custom: {"thrown":"InvalidLoginError","type":"CredentialsSignin","code":"invalid_credentials","instanceofAuthError":true,"instanceofCredentialsSignin":true}The Auth.js custom sign-in page guide catches it with error instanceof AuthError and rethrows anything else. The rethrow matters: on success signIn calls Next.js redirect(), and the Next.js docs state that "redirect throws an error so it should be called outside the try block". A catch-all that swallows it cancels the redirect after a good login. A server action that returns the code to a form:
// app/login/actions.ts (next-auth 5.0.0-beta.32)
"use server";
import { AuthError, CredentialsSignin } from "next-auth";
import { signIn } from "@/auth";
export async function login(_prev: string | null, formData: FormData) {
try {
await signIn("credentials", { ...Object.fromEntries(formData), redirectTo: "/dashboard" });
return null;
} catch (error) {
if (error instanceof CredentialsSignin) return error.code;
if (error instanceof AuthError) return error.type;
throw error;
}
}Wire it with useActionState(login, null) in a client component and render the returned string. If you are unsure where the "use server" boundary sits, the page on use client versus use server explains that the directive marks functions the client may call, not a component type.
Verify the fix#
Submit the form with a wrong password and open the Network tab. The POST to /api/auth/callback/credentials answers with a JSON body whose url contains error=CredentialsSignin. On 4.24.15 the row shows 401; on 5.0.0-beta.32 it shows 200, which is expected and is why the code checks error. The toast appears and the address bar does not change. Then log in with the right password: error is null (or undefined on v5), url holds the callback URL, and the router moves on. On 5.0.0-beta.32 the terminal prints one [auth][error] CredentialsSignin line for each failure, which is normal logging, not a crash.
One email a month — the latest explained errors
What the site published that month — mostly Next.js, Supabase and Node errors, explained. You confirm by email; unsubscribe any time in one click.
When the error arrives in the URL instead#
If you drop redirect: false, the browser follows the server's URL, and the error becomes a query parameter. In 4.24.15, CredentialsSignin is one of the errors the error route sends back to the sign-in page (/signin?error=CredentialsSignin), and a custom pages.signIn receives it as ?error= next to callbackUrl. In 5.0.0-beta.32 CredentialsSignin is a sign-in error, so the URL is the sign-in page with error=CredentialsSignin&code=credentials, as the reproduction shows and as the Auth.js Credentials guide describes. Read searchParams.error (and searchParams.code on v5) on your login page and map it to a message; never print the raw value into the page without escaping it.
If the login now succeeds locally but, in production, protected pages bounce back to the login form, the middleware probably cannot decode the token. That is covered in Next.js middleware not running on Vercel, whose fourth check is an auth secret that is missing in production or named differently in the middleware and in the auth route. If the error is a TypeScript one in _app.tsx instead, see Property 'session' does not exist on type ''. And if you are weighing whether to keep Auth.js at all, the Next.js auth comparison sets it against Clerk, Better Auth and Supabase Auth. Whatever the stack, the rule from this page holds: treat a sign-in result as data, read its error field, and do not trust an HTTP-level ok to tell you the user is logged in.
Frequently Asked Questions
Still stuck? Paste your error.
Paste the error message or stack trace — we check it against every fix and tool on the site.
Continue Reading
Fix next-auth /api/auth/error in production: NO_SECRET
Reproduced on next-auth 4.24.15 and Next.js 15.5.28: why sign-in works on localhost but fails after deploy, what a 404 versus a 500 at /api/auth/error tells you, and the env change that fixes it.
Fix Auth.js UntrustedHost in Production
A local reproduction separates the host-trust guard from proxy configuration, environment inference and later sign-in failures.
Next.js "Failed to load SWC binary": the real fix
Why next dev or next build refuses to start, what actually happens when npm cannot find the right @next/swc binary, and the one reinstall that puts it back — reproduced on Next.js 16.4.2.


