VYPR
Critical severityNVD Advisory· Published Aug 2, 2022· Updated Apr 23, 2025

Verification requests (magic link) sent to unwanted emails

CVE-2022-35924

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.

PackageAffected versionsPatched versions
next-authnpm
>= 4.0.0, < 4.10.34.10.3
next-authnpm
< 3.29.103.29.10

Affected products

1

Patches

1
afb1fcdae3cc

fix(providers): add `normalizeIdentifier` to EmailProvider

https://github.com/nextauthjs/next-authBalázs OrbánAug 1, 2022via ghsa
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

News mentions

0

No linked articles in our index yet.