Problem with TwoFactor backup codes #2664

Closed
opened 2026-03-13 10:11:57 -05:00 by GiteaMirror · 3 comments
Owner

Originally created by @MarcJose on GitHub (Jan 7, 2026).

Is this suited for github?

  • Yes, this is suited for github

To Reproduce

Hello,

I recently added the twoFactor plugin to our credentials login.
So far it seems to work very well, so users can set it up in their profile using either a QR code or the secret directly and they can log in using the code.
Now I wanted to extend it to allow backup codes but this seems to run into problems.
The codes are generated and stored in the database apparently, but when trying to use the code during login I get an error Invalid backup code.

The login is executed by running:

await authClient.twoFactor.verifyBackupCode({
  code: backupCode,
})

The client and server auth configs are attached below and my prisma schema looks like this:

model User {
  id               String    @id @default(cuid())
  name             String?
  email            String?   @unique
  emailVerified    Boolean   @default(false)
  image            String?   @db.LongText
  password         String?
  twoFactorEnabled Boolean   @default(false)
  createdAt        DateTime  @default(now())
  updatedAt        DateTime? @updatedAt

  accounts           Account[]
  Authenticators     Authenticator[]
  sessions           Session[]
  TwoFactor          TwoFactor[]

  @@map("user")
}

model Account {
  id                   String    @id @default(cuid())
  userId               String
  type                 String?
  providerId           String    @map("provider")
  accountId            String    @map("providerAccountId")
  refreshToken         String?   @map("refresh_token")
  accessToken          String?   @map("access_token") @db.Text
  accessTokenExpiresAt DateTime? @map("expires_at")
  tokenType            String?   @map("token_type")
  scope                String?
  idToken              String?   @map("id_token") @db.Text
  sessionState         String?   @map("session_state")
  password             String?

  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt

  user User @relation(fields: [userId], references: [id], onDelete: Cascade)

  @@unique([providerId, accountId])
  @@map("account")
}

model Session {
  id        String   @id @default(cuid())
  token     String   @unique @map("sessionToken")
  userId    String
  expiresAt DateTime @map("expires")
  ipAddress String?
  userAgent String?  @db.Text

  user User @relation(fields: [userId], references: [id], onDelete: Cascade)

  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt

  @@map("session")
}

model VerificationToken {
  identifier String
  token      String
  expires    DateTime

  @@id([identifier, token])
  @@map("verificationToken")
}

model Verification {
  id         String   @id @default(cuid())
  identifier String
  value      String   @db.Text
  expiresAt  DateTime
  createdAt  DateTime @default(now())
  updatedAt  DateTime @updatedAt

  @@map("verification")
}

model Authenticator {
  credentialID         String  @unique
  userId               String
  providerAccountId    String
  credentialPublicKey  String
  counter              Int
  credentialDeviceType String
  credentialBackedUp   Boolean
  transports           String?

  user User @relation(fields: [userId], references: [id], onDelete: Cascade)

  @@id([userId, credentialID])
  @@map("authenticator")
}

model TwoFactor {
  id          String @id @default(cuid())
  secret      String
  backupCodes String @db.Text
  userId      String @unique

  user User @relation(fields: [userId], references: [id], onDelete: Cascade)

  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt

  @@map("twoFactor")
}

Current vs. Expected behavior

Following the configuration from above I would expect that I can log in using the backup codes for the TwoFactor Plugin.

What version of Better Auth are you using?

1.4.10

System info

{
  "system": {
    "platform": "linux",
    "arch": "x64",
    "version": "#1 SMP PREEMPT_DYNAMIC Fri, 02 Jan 2026 17:52:55 +0000",
    "release": "6.18.3-arch1-1",
    "cpuCount": 16,
    "cpuModel": "AMD Ryzen 7 9800X3D 8-Core Processor",
    "totalMemory": "60.41 GB",
    "freeMemory": "42.18 GB"
  },
  "node": {
    "version": "v24.12.0",
    "env": "development"
  },
  "packageManager": {
    "name": "npm",
    "version": "11.6.2"
  },
  "frameworks": [
    {
      "name": "next",
      "version": "16.1.1"
    },
    {
      "name": "react",
      "version": "19.2.3"
    }
  ],
  "databases": [
    {
      "name": "@prisma/client",
      "version": "7.2.0"
    }
  ],
  "betterAuth": {
    "version": "1.4.10",
    "config": null
  }
}

Which area(s) are affected? (Select all that apply)

Client

Auth config (if applicable)

auth-config.ts:


import 'server-only'

import { betterAuth } from 'better-auth'
import { prismaAdapter } from 'better-auth/adapters/prisma'
import { createAuthMiddleware, twoFactor } from 'better-auth/plugins'

import { prisma } from '@/lib/prisma'

/**
 * Builds dynamic Better Auth configuration.
 * This function can be called on-demand to get the latest configuration.
 * @returns Promise resolving to Better Auth instance with current provider configurations.
 */
export async function buildBetterAuth() {
  /**
   * Get domain configuration for cookies based on environment.
   * @returns Domain setting for cookies or undefined to use default behavior.
   */
  const getDomainForCookies = () => {
    try {
      if (!process.env.BASE_URL) return undefined

      const url = new URL(process.env.BASE_URL)
      const hostname = url.hostname

      // For localhost or IP addresses, don't set a domain (use browser default)
      if (hostname === 'localhost' || RegExp(/^\d+\.\d+\.\d+\.\d+$/).exec(hostname)) {
        return undefined
      }

      // For production domains, prepend with '.' to include subdomains
      return process.env.NODE_ENV === 'production' ? `.${hostname}` : undefined
    } catch {
      return undefined
    }
  }

  try {
    // Get cookie domain setting
    const domain = getDomainForCookies()

    // Important: When running a production build locally on HTTP, make sure secure cookies are not enabled
    const isLocalEnvironment =
      process.env.BASE_URL?.includes('localhost') || process.env.BASE_URL?.includes('127.0.0.1')

    // Only use secure cookies when:
    //   1. in production AND not on localhost, OR
    //   2. explicitly using HTTPS
    const useSecureCookies =
      (process.env.NODE_ENV === 'production' && !isLocalEnvironment) ||
      process.env.BASE_URL?.startsWith('https://') ||
      false

    return betterAuth({
      database: prismaAdapter(prisma, {
        provider: 'mysql',
      }),

      baseURL: process.env.BASE_URL || process.env.BETTER_AUTH_URL || process.env.NEXT_AUTH_URL,
      secret: process.env.BETTER_AUTH_SECRET || process.env.NEXTAUTH_SECRET,

      user: {
        additionalFields: {
          emailVerified: {
            type: 'boolean',
            required: false,
            defaultValue: true,
          },
        },
      },

      emailAndPassword: {
        enabled: true,
        minPasswordLength: 12,
        maxPasswordLength: 128,
        requireEmailVerification: false,
      },

      plugins: [
        twoFactor({
          issuer: process.env.BASE_URL
            ? new URL(process.env.BASE_URL).hostname
            : process.env.APP_TITLE,
          backupCodes: {
            amount: 10,
            length: 10,
          },
        }),
      ],

      session: {
        expiresIn: 259200, // 3 days
        updateAge: 3600, // 1 hour
        cookieCache: {
          enabled: false, // Disabled due to large profile images in Better Auth's internal session data
        },
      },

      advanced: {
        cookiePrefix: 'next-auth', // Keep same cookie prefix for compatibility with NextAuth
        useSecureCookies,
        crossSubDomainCookies: {
          enabled: !!domain,
          domain,
        },
        // Disable state validation errors in development
        disableCSRFCheck: process.env.NODE_ENV === 'development',
      },

      hooks: {
        after: createAuthMiddleware(async (ctx) => {
          // Only run on callback completion, not every get-session call
          if (!ctx.path?.includes('/callback/')) {
            return
          }

          try {
            // Get session from context
            const session = ctx.context.newSession || ctx.context.session
            if (!session || !session.user || !session.user.id) {
              return
            }

            // Query database to get user and account data
            const user = await prisma.user.findUnique({
              where: { id: session.user.id },
              include: {
                accounts: true,
              },
            })

            if (!user || !user.accounts || user.accounts.length === 0) {
              return
            }

            // Get the most recently created account (the one just used for sign-in)
            const account = user.accounts.toSorted(
              (a, b) => b.createdAt.getTime() - a.createdAt.getTime(),
            )[0]

            if (!account) {
              return
            }

            // Update last logged in flag for all successful logins
            try {
              await prisma.userData.upsert({
                create: {
                  userId: user.id,
                  key: 'last_logged_in',
                  value: new Date().toUTCString(),
                },
                update: {
                  value: new Date().toUTCString(),
                },
                where: {
                  userId_key: {
                    userId: user.id,
                    key: 'last_logged_in',
                  },
                },
              })
            } catch {
              // Non-fatal error - continue processing
            }
          } catch {
            // Do nothing here
          }
        }),
      },
    })
  } catch (err) {
    console.error(`Error building Better Auth config: ${err}`)
    throw err
  }
}


auth-client.ts:


'use client'

import { twoFactorClient } from 'better-auth/client/plugins'
import { createAuthClient } from 'better-auth/react'

export const authClient = createAuthClient({
  baseURL: `${window.location.protocol}//${window.location.host}`,
  plugins: [twoFactorClient()],
})

// Export hooks and methods for convenience
export const { useSession, signIn, signOut, signUp, twoFactor } = authClient

Additional context

I might be doing something wrong here but I cannot find the issue according to the documentation so any help would be really appreciated :)

Thank you very much in advance!

Originally created by @MarcJose on GitHub (Jan 7, 2026). ### Is this suited for github? - [x] Yes, this is suited for github ### To Reproduce Hello, I recently added the [twoFactor](https://www.better-auth.com/docs/plugins/2fa) plugin to our credentials login. So far it seems to work very well, so users can set it up in their profile using either a QR code or the secret directly and they can log in using the code. Now I wanted to extend it to allow backup codes but this seems to run into problems. The codes are generated and stored in the database apparently, but when trying to use the code during login I get an error `Invalid backup code`. The login is executed by running: ```typescript await authClient.twoFactor.verifyBackupCode({ code: backupCode, }) ``` The client and server auth configs are attached below and my prisma schema looks like this: ```prisma model User { id String @id @default(cuid()) name String? email String? @unique emailVerified Boolean @default(false) image String? @db.LongText password String? twoFactorEnabled Boolean @default(false) createdAt DateTime @default(now()) updatedAt DateTime? @updatedAt accounts Account[] Authenticators Authenticator[] sessions Session[] TwoFactor TwoFactor[] @@map("user") } model Account { id String @id @default(cuid()) userId String type String? providerId String @map("provider") accountId String @map("providerAccountId") refreshToken String? @map("refresh_token") accessToken String? @map("access_token") @db.Text accessTokenExpiresAt DateTime? @map("expires_at") tokenType String? @map("token_type") scope String? idToken String? @map("id_token") @db.Text sessionState String? @map("session_state") password String? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@unique([providerId, accountId]) @@map("account") } model Session { id String @id @default(cuid()) token String @unique @map("sessionToken") userId String expiresAt DateTime @map("expires") ipAddress String? userAgent String? @db.Text user User @relation(fields: [userId], references: [id], onDelete: Cascade) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@map("session") } model VerificationToken { identifier String token String expires DateTime @@id([identifier, token]) @@map("verificationToken") } model Verification { id String @id @default(cuid()) identifier String value String @db.Text expiresAt DateTime createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@map("verification") } model Authenticator { credentialID String @unique userId String providerAccountId String credentialPublicKey String counter Int credentialDeviceType String credentialBackedUp Boolean transports String? user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@id([userId, credentialID]) @@map("authenticator") } model TwoFactor { id String @id @default(cuid()) secret String backupCodes String @db.Text userId String @unique user User @relation(fields: [userId], references: [id], onDelete: Cascade) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@map("twoFactor") } ``` ### Current vs. Expected behavior Following the configuration from above I would expect that I can log in using the backup codes for the TwoFactor Plugin. ### What version of Better Auth are you using? 1.4.10 ### System info ```bash { "system": { "platform": "linux", "arch": "x64", "version": "#1 SMP PREEMPT_DYNAMIC Fri, 02 Jan 2026 17:52:55 +0000", "release": "6.18.3-arch1-1", "cpuCount": 16, "cpuModel": "AMD Ryzen 7 9800X3D 8-Core Processor", "totalMemory": "60.41 GB", "freeMemory": "42.18 GB" }, "node": { "version": "v24.12.0", "env": "development" }, "packageManager": { "name": "npm", "version": "11.6.2" }, "frameworks": [ { "name": "next", "version": "16.1.1" }, { "name": "react", "version": "19.2.3" } ], "databases": [ { "name": "@prisma/client", "version": "7.2.0" } ], "betterAuth": { "version": "1.4.10", "config": null } } ``` ### Which area(s) are affected? (Select all that apply) Client ### Auth config (if applicable) ```typescript auth-config.ts: import 'server-only' import { betterAuth } from 'better-auth' import { prismaAdapter } from 'better-auth/adapters/prisma' import { createAuthMiddleware, twoFactor } from 'better-auth/plugins' import { prisma } from '@/lib/prisma' /** * Builds dynamic Better Auth configuration. * This function can be called on-demand to get the latest configuration. * @returns Promise resolving to Better Auth instance with current provider configurations. */ export async function buildBetterAuth() { /** * Get domain configuration for cookies based on environment. * @returns Domain setting for cookies or undefined to use default behavior. */ const getDomainForCookies = () => { try { if (!process.env.BASE_URL) return undefined const url = new URL(process.env.BASE_URL) const hostname = url.hostname // For localhost or IP addresses, don't set a domain (use browser default) if (hostname === 'localhost' || RegExp(/^\d+\.\d+\.\d+\.\d+$/).exec(hostname)) { return undefined } // For production domains, prepend with '.' to include subdomains return process.env.NODE_ENV === 'production' ? `.${hostname}` : undefined } catch { return undefined } } try { // Get cookie domain setting const domain = getDomainForCookies() // Important: When running a production build locally on HTTP, make sure secure cookies are not enabled const isLocalEnvironment = process.env.BASE_URL?.includes('localhost') || process.env.BASE_URL?.includes('127.0.0.1') // Only use secure cookies when: // 1. in production AND not on localhost, OR // 2. explicitly using HTTPS const useSecureCookies = (process.env.NODE_ENV === 'production' && !isLocalEnvironment) || process.env.BASE_URL?.startsWith('https://') || false return betterAuth({ database: prismaAdapter(prisma, { provider: 'mysql', }), baseURL: process.env.BASE_URL || process.env.BETTER_AUTH_URL || process.env.NEXT_AUTH_URL, secret: process.env.BETTER_AUTH_SECRET || process.env.NEXTAUTH_SECRET, user: { additionalFields: { emailVerified: { type: 'boolean', required: false, defaultValue: true, }, }, }, emailAndPassword: { enabled: true, minPasswordLength: 12, maxPasswordLength: 128, requireEmailVerification: false, }, plugins: [ twoFactor({ issuer: process.env.BASE_URL ? new URL(process.env.BASE_URL).hostname : process.env.APP_TITLE, backupCodes: { amount: 10, length: 10, }, }), ], session: { expiresIn: 259200, // 3 days updateAge: 3600, // 1 hour cookieCache: { enabled: false, // Disabled due to large profile images in Better Auth's internal session data }, }, advanced: { cookiePrefix: 'next-auth', // Keep same cookie prefix for compatibility with NextAuth useSecureCookies, crossSubDomainCookies: { enabled: !!domain, domain, }, // Disable state validation errors in development disableCSRFCheck: process.env.NODE_ENV === 'development', }, hooks: { after: createAuthMiddleware(async (ctx) => { // Only run on callback completion, not every get-session call if (!ctx.path?.includes('/callback/')) { return } try { // Get session from context const session = ctx.context.newSession || ctx.context.session if (!session || !session.user || !session.user.id) { return } // Query database to get user and account data const user = await prisma.user.findUnique({ where: { id: session.user.id }, include: { accounts: true, }, }) if (!user || !user.accounts || user.accounts.length === 0) { return } // Get the most recently created account (the one just used for sign-in) const account = user.accounts.toSorted( (a, b) => b.createdAt.getTime() - a.createdAt.getTime(), )[0] if (!account) { return } // Update last logged in flag for all successful logins try { await prisma.userData.upsert({ create: { userId: user.id, key: 'last_logged_in', value: new Date().toUTCString(), }, update: { value: new Date().toUTCString(), }, where: { userId_key: { userId: user.id, key: 'last_logged_in', }, }, }) } catch { // Non-fatal error - continue processing } } catch { // Do nothing here } }), }, }) } catch (err) { console.error(`Error building Better Auth config: ${err}`) throw err } } auth-client.ts: 'use client' import { twoFactorClient } from 'better-auth/client/plugins' import { createAuthClient } from 'better-auth/react' export const authClient = createAuthClient({ baseURL: `${window.location.protocol}//${window.location.host}`, plugins: [twoFactorClient()], }) // Export hooks and methods for convenience export const { useSession, signIn, signOut, signUp, twoFactor } = authClient ``` ### Additional context I might be doing something wrong here but I cannot find the issue according to the documentation so any help would be really appreciated :) Thank you very much in advance!
GiteaMirror added the bug label 2026-03-13 10:11:57 -05:00
Author
Owner

@better-auth-agent[bot] commented on GitHub (Jan 7, 2026):

It seems like you're experiencing an issue with validating backup codes when using the TwoFactor plugin in Better Auth.

When you encounter the Invalid backup code error, it could be due to a few factors, such as how the backup codes are generated, stored, or retrieved. Here are some steps to troubleshoot the problem:

  1. Check Backup Codes Storage: Ensure that the backup codes are properly generated and stored in your database when a user enables two-factor authentication. You can verify this by inspecting the TwoFactor model in your database schema.

  2. Code Verification Logic: Double-check your client-side code where you verify the backup code. Make sure there are no alterations to the backup code before sending it to the verification method.

  3. Ensure Correct Format: Ensure that the format of the backup code being passed in verifyBackupCode matches the format stored.

  4. Debug Logging: Add logging to see what backup code is being entered and the state of the stored backup codes in your database to identify any discrepancies.

If you've gone through these steps and still face issues, it might help to tag Beka and Max (@bekacru and @ping__) for more specific assistance.

What have you tried so far in troubleshooting this issue?

If you need more help, tag @better-auth-agent in a comment so I can respond.

DiagramDiscordGitHub

Diagram Join Star

@better-auth-agent[bot] commented on GitHub (Jan 7, 2026): It seems like you're experiencing an issue with validating backup codes when using the TwoFactor plugin in Better Auth. When you encounter the `Invalid backup code` error, it could be due to a few factors, such as how the backup codes are generated, stored, or retrieved. Here are some steps to troubleshoot the problem: 1. **Check Backup Codes Storage**: Ensure that the backup codes are properly generated and stored in your database when a user enables two-factor authentication. You can verify this by inspecting the `TwoFactor` model in your database schema. 2. **Code Verification Logic**: Double-check your client-side code where you verify the backup code. Make sure there are no alterations to the backup code before sending it to the verification method. 3. **Ensure Correct Format**: Ensure that the format of the backup code being passed in `verifyBackupCode` matches the format stored. 4. **Debug Logging**: Add logging to see what backup code is being entered and the state of the stored backup codes in your database to identify any discrepancies. If you've gone through these steps and still face issues, it might help to tag Beka and Max (@bekacru and @ping__) for more specific assistance. What have you tried so far in troubleshooting this issue? _If you need more help, tag @better-auth-agent in a comment so I can respond._ <!-- bot:webhook reply v1 --> [Diagram](https://repodiagrams.s3.eu-north-1.amazonaws.com/skyvern_ultra_detailed_interactive.html) • [Discord](https://discord.gg/fG2XXEuQX3) • [GitHub](https://github.com/Skyvern-AI/Skyvern) [![Diagram](https://img.shields.io/badge/Diagram-2b3137?style=flat-square)](https://repodiagrams.s3.eu-north-1.amazonaws.com/skyvern_ultra_detailed_interactive.html) [![Join](https://img.shields.io/badge/join-5865F2?logo=discord&logoColor=white&style=flat-square)](https://discord.gg/fG2XXEuQX3) [![Star](https://img.shields.io/badge/star-181717?logo=github&logoColor=white&style=flat-square)](https://github.com/Skyvern-AI/Skyvern)
Author
Owner

@dosubot[bot] commented on GitHub (Jan 7, 2026):

Hi @MarcJose! I'm Dosu and I’m helping the better-auth team.

The most common causes of the "Invalid backup code" error in the Better Auth twoFactor plugin are:

  • The backup code format must be two groups of 5 alphanumeric characters separated by a dash (e.g., ABCDE-12345). The code you enter must exactly match one of the generated codes for your user, including the dash and case sensitivity (codes are typically uppercase) source.
  • Backup codes are stored as an encrypted JSON string in your TwoFactor.backupCodes field. Your Prisma schema matches the expected format, so that's not the issue source.
  • Backup code verification requires a valid two-factor session cookie. If the session or two-factor cookie is missing or expired (which can happen after enabling or verifying 2FA, since Better Auth issues a new session cookie and expires the old one), verification will fail with this error. Make sure your client is sending the latest cookies when calling verifyBackupCode source source.
  • Once a backup code is used, it is removed from the database and cannot be used again source.

If you confirm the code format and freshness, but still get the error, try inspecting the TwoFactor.backupCodes field for your test user in the database to ensure codes are being generated and stored as expected. Also, check your client/server flow to ensure cookies are handled correctly after 2FA is enabled or verified. If you’re using a custom proxy or non-standard route mounting, make sure your baseURL matches exactly.

If you want to debug further, enabling debug logging for Better Auth and inspecting server logs during the backup code verification request can help pinpoint where the process is failing.

To reply, just mention @dosu.


How did I do? Good | Irrelevant | Incorrect | Verbose | Hallucination | Report 🐛 | Other

Chat with Dosu Open in Cursor Join Discord Share on X

@dosubot[bot] commented on GitHub (Jan 7, 2026): <!-- Greeting --> Hi @MarcJose! I'm [Dosu](https://go.dosu.dev/dosubot) and I’m helping the better-auth team. <!-- Answer --> The most common causes of the "Invalid backup code" error in the Better Auth twoFactor plugin are: - The backup code format must be two groups of 5 alphanumeric characters separated by a dash (e.g., `ABCDE-12345`). The code you enter must exactly match one of the generated codes for your user, including the dash and case sensitivity (codes are typically uppercase) [source](https://github.com/better-auth/better-auth/issues/735). - Backup codes are stored as an encrypted JSON string in your `TwoFactor.backupCodes` field. Your Prisma schema matches the expected format, so that's not the issue [source](https://github.com/better-auth/better-auth/blob/c31c92f3d24f3ccfb6776d4634e0681c6dee2db5/packages/better-auth/src/plugins/two-factor/backup-codes/index.ts#L1-L381). - Backup code verification requires a valid two-factor session cookie. If the session or two-factor cookie is missing or expired (which can happen after enabling or verifying 2FA, since Better Auth issues a new session cookie and expires the old one), verification will fail with this error. Make sure your client is sending the latest cookies when calling `verifyBackupCode` [source](https://github.com/better-auth/better-auth/issues/4912) [source](https://github.com/better-auth/better-auth/pull/6604). - Once a backup code is used, it is removed from the database and cannot be used again [source](https://github.com/better-auth/better-auth/pull/6374). If you confirm the code format and freshness, but still get the error, try inspecting the `TwoFactor.backupCodes` field for your test user in the database to ensure codes are being generated and stored as expected. Also, check your client/server flow to ensure cookies are handled correctly after 2FA is enabled or verified. If you’re using a custom proxy or non-standard route mounting, make sure your `baseURL` matches exactly. If you want to debug further, enabling debug logging for Better Auth and inspecting server logs during the backup code verification request can help pinpoint where the process is failing. <!-- Dosu Comment Footer --> *To reply, just mention [@dosu](https://go.dosu.dev/dosubot).* --- <sup>How did I do? [Good](https://app.dosu.dev/response-feedback/78794c48-b747-4841-a7c6-4fe2c45396eb?feedback_type=great_response) | [Irrelevant](https://app.dosu.dev/response-feedback/78794c48-b747-4841-a7c6-4fe2c45396eb?feedback_type=irrelevant_answer) | [Incorrect](https://app.dosu.dev/response-feedback/78794c48-b747-4841-a7c6-4fe2c45396eb?feedback_type=incorrect_sources) | [Verbose](https://app.dosu.dev/response-feedback/78794c48-b747-4841-a7c6-4fe2c45396eb?feedback_type=too_verbose) | [Hallucination](https://app.dosu.dev/response-feedback/78794c48-b747-4841-a7c6-4fe2c45396eb?feedback_type=hallucination) | [Report 🐛](https://app.dosu.dev/response-feedback/78794c48-b747-4841-a7c6-4fe2c45396eb?feedback_type=bug_report) | [Other](https://app.dosu.dev/response-feedback/78794c48-b747-4841-a7c6-4fe2c45396eb?feedback_type=other)</sup> [![Chat with Dosu](https://dosu.dev/dosu-chat-badge.svg)](https://app.dosu.dev/cdda13d9-dd27-4d31-b09a-5d8bec92de21/ask?utm_source=github)&nbsp;[![Open in Cursor](https://dosu.dev/dosu-in-cursor.svg)](https://cursor.com/link/prompt?text=The%20most%20common%20causes%20of%20the%20%22Invalid%20backup%20code%22%20error%20in%20the%20Better%20Auth%20twoFactor%20plugin%20are%3A%20%0A%0A-%20The%20backup%20code%20format%20must%20be%20two%20groups%20of%205%20alphanumeric%20characters%20separated%20by%20a%20dash%20%28e.g.%2C%20%60ABCDE-12345%60%29.%20The%20code%20you%20enter%20must%20exactly%20match%20one%20of%20the%20generated%20codes%20for%20your%20user%2C%20including%20the%20dash%20and%20case%20sensitivity%20%28codes%20are%20typically%20uppercase%29%20%5Bsource%5D%28https%3A//github.com/better-auth/better-auth/issues/735%29.%0A-%20Backup%20codes%20are%20stored%20as%20an%20encrypted%20JSON%20string%20in%20your%20%60TwoFactor.backupCodes%60%20field.%20Your%20Prisma%20schema%20matches%20the%20expected%20format%2C%20so%20that%27s%20not%20the%20issue%20%5Bsource%5D%28https%3A//github.com/better-auth/better-auth/blob/c31c92f3d24f3ccfb6776d4634e0681c6dee2db5/packages/better-auth/src/plugins/two-factor/backup-codes/index.ts%23L1-L381%29.%0A-%20Backup%20code%20verification%20requires%20a%20valid%20two-factor%20session%20cookie.%20If%20the%20session%20or%20two-factor%20cookie%20is%20missing%20or%20expired%20%28which%20can%20happen%20after%20enabling%20or%20verifying%202FA%2C%20since%20Better%20Auth%20issues%20a%20new%20session%20cookie%20and%20expires%20the%20old%20one%29%2C%20verification%20will%20fail%20with%20this%20error.%20Make%20sure%20your%20client%20is%20sending%20the%20latest%20cookies%20when%20calling%20%60verifyBackupCode%60%20%5Bsource%5D%28https%3A//github.com/better-auth/better-auth/issues/4912%29%20%5Bsource%5D%28https%3A//github.com/better-auth/better-auth/pull/6604%29.%0A-%20Once%20a%20backup%20code%20is%20used%2C%20it%20is%20removed%20from%20the%20database%20and%20cannot%20be%20used%20again%20%5Bsource%5D%28https%3A//github.com/better-auth/better-auth/pull/6374%29.%0A%0AIf%20you%20confirm%20the%20code%20format%20and%20freshness%2C%20but%20still%20get%20the%20error%2C%20try%20inspecting%20the%20%60TwoFactor.backupCodes%60%20field%20for%20your%20test%20user%20in%20the%20database%20to%20ensure%20codes%20are%20being%20generated%20and%20stored%20as%20expected.%20Also%2C%20check%20your%20client/server%20flow%20to%20ensure%20cookies%20are%20handled%20correctly%20after%202FA%20is%20enabled%20or%20verified.%20If%20you%E2%80%99re%20using%20a%20custom%20proxy%20or%20non-standard%20route%20mounting%2C%20make%20sure%20your%20%60baseURL%60%20matches%20exactly.%0A%0AIf%20you%20want%20to%20debug%20further%2C%20enabling%20debug%20logging%20for%20Better%20Auth%20and%20inspecting%20server%20logs%20during%20the%20backup%20code%20verification%20request%20can%20help%20pinpoint%20where%20the%20process%20is%20failing.)&nbsp;[![Join Discord](https://img.shields.io/badge/join-5865F2?logo=discord&logoColor=white&label=)](https://go.dosu.dev/discord-bot)&nbsp;[![Share on X](https://img.shields.io/badge/X-share-black)](https://twitter.com/intent/tweet?text=%40dosu_ai%20helped%20me%20solve%20this%20issue!&url=https%3A//github.com/better-auth/better-auth/issues/7177)
Author
Owner

@MarcJose commented on GitHub (Jan 7, 2026):

Oh wow... right after submitting this issue I found my problem.. Apparently after being sent the code was always missing the last character...

@MarcJose commented on GitHub (Jan 7, 2026): Oh wow... right after submitting this issue I found my problem.. Apparently after being sent the code was always missing the last character...
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: github-starred/better-auth#2664