Testing NextAuth.js Logins in Cypress: Optimizing Speed & Reliability
Login Strategies for Cypress Tests In this article, I will demonstrate different approaches to handling authentication in your Cypress tests when using the NextAuth.js credentials provider. We will cover different methods for balancing test reliability with execution speed. In our examples, we will use a standard email/password login form, authenticating with a hard-coded test user (user@example.com) configured in the NextAuth.js Credentials Provider. // /auth.config.js import CredentialsProvider from "next-auth/providers/credentials"; export const authConfig = { providers: [ CredentialsProvider({ name: "Credentials", async authorize(credentials) { const user = { id: "1", password: "123", email: "user@example.com" }; return credentials.email === user.email && credentials.password === user.password ? user : null; }, }), ], pages: { signIn: "/login" }, }; // /app/page.js "use client"; import { signIn } from "next-auth/react"; import { useRouter } from "next/navigation"; import { useState } from "react"; export default function LoginPage() { const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); const router = useRouter(); const handleSubmit = async (e) => { e.preventDefault(); const result = await signIn("credentials", { email, password, redirect: false, }); result?.ok && router.refresh(); }; return ( Login setEmail(e.target.value)} placeholder="Email" /> setPassword(e.target.value)} placeholder="Password" /> Login ); } The simplest and often correct approach is UI-based logins, as they accurately reflect real user behavior and are ideal for end-to-end (E2E) testing. However, if your test suite includes many tests that require authentication, UI-based repeated logins can significantly slow down execution. Below, I will cover several optimization techniques when using NextAuth.js with the credentials provider. // /cypress/e2e/login.cy.js describe("Login", () => { const credentials = { email: "user@example.com", password: "123", }; // 1. UI Login // Pros: Tests real user journey end-to-end // Cons: Requires full render cycle it("logs in via UI", () => { cy.visit("/"); cy.contains("Not logged in"); cy.get('[type="email"]').type(credentials.email); cy.get('[type="password"]').type(credentials.password); cy.get("button").contains("Login").click(); cy.contains(`Logged in as ${credentials.email}`).should("be.visible"); }); // 2. Direct API Request Login // Pros: Fastest method, bypasses UI // Cons: Doesn't test actual user flow it("logs in via direct API request", () => { cy.visit("/"); cy.contains("Not logged in"); cy.request({ method: "GET", url: `/api/auth/csrf`, }).then((csrfResponse) => { return cy.request({ method: "POST", url: `/api/auth/callback/credentials`, headers: { "Content-Type": "application/json" }, body: { ...credentials, csrfToken: csrfResponse.body.csrfToken, json: "true", }, }); }); cy.visit("/"); cy.contains(`Logged in as ${credentials.email}`).should("be.visible"); }); // 3. Browser Fetch API Login // Pros: Tests auth in browser context with real fetch API // Cons: More complex than cy.request, still skips UI it("logs in via browser fetch API", () => { cy.visit("/"); cy.contains("Not logged in"); cy.window().then(async (win) => { const csrfResponse = await win.fetch("/api/auth/csrf"); const { csrfToken } = await csrfResponse.json(); const loginResponse = await win.fetch("/api/auth/callback/credentials", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ ...credentials, csrfToken, json: "true" }), }); return loginResponse.json(); }); cy.visit("/"); cy.contains(`Logged in as ${credentials.email}`).should("be.visible"); }); // 4. Eval-Based Login // Pros: Demonstrates string injection technique // Cons: Least readable, potential security concerns // Use Case: Only needed when testing code evaluation scenarios it("logs in via eval-based approach", () => { cy.visit("/"); cy.contains("Not logged in"); cy.window().then((win) => { return win.eval(` (() => { const credentials = ${JSON.stringify(credentials)}; return fetch('/api/auth/csrf') .then(r => r.json()) .then(({ csrfToken }) => fetch('/api/auth/callback/credentials', { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ ...credentials,

Login Strategies for Cypress Tests
In this article, I will demonstrate different approaches to handling authentication in your Cypress tests when using the NextAuth.js credentials provider. We will cover different methods for balancing test reliability with execution speed.
In our examples, we will use a standard email/password login form, authenticating with a hard-coded test user (user@example.com) configured in the NextAuth.js Credentials Provider.
// /auth.config.js
import CredentialsProvider from "next-auth/providers/credentials";
export const authConfig = {
providers: [
CredentialsProvider({
name: "Credentials",
async authorize(credentials) {
const user = { id: "1", password: "123", email: "user@example.com" };
return credentials.email === user.email &&
credentials.password === user.password
? user
: null;
},
}),
],
pages: { signIn: "/login" },
};
// /app/page.js
"use client";
import { signIn } from "next-auth/react";
import { useRouter } from "next/navigation";
import { useState } from "react";
export default function LoginPage() {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const router = useRouter();
const handleSubmit = async (e) => {
e.preventDefault();
const result = await signIn("credentials", {
email,
password,
redirect: false,
});
result?.ok && router.refresh();
};
return (
<form onSubmit={handleSubmit}>
<h1>Login</h1>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="Email"
/>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Password"
/>
<button type="submit">Login</button>
</form>
);
}
The simplest and often correct approach is UI-based logins, as they accurately reflect real user behavior and are ideal for end-to-end (E2E) testing. However, if your test suite includes many tests that require authentication, UI-based repeated logins can significantly slow down execution. Below, I will cover several optimization techniques when using NextAuth.js with the credentials provider.
// /cypress/e2e/login.cy.js
describe("Login", () => {
const credentials = {
email: "user@example.com",
password: "123",
};
// 1. UI Login
// Pros: Tests real user journey end-to-end
// Cons: Requires full render cycle
it("logs in via UI", () => {
cy.visit("/");
cy.contains("Not logged in");
cy.get('[type="email"]').type(credentials.email);
cy.get('[type="password"]').type(credentials.password);
cy.get("button").contains("Login").click();
cy.contains(`Logged in as ${credentials.email}`).should("be.visible");
});
// 2. Direct API Request Login
// Pros: Fastest method, bypasses UI
// Cons: Doesn't test actual user flow
it("logs in via direct API request", () => {
cy.visit("/");
cy.contains("Not logged in");
cy.request({
method: "GET",
url: `/api/auth/csrf`,
}).then((csrfResponse) => {
return cy.request({
method: "POST",
url: `/api/auth/callback/credentials`,
headers: { "Content-Type": "application/json" },
body: {
...credentials,
csrfToken: csrfResponse.body.csrfToken,
json: "true",
},
});
});
cy.visit("/");
cy.contains(`Logged in as ${credentials.email}`).should("be.visible");
});
// 3. Browser Fetch API Login
// Pros: Tests auth in browser context with real fetch API
// Cons: More complex than cy.request, still skips UI
it("logs in via browser fetch API", () => {
cy.visit("/");
cy.contains("Not logged in");
cy.window().then(async (win) => {
const csrfResponse = await win.fetch("/api/auth/csrf");
const { csrfToken } = await csrfResponse.json();
const loginResponse = await win.fetch("/api/auth/callback/credentials", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ...credentials, csrfToken, json: "true" }),
});
return loginResponse.json();
});
cy.visit("/");
cy.contains(`Logged in as ${credentials.email}`).should("be.visible");
});
// 4. Eval-Based Login
// Pros: Demonstrates string injection technique
// Cons: Least readable, potential security concerns
// Use Case: Only needed when testing code evaluation scenarios
it("logs in via eval-based approach", () => {
cy.visit("/");
cy.contains("Not logged in");
cy.window().then((win) => {
return win.eval(`
(() => {
const credentials = ${JSON.stringify(credentials)};
return fetch('/api/auth/csrf')
.then(r => r.json())
.then(({ csrfToken }) =>
fetch('/api/auth/callback/credentials', {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
...credentials,
csrfToken,
json: "true"
})
})
)
.then(res => res.json())
})()
`);
});
cy.visit("/");
cy.contains(`Logged in as ${credentials.email}`).should("be.visible");
});
});
For most projects, you'll primarily use either UI login (for full end-to-end tests) or API login (for faster tests). The other methods are just examples. To make testing easier, move your login code into Cypress custom commands - this way you can reuse the same login logic across all your tests, keeping them clean and simple.
// /cypress/support/commands.js
Cypress.Commands.add("signIn", (email, password) => {
cy.request("/api/auth/csrf").then((response) => {
return cy.request({
method: "POST",
url: "/api/auth/callback/credentials",
body: {
email,
password,
csrfToken: response.body.csrfToken,
json: "true",
},
});
});
cy.visit("/");
cy.contains(`Logged in as ${email}`).should("be.visible");
});
Now you can reuse the login command across all your tests
it('logs in via custom command', ()=> {
cy.signIn('user@example.com', '123')
})
Hope this helps! Full code example:
https://github.com/AlekseevArthur/next-auth-cypress