CVE-2022-31127: Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting') (CWE-79)
value: NextAuth.js is a complete open source authentication solution for Next.js applications. An attacker can pass a compromised input to the e-mail [signin endpoint](https://next-auth.js.org/getting-started/rest-api#post-apiauthsigninprovider) that contains some malicious HTML, tricking the e-mail server to send it to the user, so they can perform a phishing attack. Eg.: `balazs@email.com, <a href="http://attacker.com">Before signing in, claim your money!</a>`. This was previously sent to `balazs@email.com`, and the content of the email containing a link to the attacker's site was rendered in the HTML. This has been remedied in the following releases, by simply not rendering that e-mail in the HTML, since it should be obvious to the receiver what e-mail they used: next-auth v3 users before version 3.29.8 are impacted. (We recommend upgrading to v4, as v3 is considered unmaintained. next-auth v4 users before version 4.9.0 are impacted. If for some reason you cannot upgrade, the workaround requires you to sanitize the `email` parameter that is passed to `sendVerificationRequest` and rendered in the HTML. If you haven't created a custom `sendVerificationRequest`, you only need to upgrade. Otherwise, make sure to either exclude `email` from the HTML body or efficiently sanitize it.
CWE description: The product does not neutralize or incorrectly neutralizes user-controllable input before it is placed in output that is used as a web page that is served to other users.
CVSS v3 base score: 6.1
Published: 2022-07-06T18:15Z
Repository: https://github.com/nextauthjs/next-auth
Language: TypeScript
Files changed: apps/dev/pages/api/auth/[...nextauth].ts, docs/docs/configuration/options.md, docs/docs/providers/email.md, packages/next-auth/src/core/init.ts, packages/next-auth/src/core/lib/email/signin.ts, packages/next-auth/src/core/routes/signin.ts, packages/next-auth/src/core/types.ts, packages/next-auth/src/providers/email.ts
Commit date: 2022-07-05 16:02:04 +0200
Commit message: feat(providers): allow styling e-mail through `theme` option (#4841)

* fix(core): move email handling

* fix: don' use `replaceAll`

* feat(providers): re-use `theme` for e-mail

* docs: mention `theme` option for email

* fix: don't render user e-mail in the email HTML body

* docs: add missing comma

* refactor: fix lint

* refactor: fix lint
Diff: diff --git a/apps/dev/pages/api/auth/[...nextauth].ts b/apps/dev/pages/api/auth/[...nextauth].ts
index 041ac845..45c5f8f8 100644
--- a/apps/dev/pages/api/auth/[...nextauth].ts
+++ b/apps/dev/pages/api/auth/[...nextauth].ts
@@ -44,11 +44,14 @@ import BoxyHQSAMLProvider from "next-auth/providers/boxyhq-saml"
 //   secret: process.env.FAUNA_SECRET,
 //   domain: process.env.FAUNA_DOMAIN,
 // })
 // const adapter = FaunaAdapter(client)
 export const authOptions: NextAuthOptions = {
-  // adapter,
+  // adapter: {
+  //   getUserByEmail: (email) => ({ id: "1", email, emailVerified: null }),
+  //   createVerificationToken: (token) => token,
+  // } as any,
   providers: [
     // E-mail
     // Start fake e-mail server with `npm run start:email`
     // EmailProvider({
     //   server: {
diff --git a/docs/docs/configuration/options.md b/docs/docs/configuration/options.md
index 02b0c2ea..870472e0 100644
--- a/docs/docs/configuration/options.md
+++ b/docs/docs/configuration/options.md
@@ -364,15 +364,18 @@ If the `debug` level is defined by the user, it will be called regardless of the
 
 Changes the color scheme theme of [pages](/configuration/pages) as well as allows some minor customization. Set `theme.colorScheme` to `"light"`, if you want to force pages to always be light. Set to `"dark"`, if you want to force pages to always be dark. Set to `"auto"`, (or leave this option out) if you want the pages to follow the preferred system theme. (Uses the [prefers-color-scheme](https://developer.mozilla.org/en-US/docs/Web/CSS/@media/prefers-color-scheme) media query.)
 
 In addition, you can define a logo URL in `theme.logo` which will be rendered above the main card in the default signin/signout/error/verify-request pages, as well as a `theme.brandColor` which will affect the accent color of these pages.
 
+The sign-in button's background color will match the `brandColor` and defaults to `"#346df1"`. The text color is `#fff` by default, but if your brand color gives a weak contrast, correct it with the `buttonText` color option.
+
 ```js
 theme: {
   colorScheme: "auto", // "auto" | "dark" | "light"
   brandColor: "", // Hex color code
-  logo: "" // Absolute URL to image
+  logo: "", // Absolute URL to image
+  buttonText: "" // Hex color code
 }
 ```
 
 ---
 
diff --git a/docs/docs/providers/email.md b/docs/docs/providers/email.md
index 0551118a..6ddefe65 100644
--- a/docs/docs/providers/email.md
+++ b/docs/docs/providers/email.md
@@ -122,81 +122,88 @@ providers: [
 ```
 
 The following code shows the complete source for the built-in `sendVerificationRequest()` method:
 
 ```js
-import nodemailer from "nodemailer"
+import { createTransport } from "nodemailer"
 
-async function sendVerificationRequest({
-  identifier: email,
-  url,
-  provider: { server, from },
-}) {
+async function sendVerificationRequest(params) {
+  const { identifier, url, provider, theme } = params
   const { host } = new URL(url)
-  const transport = nodemailer.createTransport(server)
-  await transport.sendMail({
-    to: email,
-    from,
+  // NOTE: You are not required to use `nodemailer`, use whatever you want.
+  const transport = createTransport(provider.server)
+  const result = await transport.sendMail({
+    to: identifier,
+    from: provider.from,
     subject: `Sign in to ${host}`,
     text: text({ url, host }),
-    html: html({ url, host, email }),
+    html: html({ url, host, theme }),
   })
+  const failed = result.rejected.concat(result.pending).filter(Boolean)
+  if (failed.length) {
+    throw new Error(`Email(s) (${failed.join(", ")}) could not be sent`)
+  }
 }
 
-// Email HTML body
-function html({ url, host, email }: Record<"url" | "host" | "email", string>) {
-  // Insert invisible space into domains and email address to prevent both the
-  // email address and the domain from being turned into a hyperlink by email
-  // clients like Outlook and Apple mail, as this is confusing because it seems
-  // like they are supposed to click on their email address to sign in.
-  const escapedEmail = `${email.replace(/\./g, "&#8203;.")}`
-  const escapedHost = `${host.replace(/\./g, "&#8203;.")}`
-
-  // Some simple styling options
-  const backgroundColor = "#f9f9f9"
-  const textColor = "#444444"
-  const mainBackgroundColor = "#ffffff"
-  const buttonBackgroundColor = "#346df1"
-  const buttonBorderColor = "#346df1"
-  const buttonTextColor = "#ffffff"
+/**
+ * Email HTML body
+ * Insert invisible space into domains from being turned into a hyperlink by email
+ * clients like Outlook and Apple mail, as this is confusing because it seems
+ * like they are supposed to click on it to sign in.
+ *
+ * @note We don't add the email address to avoid needing to escape it, if you do, remember to sanitize it!
+ */
+function html(params: { url: string; host: string; theme: Theme }) {
+  const { url, host, theme } = params
+
+  const escapedHost = host.replace(/\./g, "&#8203;.")
+
+  const brandColor = theme.brandColor || "#346df1"
+  const color = {
+    background: "#f9f9f9",
+    text: "#444",
+    mainBackground: "#fff",
+    buttonBackground: brandColor,
+    buttonBorder: brandColor,
+    buttonText: theme.buttonText || "#fff",
+  }
 
   return `
-<body style="background: ${backgroundColor};">
-  <table width="100%" border="0" cellspacing="0" cellpadding="0">
+<body style="background: ${color.background};">
+  <table width="100%" border="0" cellspacing="20" cellpadding="0"
+    style="background: ${color.mainBackground}; max-width: 600px; margin: auto; border-radius: 10px;">
     <tr>
-      <td align="center" style="padding: 10px 0px 20px 0px; font-size: 22px; font-family: Helvetica, Arial, sans-serif; color: ${textColor};">
-        <strong>${escapedHost}</strong>
-      </td>
-    </tr>
-  </table>
-  <table width="100%" border="0" cellspacing="20" cellpadding="0" style="background: ${mainBackgroundColor}; max-width: 600px; margin: auto; border-radius: 10px;">
-    <tr>
-      <td align="center" style="padding: 10px 0px 0px 0px; font-size: 18px; font-family: Helvetica, Arial, sans-serif; color: ${textColor};">
-        Sign in as <strong>${escapedEmail}</strong>
+      <td align="center"
+        style="padding: 10px 0px; font-size: 22px; font-family: Helvetica, Arial, sans-serif; color: ${color.text};">
+        Sign in to <strong>${escapedHost}</strong>
       </td>
     </tr>
     <tr>
       <td align="center" style="padding: 20px 0;">
         <table border="0" cellspacing="0" cellpadding="0">
           <tr>
-            <td align="center" style="border-radius: 5px;" bgcolor="${buttonBackgroundColor}"><a href="${url}" target="_blank" style="font-size: 18px; font-family: Helvetica, Arial, sans-serif; color: ${buttonTextColor}; text-decoration: none; border-radius: 5px; padding: 10px 20px; border: 1px solid ${buttonBorderColor}; display: inline-block; font-weight: bold;">Sign in</a></td>
+            <td align="center" style="border-radius: 5px;" bgcolor="${color.buttonBackground}"><a href="${url}"
+                target="_blank"
+                style="font-size: 18px; font-family: Helvetica, Arial, sans-serif; color: ${color.buttonText}; text-decoration: none; border-radius: 5px; padding: 10px 20px; border: 1px solid ${color.buttonBorder}; display: inline-block; font-weight: bold;">Sign
+                in</a></td>
           </tr>
         </table>
       </td>
     </tr>
     <tr>
-      <td align="center" style="padding: 0px 0px 10px 0px; font-size: 16px; line-height: 22px; font-family: Helvetica, Arial, sans-serif; color: ${textColor};">
+      <td align="center"
+        style="padding: 0px 0px 10px 0px; font-size: 16px; line-height: 22px; font-family: Helvetica, Arial, sans-serif; color: ${color.text};">
         If you did not request this email you can safely ignore it.
       </td>
     </tr>
   </table>
 </body>
 `
 }
 
-// Email Text body (fallback for email clients that don't render HTML, e.g. feature phones)
-function text({ url, host }: Record<"url" | "host", string>) {
+/** Email Text body (fallback for email clients that don't render HTML, e.g. feature phones) */
+function text({ url, host }: { url: string; host: string }) {
   return `Sign in to ${host}\n${url}\n\n`
 }
 ```
 
 :::tip
diff --git a/packages/next-auth/src/core/init.ts b/packages/next-auth/src/core/init.ts
index 5d2e4895..38a38acc 100644
--- a/packages/next-auth/src/core/init.ts
+++ b/packages/next-auth/src/core/init.ts
@@ -60,10 +60,11 @@ export async function init({
     pages: {},
     theme: {
       colorScheme: "auto",
       logo: "",
       brandColor: "",
+      buttonText: "",
     },
     // Custom options override defaults
     ...userOptions,
     // These computed settings can have values in userOptions but we override them
     // and are request-specific.
diff --git a/packages/next-auth/src/core/lib/email/signin.ts b/packages/next-auth/src/core/lib/email/signin.ts
index ae4fa831..cd58f926 100644
--- a/packages/next-auth/src/core/lib/email/signin.ts
+++ b/packages/next-auth/src/core/lib/email/signin.ts
@@ -8,11 +8,11 @@ import type { InternalOptions } from "../../types"
  */
 export default async function email(
   identifier: string,
   options: InternalOptions<"email">
 ) {
-  const { url, adapter, provider, logger, callbackUrl } = options
+  const { url, adapter, provider, logger, callbackUrl, theme } = options
 
   // Generate token
   const token =
     (await provider.generateVerificationToken?.()) ??
     randomBytes(32).toString("hex")
@@ -40,10 +40,11 @@ export default async function email(
       identifier,
       token,
       expires,
       url: _url,
       provider,
+      theme,
     })
   } catch (error) {
     logger.error("SEND_VERIFICATION_EMAIL_ERROR", {
       identifier,
       url,
diff --git a/packages/next-auth/src/core/routes/signin.ts b/packages/next-auth/src/core/routes/signin.ts
index 0bd48731..cd154701 100644
--- a/packages/next-auth/src/core/routes/signin.ts
+++ b/packages/next-auth/src/core/routes/signin.ts
@@ -35,23 +35,14 @@ export default async function signin(params: {
      * (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.
      */
-    let email = body?.email?.toLowerCase()
+    const email = body?.email?.toLowerCase()
 
     if (!email) return { redirect: `${url}/error?error=EmailSignin` }
 
-    email = email
-      .split(",")[0]
-      .trim()
-      .replaceAll("&", "&amp;")
-      .replaceAll("<", "&lt;")
-      .replaceAll(">", "&gt;")
-      .replaceAll('"', "&quot;")
-      .replaceAll("'", "&#x27;")
-
     // Verified in `assertConfig`
     // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
     const { getUserByEmail } = adapter!
     // If is an existing user return a user object (otherwise use placeholder)
     const user: User = (email ? await getUserByEmail(email) : null) ?? {
diff --git a/packages/next-auth/src/core/types.ts b/packages/next-auth/src/core/types.ts
index 5f7590b4..a66800a7 100644
--- a/packages/next-auth/src/core/types.ts
+++ b/packages/next-auth/src/core/types.ts
@@ -215,10 +215,11 @@ export interface NextAuthOptions {
  */
 export interface Theme {
   colorScheme: "auto" | "dark" | "light"
   logo?: string
   brandColor?: string
+  buttonText?: string
 }
 
 /**
  * Different tokens returned by OAuth Providers.
  * Some of them are available with different casing,
diff --git a/packages/next-auth/src/providers/email.ts b/packages/next-auth/src/providers/email.ts
index 185fcb67..89aca9d5 100644
--- a/packages/next-auth/src/providers/email.ts
+++ b/packages/next-auth/src/providers/email.ts
@@ -1,10 +1,20 @@
 import { createTransport } from "nodemailer"
 
 import type { CommonProviderOptions } from "."
 import type { Options as SMTPConnectionOptions } from "nodemailer/lib/smtp-connection"
 import type { Awaitable } from ".."
+import type { Theme } from "../core/types"
+
+export interface SendVerificationRequestParams {
+  identifier: string
+  url: string
+  expires: Date
+  provider: EmailConfig
+  token: string
+  theme: Theme
+}
 
 export interface EmailConfig extends CommonProviderOptions {
   type: "email"
   // TODO: Make use of https://www.typescriptlang.org/docs/handbook/2/template-literal-types.html
   server: string | SMTPConnectionOptions
@@ -14,17 +24,14 @@ export interface EmailConfig extends CommonProviderOptions {
    * How long until the e-mail can be used to log the user in,
    * in seconds. Defaults to 1 day
    * @default 86400
    */
   maxAge?: number
-  sendVerificationRequest: (params: {
-    identifier: string
-    url: string
-    expires: Date
-    provider: EmailConfig
-    token: string
-  }) => Awaitable<void>
+  /** [Documentation](https://next-auth.js.org/providers/email#customizing-emails) */
+  sendVerificationRequest: (
+    params: SendVerificationRequestParams
+  ) => Awaitable<void>
   /**
    * By default, we are generating a random verification token.
    * You can make it predictable or modify it as you like with this method.
    * @example
    * ```js
@@ -54,89 +61,92 @@ export default function Email(options: EmailUserConfig): EmailConfig {
   return {
     id: "email",
     type: "email",
     name: "Email",
     // Server can be an SMTP connection string or a nodemailer config object
-    server: {
-      host: "localhost",
-      port: 25,
-      auth: {
-        user: "",
-        pass: "",
-      },
-    },
+    server: { host: "localhost", port: 25, auth: { user: "", pass: "" } },
     from: "NextAuth <no-reply@example.com>",
     maxAge: 24 * 60 * 60,
-    async sendVerificationRequest({
-      identifier: email,
-      url,
-      provider: { server, from },
-    }) {
+    async sendVerificationRequest(params) {
+      const { identifier, url, provider, theme } = params
       const { host } = new URL(url)
-      const transport = createTransport(server)
-      await transport.sendMail({
-        to: email,
-        from,
+      const transport = createTransport(provider.server)
+      const result = await transport.sendMail({
+        to: identifier,
+        from: provider.from,
         subject: `Sign in to ${host}`,
         text: text({ url, host }),
-        html: html({ url, host, email }),
+        html: html({ url, host, theme }),
       })
+      const failed = result.rejected.concat(result.pending).filter(Boolean)
+      if (failed.length) {
+        throw new Error(`Email(s) (${failed.join(", ")}) could not be sent`)
+      }
     },
     options,
   }
 }
 
-// Email HTML body
-function html({ url, host, email }: Record<"url" | "host" | "email", string>) {
-  // Insert invisible space into domains and email address to prevent both the
-  // email address and the domain from being turned into a hyperlink by email
-  // clients like Outlook and Apple mail, as this is confusing because it seems
-  // like they are supposed to click on their email address to sign in.
-  const escapedEmail = `${email.replace(/\./g, "&#8203;.")}`
-  const escapedHost = `${host.replace(/\./g, "&#8203;.")}`
+/**
+ * Email HTML body
+ * Insert invisible space into domains from being turned into a hyperlink by email
+ * clients like Outlook and Apple mail, as this is confusing because it seems
+ * like they are supposed to click on it to sign in.
+ *
+ * @note We don't add the email address to avoid needing to escape it, if you do, remember to sanitize it!
+ */
+function html(params: { url: string; host: string; theme: Theme }) {
+  const { url, host, theme } = params
+
+  const escapedHost = host.replace(/\./g, "&#8203;.")
+
+  // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
+  const brandColor = theme.brandColor || "#346df1"
+  // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
+  const buttonText = theme.buttonText || "#fff"
 
-  // Some simple styling options
-  const backgroundColor = "#f9f9f9"
-  const textColor = "#444444"
-  const mainBackgroundColor = "#ffffff"
-  const buttonBackgroundColor = "#346df1"
-  const buttonBorderColor = "#346df1"
-  const buttonTextColor = "#ffffff"
+  const color = {
+    background: "#f9f9f9",
+    text: "#444",
+    mainBackground: "#fff",
+    buttonBackground: brandColor,
+    buttonBorder: brandColor,
+    buttonText,
+  }
 
   return `
-<body style="background: ${backgroundColor};">
-  <table width="100%" border="0" cellspacing="0" cellpadding="0">
-    <tr>
-      <td align="center" style="padding: 10px 0px 20px 0px; font-size: 22px; font-family: Helvetica, Arial, sans-serif; color: ${textColor};">
-        <strong>${escapedHost}</strong>
-      </td>
-    </tr>
-  </table>
-  <table width="100%" border="0" cellspacing="20" cellpadding="0" style="background: ${mainBackgroundColor}; max-width: 600px; margin: auto; border-radius: 10px;">
+<body style="background: ${color.background};">
+  <table width="100%" border="0" cellspacing="20" cellpadding="0"
+    style="background: ${color.mainBackground}; max-width: 600px; margin: auto; border-radius: 10px;">
     <tr>
-      <td align="center" style="padding: 10px 0px 0px 0px; font-size: 18px; font-family: Helvetica, Arial, sans-serif; color: ${textColor};">
-        Sign in as <strong>${escapedEmail}</strong>
+      <td align="center"
+        style="padding: 10px 0px; font-size: 22px; font-family: Helvetica, Arial, sans-serif; color: ${color.text};">
+        Sign in to <strong>${escapedHost}</strong>
       </td>
     </tr>
     <tr>
       <td align="center" style="padding: 20px 0;">
         <table border="0" cellspacing="0" cellpadding="0">
           <tr>
-            <td align="center" style="border-radius: 5px;" bgcolor="${buttonBackgroundColor}"><a href="${url}" target="_blank" style="font-size: 18px; font-family: Helvetica, Arial, sans-serif; color: ${buttonTextColor}; text-decoration: none; border-radius: 5px; padding: 10px 20px; border: 1px solid ${buttonBorderColor}; display: inline-block; font-weight: bold;">Sign in</a></td>
+            <td align="center" style="border-radius: 5px;" bgcolor="${color.buttonBackground}"><a href="${url}"
+                target="_blank"
+                style="font-size: 18px; font-family: Helvetica, Arial, sans-serif; color: ${color.buttonText}; text-decoration: none; border-radius: 5px; padding: 10px 20px; border: 1px solid ${color.buttonBorder}; display: inline-block; font-weight: bold;">Sign
+                in</a></td>
           </tr>
         </table>
       </td>
     </tr>
     <tr>
-      <td align="center" style="padding: 0px 0px 10px 0px; font-size: 16px; line-height: 22px; font-family: Helvetica, Arial, sans-serif; color: ${textColor};">
+      <td align="center"
+        style="padding: 0px 0px 10px 0px; font-size: 16px; line-height: 22px; font-family: Helvetica, Arial, sans-serif; color: ${color.text};">
         If you did not request this email you can safely ignore it.
       </td>
     </tr>
   </table>
 </body>
 `
 }
 
-// Email Text body (fallback for email clients that don't render HTML, e.g. feature phones)
-function text({ url, host }: Record<"url" | "host", string>) {
+/** Email Text body (fallback for email clients that don't render HTML, e.g. feature phones) */
+function text({ url, host }: { url: string; host: string }) {
   return `Sign in to ${host}\n${url}\n\n`
 }
diff --git a/packages/next-auth/tests/email.test.ts b/packages/next-auth/tests/email.test.ts
deleted file mode 100644
index 43c3973f..00000000
--- a/packages/next-auth/tests/email.test.ts
+++ /dev/null
@@ -1,59 +0,0 @@
-import { createCSRF, handler } from "./lib"
-import EmailProvider from "../src/providers/email"
-
-const originalEmail = "balazs@email.com"
-
-test.each([
-  [originalEmail, `,<a href="example.com">Click here!</a>`],
-  [originalEmail, ""],
-])("Sanitize email", async (emailOriginal, emailCompromised) => {
-  const sendEmail = jest.fn()
-
-  const { secret, csrf } = createCSRF()
-
-  const email = {
-    original: emailOriginal,
-    compromised: `${emailOriginal}${emailCompromised}`,
-  }
-
-  const { res } = await handler(
-    {
-      providers: [EmailProvider({ sendVerificationRequest: sendEmail })],
-      adapter: {
-        getUserByEmail: (email) => ({ id: "1", email, emailVerified: null }),
-        createVerificationToken: (token) => token,
-      } as any,
-      secret,
-    },
-    {
-      prod: true,
-      path: "signin/email",
-      requestInit: {
-        method: "POST",
-        body: JSON.stringify({
-          email: email.compromised,
-          csrfToken: csrf.value,
-        }),
-        headers: { "Content-Type": "application/json", Cookie: csrf.cookie },
-      },
-    }
-  )
-
-  if (!emailCompromised) {
-    expect(res.redirect).toBe(
-      "http://localhost:3000/api/auth/verify-request?provider=email&type=email"
-    )
-    expect(sendEmail).toHaveBeenCalledWith(
-      expect.objectContaining({
-        identifier: email.original,
-        token: expect.any(String),
-      })
-    )
-  } else {
-    expect(res.redirect).not.toContain("error=EmailSignin")
-
-    const emailTo = sendEmail.mock.calls[0][0].identifier
-    expect(emailTo).not.toBe(email.compromised)
-    expect(emailTo).toBe(email.original)
-  }
-})

Vulnerable code:   // adapter,
  logo: "" // Absolute URL to image
import nodemailer from "nodemailer"
async function sendVerificationRequest({
  identifier: email,
  url,
  provider: { server, from },
}) {
  const transport = nodemailer.createTransport(server)
  await transport.sendMail({
    to: email,
    from,
    html: html({ url, host, email }),
// Email HTML body
function html({ url, host, email }: Record<"url" | "host" | "email", string>) {
  // Insert invisible space into domains and email address to prevent both the
  // email address and the domain from being turned into a hyperlink by email
  // clients like Outlook and Apple mail, as this is confusing because it seems
  // like they are supposed to click on their email address to sign in.
  const escapedEmail = `${email.replace(/\./g, "&#8203;.")}`
  const escapedHost = `${host.replace(/\./g, "&#8203;.")}`

  // Some simple styling options
  const backgroundColor = "#f9f9f9"
  const textColor = "#444444"
  const mainBackgroundColor = "#ffffff"
  const buttonBackgroundColor = "#346df1"
  const buttonBorderColor = "#346df1"
  const buttonTextColor = "#ffffff"
<body style="background: ${backgroundColor};">
  <table width="100%" border="0" cellspacing="0" cellpadding="0">
      <td align="center" style="padding: 10px 0px 20px 0px; font-size: 22px; font-family: Helvetica, Arial, sans-serif; color: ${textColor};">
        <strong>${escapedHost}</strong>
      </td>
    </tr>
  </table>
  <table width="100%" border="0" cellspacing="20" cellpadding="0" style="background: ${mainBackgroundColor}; max-width: 600px; margin: auto; border-radius: 10px;">
    <tr>
      <td align="center" style="padding: 10px 0px 0px 0px; font-size: 18px; font-family: Helvetica, Arial, sans-serif; color: ${textColor};">
        Sign in as <strong>${escapedEmail}</strong>
            <td align="center" style="border-radius: 5px;" bgcolor="${buttonBackgroundColor}"><a href="${url}" target="_blank" style="font-size: 18px; font-family: Helvetica, Arial, sans-serif; color: ${buttonTextColor}; text-decoration: none; border-radius: 5px; padding: 10px 20px; border: 1px solid ${buttonBorderColor}; display: inline-block; font-weight: bold;">Sign in</a></td>
      <td align="center" style="padding: 0px 0px 10px 0px; font-size: 16px; line-height: 22px; font-family: Helvetica, Arial, sans-serif; color: ${textColor};">
// Email Text body (fallback for email clients that don't render HTML, e.g. feature phones)
function text({ url, host }: Record<"url" | "host", string>) {
  const { url, adapter, provider, logger, callbackUrl } = options
    let email = body?.email?.toLowerCase()
    email = email
      .split(",")[0]
      .trim()
      .replaceAll("&", "&amp;")
      .replaceAll("<", "&lt;")
      .replaceAll(">", "&gt;")
      .replaceAll('"', "&quot;")
      .replaceAll("'", "&#x27;")

  sendVerificationRequest: (params: {
    identifier: string
    url: string
    expires: Date
    provider: EmailConfig
    token: string
  }) => Awaitable<void>
    server: {
      host: "localhost",
      port: 25,
      auth: {
        user: "",
        pass: "",
      },
    },
    async sendVerificationRequest({
      identifier: email,
      url,
      provider: { server, from },
    }) {
      const transport = createTransport(server)
      await transport.sendMail({
        to: email,
        from,
        html: html({ url, host, email }),
// Email HTML body
function html({ url, host, email }: Record<"url" | "host" | "email", string>) {
  // Insert invisible space into domains and email address to prevent both the
  // email address and the domain from being turned into a hyperlink by email
  // clients like Outlook and Apple mail, as this is confusing because it seems
  // like they are supposed to click on their email address to sign in.
  const escapedEmail = `${email.replace(/\./g, "&#8203;.")}`
  const escapedHost = `${host.replace(/\./g, "&#8203;.")}`
  // Some simple styling options
  const backgroundColor = "#f9f9f9"
  const textColor = "#444444"
  const mainBackgroundColor = "#ffffff"
  const buttonBackgroundColor = "#346df1"
  const buttonBorderColor = "#346df1"
  const buttonTextColor = "#ffffff"
<body style="background: ${backgroundColor};">
  <table width="100%" border="0" cellspacing="0" cellpadding="0">
    <tr>
      <td align="center" style="padding: 10px 0px 20px 0px; font-size: 22px; font-family: Helvetica, Arial, sans-serif; color: ${textColor};">
        <strong>${escapedHost}</strong>
      </td>
    </tr>
  </table>
  <table width="100%" border="0" cellspacing="20" cellpadding="0" style="background: ${mainBackgroundColor}; max-width: 600px; margin: auto; border-radius: 10px;">
      <td align="center" style="padding: 10px 0px 0px 0px; font-size: 18px; font-family: Helvetica, Arial, sans-serif; color: ${textColor};">
        Sign in as <strong>${escapedEmail}</strong>
            <td align="center" style="border-radius: 5px;" bgcolor="${buttonBackgroundColor}"><a href="${url}" target="_blank" style="font-size: 18px; font-family: Helvetica, Arial, sans-serif; color: ${buttonTextColor}; text-decoration: none; border-radius: 5px; padding: 10px 20px; border: 1px solid ${buttonBorderColor}; display: inline-block; font-weight: bold;">Sign in</a></td>
      <td align="center" style="padding: 0px 0px 10px 0px; font-size: 16px; line-height: 22px; font-family: Helvetica, Arial, sans-serif; color: ${textColor};">
// Email Text body (fallback for email clients that don't render HTML, e.g. feature phones)
function text({ url, host }: Record<"url" | "host", string>) {
import { createCSRF, handler } from "./lib"
import EmailProvider from "../src/providers/email"

const originalEmail = "balazs@email.com"

test.each([
  [originalEmail, `,<a href="example.com">Click here!</a>`],
  [originalEmail, ""],
])("Sanitize email", async (emailOriginal, emailCompromised) => {
  const sendEmail = jest.fn()

  const { secret, csrf } = createCSRF()

  const email = {
    original: emailOriginal,
    compromised: `${emailOriginal}${emailCompromised}`,
  }

  const { res } = await handler(
    {
      providers: [EmailProvider({ sendVerificationRequest: sendEmail })],
      adapter: {
        getUserByEmail: (email) => ({ id: "1", email, emailVerified: null }),
        createVerificationToken: (token) => token,
      } as any,
      secret,
    },
    {
      prod: true,
      path: "signin/email",
      requestInit: {
        method: "POST",
        body: JSON.stringify({
          email: email.compromised,
          csrfToken: csrf.value,
        }),
        headers: { "Content-Type": "application/json", Cookie: csrf.cookie },
      },
    }
  )

  if (!emailCompromised) {
    expect(res.redirect).toBe(
      "http://localhost:3000/api/auth/verify-request?provider=email&type=email"
    )
    expect(sendEmail).toHaveBeenCalledWith(
      expect.objectContaining({
        identifier: email.original,
        token: expect.any(String),
      })
    )
  } else {
    expect(res.redirect).not.toContain("error=EmailSignin")

    const emailTo = sendEmail.mock.calls[0][0].identifier
    expect(emailTo).not.toBe(email.compromised)
    expect(emailTo).toBe(email.original)
  }
})
Fixed code:   // adapter: {
  //   getUserByEmail: (email) => ({ id: "1", email, emailVerified: null }),
  //   createVerificationToken: (token) => token,
  // } as any,
The sign-in button's background color will match the `brandColor` and defaults to `"#346df1"`. The text color is `#fff` by default, but if your brand color gives a weak contrast, correct it with the `buttonText` color option.

  logo: "", // Absolute URL to image
  buttonText: "" // Hex color code
import { createTransport } from "nodemailer"
async function sendVerificationRequest(params) {
  const { identifier, url, provider, theme } = params
  // NOTE: You are not required to use `nodemailer`, use whatever you want.
  const transport = createTransport(provider.server)
  const result = await transport.sendMail({
    to: identifier,
    from: provider.from,
    html: html({ url, host, theme }),
  const failed = result.rejected.concat(result.pending).filter(Boolean)
  if (failed.length) {
    throw new Error(`Email(s) (${failed.join(", ")}) could not be sent`)
  }
/**
 * Email HTML body
 * Insert invisible space into domains from being turned into a hyperlink by email
 * clients like Outlook and Apple mail, as this is confusing because it seems
 * like they are supposed to click on it to sign in.
 *
 * @note We don't add the email address to avoid needing to escape it, if you do, remember to sanitize it!
 */
function html(params: { url: string; host: string; theme: Theme }) {
  const { url, host, theme } = params

  const escapedHost = host.replace(/\./g, "&#8203;.")

  const brandColor = theme.brandColor || "#346df1"
  const color = {
    background: "#f9f9f9",
    text: "#444",
    mainBackground: "#fff",
    buttonBackground: brandColor,
    buttonBorder: brandColor,
    buttonText: theme.buttonText || "#fff",
  }
<body style="background: ${color.background};">
  <table width="100%" border="0" cellspacing="20" cellpadding="0"
    style="background: ${color.mainBackground}; max-width: 600px; margin: auto; border-radius: 10px;">
      <td align="center"
        style="padding: 10px 0px; font-size: 22px; font-family: Helvetica, Arial, sans-serif; color: ${color.text};">
        Sign in to <strong>${escapedHost}</strong>
            <td align="center" style="border-radius: 5px;" bgcolor="${color.buttonBackground}"><a href="${url}"
                target="_blank"
                style="font-size: 18px; font-family: Helvetica, Arial, sans-serif; color: ${color.buttonText}; text-decoration: none; border-radius: 5px; padding: 10px 20px; border: 1px solid ${color.buttonBorder}; display: inline-block; font-weight: bold;">Sign
                in</a></td>
      <td align="center"
        style="padding: 0px 0px 10px 0px; font-size: 16px; line-height: 22px; font-family: Helvetica, Arial, sans-serif; color: ${color.text};">
/** Email Text body (fallback for email clients that don't render HTML, e.g. feature phones) */
function text({ url, host }: { url: string; host: string }) {
      buttonText: "",
  const { url, adapter, provider, logger, callbackUrl, theme } = options
      theme,
    const email = body?.email?.toLowerCase()
  buttonText?: string
import type { Theme } from "../core/types"

export interface SendVerificationRequestParams {
  identifier: string
  url: string
  expires: Date
  provider: EmailConfig
  token: string
  theme: Theme
}
  /** [Documentation](https://next-auth.js.org/providers/email#customizing-emails) */
  sendVerificationRequest: (
    params: SendVerificationRequestParams
  ) => Awaitable<void>
    server: { host: "localhost", port: 25, auth: { user: "", pass: "" } },
    async sendVerificationRequest(params) {
      const { identifier, url, provider, theme } = params
      const transport = createTransport(provider.server)
      const result = await transport.sendMail({
        to: identifier,
        from: provider.from,
        html: html({ url, host, theme }),
      const failed = result.rejected.concat(result.pending).filter(Boolean)
      if (failed.length) {
        throw new Error(`Email(s) (${failed.join(", ")}) could not be sent`)
      }
/**
 * Email HTML body
 * Insert invisible space into domains from being turned into a hyperlink by email
 * clients like Outlook and Apple mail, as this is confusing because it seems
 * like they are supposed to click on it to sign in.
 *
 * @note We don't add the email address to avoid needing to escape it, if you do, remember to sanitize it!
 */
function html(params: { url: string; host: string; theme: Theme }) {
  const { url, host, theme } = params

  const escapedHost = host.replace(/\./g, "&#8203;.")

  // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
  const brandColor = theme.brandColor || "#346df1"
  // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
  const buttonText = theme.buttonText || "#fff"
  const color = {
    background: "#f9f9f9",
    text: "#444",
    mainBackground: "#fff",
    buttonBackground: brandColor,
    buttonBorder: brandColor,
    buttonText,
  }
<body style="background: ${color.background};">
  <table width="100%" border="0" cellspacing="20" cellpadding="0"
    style="background: ${color.mainBackground}; max-width: 600px; margin: auto; border-radius: 10px;">
      <td align="center"
        style="padding: 10px 0px; font-size: 22px; font-family: Helvetica, Arial, sans-serif; color: ${color.text};">
        Sign in to <strong>${escapedHost}</strong>
            <td align="center" style="border-radius: 5px;" bgcolor="${color.buttonBackground}"><a href="${url}"
                target="_blank"
                style="font-size: 18px; font-family: Helvetica, Arial, sans-serif; color: ${color.buttonText}; text-decoration: none; border-radius: 5px; padding: 10px 20px; border: 1px solid ${color.buttonBorder}; display: inline-block; font-weight: bold;">Sign
                in</a></td>
      <td align="center"
        style="padding: 0px 0px 10px 0px; font-size: 16px; line-height: 22px; font-family: Helvetica, Arial, sans-serif; color: ${color.text};">
/** Email Text body (fallback for email clients that don't render HTML, e.g. feature phones) */
function text({ url, host }: { url: string; host: string }) {