Verification requests (magic link) sent to unwanted emails
Description
NextAuth.js is a complete open source authentication solution for Next.js applications. next-auth users who are using the EmailProvider either in versions before 4.10.3 or 3.29.10 are affected. If an attacker could forge a request that sent a comma-separated list of emails (eg.: attacker@attacker.com,victim@victim.com) to the sign-in endpoint, NextAuth.js would send emails to both the attacker and the victim's e-mail addresses. The attacker could then login as a newly created user with the email being attacker@attacker.com,victim@victim.com. This means that basic authorization like email.endsWith("@victim.com") in the signIn callback would fail to communicate a threat to the developer and would let the attacker bypass authorization, even with an @attacker.com address. This vulnerability has been patched in v4.10.3 and v3.29.10 by normalizing the email value that is sent to the sign-in endpoint before accessing it anywhere else. We also added a normalizeIdentifier callback on the EmailProvider configuration, where you can further tweak your requirements for what your system considers a valid e-mail address. (E.g.: strict RFC2821 compliance). Users are advised to upgrade. There are no known workarounds for this vulnerability. If for some reason you cannot upgrade, you can normalize the incoming request using Advanced Initialization.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
next-authnpm | >= 4.0.0, < 4.10.3 | 4.10.3 |
next-authnpm | < 3.29.10 | 3.29.10 |
Affected products
1- Range: >= 4.0.0, < 4.10.3
Patches
1afb1fcdae3ccfix(providers): add `normalizeIdentifier` to EmailProvider
12 files changed · +301 −66
docs/docs/providers/email.md+28 −0 modified@@ -223,3 +223,31 @@ providers: [ }) ], ``` + +## Normalizing the email address + +By default, NextAuth.js will normalize the email address. It treats values as case-insensitive (which is technically not compliant to the [RFC 2821 spec](https://datatracker.ietf.org/doc/html/rfc2821), but in practice this causes more problems than it solves, eg. when looking up users by e-mail from databases.) and also removes any secondary email address that was passed in as a comma-separated list. You can apply your own normalization via the `normalizeIdentifier` method on the `EmailProvider`. The following example shows the default behavior: +```ts + EmailProvider({ + // ... + normalizeIdentifier(identifier: string): string { + // Get the first two elements only, + // separated by `@` from user input. + let [local, domain] = identifier.toLowerCase().trim().split("@") + // The part before "@" can contain a "," + // but we remove it on the domain part + domain = domain.split(",")[0] + return `${local}@${domain}` + + // You can also throw an error, which will redirect the user + // to the error page with error=EmailSignin in the URL + // if (identifier.split("@").length > 2) { + // throw new Error("Only one email allowed") + // } + }, + }) +``` + +:::warning +Always make sure this returns a single e-mail address, even if multiple ones were passed in. +::: \ No newline at end of file
packages/next-auth/config/jest.client.config.js+0 −16 removed@@ -1,16 +0,0 @@ -/** @type {import('@jest/types').Config.InitialOptions} */ -module.exports = { - transform: { - "\\.(js|jsx|ts|tsx)$": ["@swc/jest", require("./swc.config")], - }, - rootDir: "../src", - setupFilesAfterEnv: ["../config/jest-setup.js"], - testMatch: ["**/*.test.js"], - // collectCoverageFrom: ["!client/__tests__/**"], - // coverageDirectory: "../coverage", - testEnvironment: "jsdom", - watchPlugins: [ - "jest-watch-typeahead/filename", - "jest-watch-typeahead/testname", - ], -}
packages/next-auth/config/jest.config.js+34 −0 added@@ -0,0 +1,34 @@ +/** @type {import('jest').Config} */ +module.exports = { + projects: [ + { + displayName: "core", + testMatch: ["**/*.test.ts"], + rootDir: ".", + setupFilesAfterEnv: ["./config/jest-setup.js"], + transform: { + "\\.(js|jsx|ts|tsx)$": ["@swc/jest", require("./swc.config")], + }, + coveragePathIgnorePatterns: ["tests"], + }, + { + displayName: "client", + testMatch: ["**/*.test.js"], + setupFilesAfterEnv: ["./config/jest-setup.js"], + rootDir: ".", + transform: { + "\\.(js|jsx|ts|tsx)$": ["@swc/jest", require("./swc.config")], + }, + testEnvironment: "jsdom", + coveragePathIgnorePatterns: ["__tests__"], + }, + ], + watchPlugins: [ + "jest-watch-typeahead/filename", + "jest-watch-typeahead/testname", + ], + collectCoverage: true, + coverageDirectory: "../coverage", + coverageReporters: ["html", "text-summary"], + collectCoverageFrom: ["src/**/*.(js|jsx|ts|tsx)"], +}
packages/next-auth/config/jest.core.config.js+0 −13 removed@@ -1,13 +0,0 @@ -/** @type {import('@jest/types').Config.InitialOptions} */ -module.exports = { - transform: { - "\\.(js|jsx|ts|tsx)$": ["@swc/jest", require("./swc.config")], - }, - rootDir: "..", - testMatch: ["**/*.test.ts"], - setupFilesAfterEnv: ["./config/jest-setup.js"], - watchPlugins: [ - "jest-watch-typeahead/filename", - "jest-watch-typeahead/testname", - ], -}
packages/next-auth/package.json+1 −3 modified@@ -42,9 +42,7 @@ "build:js": "pnpm clean && pnpm generate-providers && tsc && babel --config-file ./config/babel.config.js src --out-dir . --extensions \".tsx,.ts,.js,.jsx\"", "build:css": "postcss --config config/postcss.config.js src/**/*.css --base src --dir . && node config/wrap-css.js", "watch:css": "postcss --config config/postcss.config.js --watch src/**/*.css --base src --dir .", - "test:client": "jest --config ./config/jest.client.config.js", - "test:core": "jest --config ./config/jest.core.config.js", - "test": "pnpm test:core && pnpm test:client", + "test": "jest --config ./config/jest.config.js", "prepublishOnly": "pnpm build", "generate-providers": "node ./config/generate-providers.js", "setup": "pnpm generate-providers",
packages/next-auth/src/core/lib/email/signin.ts+18 −17 modified@@ -21,27 +21,28 @@ export default async function email( Date.now() + (provider.maxAge ?? ONE_DAY_IN_SECONDS) * 1000 ) - // Save in database - // @ts-expect-error - await adapter.createVerificationToken({ - identifier, - token: hashToken(token, options), - expires, - }) - // Generate a link with email, unhashed token and callback url const params = new URLSearchParams({ callbackUrl, token, email: identifier }) const _url = `${url}/callback/${provider.id}?${params}` - // Send to user - await provider.sendVerificationRequest({ - identifier, - token, - expires, - url: _url, - provider, - theme, - }) + await Promise.all([ + // Send to user + provider.sendVerificationRequest({ + identifier, + token, + expires, + url: _url, + provider, + theme, + }), + // Save in database + // @ts-expect-error // verified in `assertConfig` + adapter.createVerificationToken({ + identifier, + token: hashToken(token, options), + expires, + }), + ]) return `${url}/verify-request?${new URLSearchParams({ provider: provider.id,
packages/next-auth/src/core/routes/signin.ts+20 −13 modified@@ -33,16 +33,26 @@ export default async function signin(params: { return { redirect: `${url}/error?error=OAuthSignin` } } } else if (provider.type === "email") { - /** - * @note Technically the part of the email address local mailbox element - * (everything before the @ symbol) should be treated as 'case sensitive' - * according to RFC 2821, but in practice this causes more problems than - * it solves. We treat email addresses as all lower case. If anyone - * complains about this we can make strict RFC 2821 compliance an option. - */ - const email = body?.email?.toLowerCase() - + let email: string = body?.email if (!email) return { redirect: `${url}/error?error=EmailSignin` } + const normalizer: (identifier: string) => string = + provider.normalizeIdentifier ?? + ((identifier) => { + // Get the first two elements only, + // separated by `@` from user input. + let [local, domain] = identifier.toLowerCase().trim().split("@") + // The part before "@" can contain a "," + // but we remove it on the domain part + domain = domain.split(",")[0] + return `${local}@${domain}` + }) + + try { + email = normalizer(body?.email) + } catch (error) { + logger.error("SIGNIN_EMAIL_ERROR", { error, providerId: provider.id }) + return { redirect: `${url}/error?error=EmailSignin` } + } // Verified in `assertConfig` // eslint-disable-next-line @typescript-eslint/no-non-null-assertion @@ -85,10 +95,7 @@ export default async function signin(params: { const redirect = await emailSignin(email, options) return { redirect } } catch (error) { - logger.error("SIGNIN_EMAIL_ERROR", { - error: error as Error, - providerId: provider.id, - }) + logger.error("SIGNIN_EMAIL_ERROR", { error, providerId: provider.id }) return { redirect: `${url}/error?error=EmailSignin` } } }
packages/next-auth/src/providers/email.ts+16 −1 modified@@ -46,6 +46,21 @@ export interface EmailConfig extends CommonProviderOptions { generateVerificationToken?: () => Awaitable<string> /** If defined, it is used to hash the verification token when saving to the database . */ secret?: string + /** + * Normalizes the user input before sending the verification request. + * + * ⚠️ Always make sure this method returns a single email address. + * + * @note Technically, the part of the email address local mailbox element + * (everything before the `@` symbol) should be treated as 'case sensitive' + * according to RFC 2821, but in practice this causes more problems than + * it solves, e.g.: when looking up users by e-mail from databases. + * By default, we treat email addresses as all lower case, + * but you can override this function to change this behavior. + * + * [Documentation](https://next-auth.js.org/providers/email#normalizing-the-e-mail-address) | [RFC 2821](https://tools.ietf.org/html/rfc2821) | [Email syntax](https://en.wikipedia.org/wiki/Email_address#Syntax) + */ + normalizeIdentifier?: (identifier: string) => string options: EmailUserConfig } @@ -79,7 +94,7 @@ export default function Email(options: EmailUserConfig): EmailConfig { }) const failed = result.rejected.concat(result.pending).filter(Boolean) if (failed.length) { - throw new Error(`Email(s) (${failed.join(", ")}) could not be sent`) + throw new Error(`Email (${failed.join(", ")}) could not be sent`) } }, options,
packages/next-auth/tests/email.test.ts+167 −0 added@@ -0,0 +1,167 @@ +import { createCSRF, handler, mockAdapter } from "./lib" +import EmailProvider from "../src/providers/email" + +it("Send e-mail to the only address correctly", async () => { + const { secret, csrf } = await createCSRF() + const sendVerificationRequest = jest.fn() + const signIn = jest.fn(() => true) + + const email = "email@example.com" + const { res } = await handler( + { + adapter: mockAdapter(), + providers: [EmailProvider({ sendVerificationRequest })], + callbacks: { signIn }, + secret, + }, + { + path: "signin/email", + requestInit: { + method: "POST", + headers: { cookie: csrf.cookie }, + body: JSON.stringify({ email: email, csrfToken: csrf.value }), + }, + } + ) + + expect(res.redirect).toBe( + "http://localhost:3000/api/auth/verify-request?provider=email&type=email" + ) + + expect(signIn).toBeCalledTimes(1) + expect(signIn).toHaveBeenCalledWith( + expect.objectContaining({ + user: expect.objectContaining({ email }), + }) + ) + + expect(sendVerificationRequest).toHaveBeenCalledWith( + expect.objectContaining({ identifier: email }) + ) +}) + +it("Send e-mail to first address only", async () => { + const { secret, csrf } = await createCSRF() + const sendVerificationRequest = jest.fn() + const signIn = jest.fn(() => true) + + const firstEmail = "email@email.com" + const email = `${firstEmail},email@email2.com` + const { res } = await handler( + { + adapter: mockAdapter(), + providers: [EmailProvider({ sendVerificationRequest })], + callbacks: { signIn }, + secret, + }, + { + path: "signin/email", + requestInit: { + method: "POST", + headers: { cookie: csrf.cookie }, + body: JSON.stringify({ email: email, csrfToken: csrf.value }), + }, + } + ) + + expect(res.redirect).toBe( + "http://localhost:3000/api/auth/verify-request?provider=email&type=email" + ) + + expect(signIn).toBeCalledTimes(1) + expect(signIn).toHaveBeenCalledWith( + expect.objectContaining({ + user: expect.objectContaining({ email: firstEmail }), + }) + ) + + expect(sendVerificationRequest).toHaveBeenCalledWith( + expect.objectContaining({ identifier: firstEmail }) + ) +}) + +it("Send e-mail to address with first domain", async () => { + const { secret, csrf } = await createCSRF() + const sendVerificationRequest = jest.fn() + const signIn = jest.fn(() => true) + + const firstEmail = "email@email.com" + const email = `${firstEmail},email2.com` + const { res } = await handler( + { + adapter: mockAdapter(), + providers: [EmailProvider({ sendVerificationRequest })], + callbacks: { signIn }, + secret, + }, + { + path: "signin/email", + requestInit: { + method: "POST", + headers: { cookie: csrf.cookie }, + body: JSON.stringify({ email: email, csrfToken: csrf.value }), + }, + } + ) + + expect(res.redirect).toBe( + "http://localhost:3000/api/auth/verify-request?provider=email&type=email" + ) + + expect(signIn).toBeCalledTimes(1) + expect(signIn).toHaveBeenCalledWith( + expect.objectContaining({ + user: expect.objectContaining({ email: firstEmail }), + }) + ) + + expect(sendVerificationRequest).toHaveBeenCalledWith( + expect.objectContaining({ identifier: firstEmail }) + ) +}) + +it("Redirect to error page if multiple addresses aren't allowed", async () => { + const { secret, csrf } = await createCSRF() + const sendVerificationRequest = jest.fn() + const signIn = jest.fn() + const error = new Error("Only one email allowed") + const { res, log } = await handler( + { + adapter: mockAdapter(), + callbacks: { signIn }, + providers: [ + EmailProvider({ + sendVerificationRequest, + normalizeIdentifier(identifier) { + if (identifier.split("@").length > 2) throw error + return identifier + }, + }), + ], + secret, + }, + { + path: "signin/email", + requestInit: { + method: "POST", + headers: { cookie: csrf.cookie }, + body: JSON.stringify({ + email: "email@email.com,email@email2.com", + csrfToken: csrf.value, + }), + }, + } + ) + + expect(signIn).toBeCalledTimes(0) + expect(sendVerificationRequest).toBeCalledTimes(0) + + expect(log.error.mock.calls[0]).toEqual([ + "SIGNIN_EMAIL_ERROR", + { error, providerId: "email" }, + ]) + + expect(res.redirect).toBe( + "http://localhost:3000/api/auth/error?error=EmailSignin" + ) +})
packages/next-auth/tests/getServerSession.test.ts+0 −1 modified@@ -1,4 +1,3 @@ -import type { NextApiRequest } from "next" import { MissingSecret } from "../src/core/errors" import { unstable_getServerSession } from "../src/next" import { mockLogger } from "./lib"
packages/next-auth/tests/lib.ts+9 −1 modified@@ -1,6 +1,7 @@ import { createHash } from "crypto" -import type { LoggerInstance, NextAuthOptions } from "../src" import { NextAuthHandler } from "../src/core" +import type { LoggerInstance, NextAuthOptions } from "../src" +import type { Adapter } from "../src/adapters" export const mockLogger: () => LoggerInstance = () => ({ error: jest.fn(() => {}), @@ -56,3 +57,10 @@ export function createCSRF() { csrf: { value, token, cookie: `next-auth.csrf-token=${value}|${token}` }, } } + +export function mockAdapter(): Adapter { + return { + createVerificationToken: jest.fn(() => {}), + getUserByEmail: jest.fn(() => {}), + } as Adapter +}
packages/next-auth/tsconfig.json+8 −1 modified@@ -19,5 +19,12 @@ "next": ["node_modules/next"] } }, - "exclude": ["./*.js", "./*.d.ts", "config", "**/__tests__", "tests"] + "exclude": [ + "./*.js", + "./*.d.ts", + "config", + "**/__tests__", + "tests", + "coverage" + ] }
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
11- github.com/advisories/GHSA-xv97-c62v-4587ghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2022-35924ghsaADVISORY
- en.wikipedia.org/wiki/Email_addressghsax_refsource_MISCWEB
- github.com/nextauthjs/next-auth/commit/afb1fcdae3cc30445038ef588e491d139b916003ghsax_refsource_MISCWEB
- github.com/nextauthjs/next-auth/security/advisories/GHSA-xv97-c62v-4587ghsax_refsource_CONFIRMWEB
- next-auth.js.org/configuration/callbacksghsax_refsource_MISCWEB
- next-auth.js.org/configuration/initializationghsax_refsource_MISCWEB
- next-auth.js.org/providers/emailghsax_refsource_MISCWEB
- next-auth.js.org/providers/emailghsax_refsource_MISCWEB
- next-auth.js.org/providers/emailghsaWEB
- nodemailer.com/message/addressesghsax_refsource_MISCWEB
News mentions
0No linked articles in our index yet.