[GH-ISSUE #9229] captureOTP returns hashed value instead of plaintext when storeOTP: "hashed" is enabled #28640

Open
opened 2026-04-17 20:03:45 -05:00 by GiteaMirror · 1 comment
Owner

Originally created by @btnalexandre on GitHub (Apr 17, 2026).
Original GitHub issue: https://github.com/better-auth/better-auth/issues/9229

Is this suited for github?

  • Yes, this is suited for github

Reproduction

Steps to reproduce:

  1. Configure emailOTP with storeOTP: "hashed" and testUtils with captureOTP: true:
export const auth = betterAuth({                                                                                                                         
  plugins: [
    emailOTP({
      sendVerificationOTP() {},
      storeOTP: "hashed",
    }),
    testUtils({ captureOTP: true }),
  ],                                                                                                                                                       
});
  1. Send a verification OTP and retrieve it via getOTP():
const ctx = await auth.$context;                                                                                                                           
  await auth.api.sendVerificationOTP({                                                                                                                     
    body: { email: "user@example.com", type: "sign-in" },
  });
  const otp = ctx.test.getOTP!("user@example.com");                                                                                                          
  console.log(otp); // "1JY4aCmuJSOU5lhFgbVctAN7AsOrCphjiSC8uLvo_yQ"
  1. Observe that getOTP() returns the hashed value instead of the plaintext 6-digit code.

Current vs. Expected behavior

Current behavior: getOTP() returns the hashed OTP value (e.g. 1JY4aCmuJSOU5lhFgbVctAN7AsOrCphjiSC8uLvo_yQ) instead of the plaintext code.

This happens because the captureOTP hook is a database after hook on the verification table (plugins/test-utils/index.mjs):

  const databaseHooks = options.captureOTP ? { verification: { create: { async after(verification) {                                                         
      const otpPart = verification.value.split(":")[0];                                                                                                      
      otpStore.capture(identifier, otpPart);

When storeOTP: "hashed" is enabled, verification.value is already hashed at this point, so the captured value is the hash, not the plaintext.

Expected behavior: getOTP() should return the plaintext OTP (e.g. "123456") regardless of the storeOTP setting. The capture should happen before hashing either via a before hook or by intercepting at OTP generation time.

What version of Better Auth are you using?

1.6.5

System info

{
  "system": {
    "platform": "darwin",
    "arch": "arm64",
    "version": "Darwin Kernel Version 25.3.0: Wed Jan 28 20:51:28 PST 2026; root:xnu-12377.91.3~2/RELEASE_ARM64_T6041",
    "release": "25.3.0",
    "cpuCount": 14,
    "cpuModel": "Apple M4 Pro",
    "totalMemory": "48.00 GB",
    "freeMemory": "8.11 GB"
  },
  "node": {
    "version": "v24.14.1",
    "env": "development"
  },
  "packageManager": {
    "name": "npm",
    "version": "11.11.0"
  },
  "frameworks": [
    {
      "name": "next",
      "version": "16.2.2"
    },
    {
      "name": "react",
      "version": "19.2.4"
    }
  ],
  "databases": [
    {
      "name": "pg",
      "version": "^8.16.3"
    },
    {
      "name": "@prisma/client",
      "version": "^7.0.0"
    }
  ],
  "betterAuth": {
    "version": "Unknown",
    "config": null,
    "error": "Please remove import 'server-only' from your auth config file temporarily. The CLI cannot resolve the configuration with it included. You can re-add it after running the CLI."
  }

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

Other

Auth config (if applicable)

import { betterAuth } from "better-auth"
export const auth = betterAuth({
  emailOTP({
    sendVerificationOTP() {},
    storeOTP: "hashed",
  }),
  testUtils({ captureOTP: true }),  
});

Additional context

No response

Originally created by @btnalexandre on GitHub (Apr 17, 2026). Original GitHub issue: https://github.com/better-auth/better-auth/issues/9229 ### Is this suited for github? - [x] Yes, this is suited for github ### Reproduction Steps to reproduce: 1. Configure `emailOTP` with `storeOTP: "hashed"` and `testUtils` with `captureOTP: true`: ```ts export const auth = betterAuth({ plugins: [ emailOTP({ sendVerificationOTP() {}, storeOTP: "hashed", }), testUtils({ captureOTP: true }), ], }); ``` 2. Send a verification OTP and retrieve it via getOTP(): ```ts const ctx = await auth.$context; await auth.api.sendVerificationOTP({ body: { email: "user@example.com", type: "sign-in" }, }); const otp = ctx.test.getOTP!("user@example.com"); console.log(otp); // "1JY4aCmuJSOU5lhFgbVctAN7AsOrCphjiSC8uLvo_yQ" ``` 3. Observe that getOTP() returns the hashed value instead of the plaintext 6-digit code. ### Current vs. Expected behavior Current behavior: getOTP() returns the hashed OTP value (e.g. 1JY4aCmuJSOU5lhFgbVctAN7AsOrCphjiSC8uLvo_yQ) instead of the plaintext code. This happens because the captureOTP hook is a database after hook on the verification table (plugins/test-utils/index.mjs): ```ts const databaseHooks = options.captureOTP ? { verification: { create: { async after(verification) { const otpPart = verification.value.split(":")[0]; otpStore.capture(identifier, otpPart); ``` When storeOTP: "hashed" is enabled, verification.value is already hashed at this point, so the captured value is the hash, not the plaintext. Expected behavior: getOTP() should return the plaintext OTP (e.g. "123456") regardless of the storeOTP setting. The capture should happen before hashing either via a before hook or by intercepting at OTP generation time. ### What version of Better Auth are you using? 1.6.5 ### System info ```bash { "system": { "platform": "darwin", "arch": "arm64", "version": "Darwin Kernel Version 25.3.0: Wed Jan 28 20:51:28 PST 2026; root:xnu-12377.91.3~2/RELEASE_ARM64_T6041", "release": "25.3.0", "cpuCount": 14, "cpuModel": "Apple M4 Pro", "totalMemory": "48.00 GB", "freeMemory": "8.11 GB" }, "node": { "version": "v24.14.1", "env": "development" }, "packageManager": { "name": "npm", "version": "11.11.0" }, "frameworks": [ { "name": "next", "version": "16.2.2" }, { "name": "react", "version": "19.2.4" } ], "databases": [ { "name": "pg", "version": "^8.16.3" }, { "name": "@prisma/client", "version": "^7.0.0" } ], "betterAuth": { "version": "Unknown", "config": null, "error": "Please remove import 'server-only' from your auth config file temporarily. The CLI cannot resolve the configuration with it included. You can re-add it after running the CLI." } ``` ### Which area(s) are affected? (Select all that apply) Other ### Auth config (if applicable) ```typescript import { betterAuth } from "better-auth" export const auth = betterAuth({ emailOTP({ sendVerificationOTP() {}, storeOTP: "hashed", }), testUtils({ captureOTP: true }), }); ``` ### Additional context _No response_
GiteaMirror added the devtools label 2026-04-17 20:03:45 -05:00
Author
Owner

@dosubot[bot] commented on GitHub (Apr 17, 2026):

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

This is a valid issue. The root cause is an architectural timing problem: the emailOTP plugin hashes the OTP before database insertion [1], but the testUtils plugin's captureOTP uses a create.after database hook on the verification table [2], which only sees the already-hashed value.

The flow looks like:

  1. Generate plaintext OTP
  2. Hash it via storeOTP() → SHA-256 [3]
  3. Insert hashed value into DB
  4. captureOTP hook fires → captures the hashed value

A fix would involve capturing the OTP before hashing — either via a create.before hook or by intercepting at OTP generation time in the emailOTP plugin, as you suggested.

Workaround for now: Use storeOTP: "plain" in your test environment configuration, which avoids the hashing step and lets captureOTP work correctly:

emailOTP({
  sendVerificationOTP() {},
  storeOTP: process.env.NODE_ENV === "test" ? "plain" : "hashed",
})

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

<!-- gh-comment-id:4269452231 --> @dosubot[bot] commented on GitHub (Apr 17, 2026): <!-- Greeting --> Hi @btnalexandre! I'm [Dosu](https://go.dosu.dev/dosubot) and I’m helping the better-auth team. <!-- Answer --> This is a valid issue. The root cause is an architectural timing problem: the `emailOTP` plugin hashes the OTP **before** database insertion [[1]](https://github.com/better-auth/better-auth/blob/a844c7dd087715678787cb10bf9670fad46e535b/packages/better-auth/src/plugins/email-otp/index.ts#L35-L52), but the `testUtils` plugin's `captureOTP` uses a `create.after` database hook on the verification table [[2]](https://github.com/better-auth/better-auth/blob/a844c7dd087715678787cb10bf9670fad46e535b/packages/better-auth/src/plugins/test-utils/index.ts#L116-L143), which only sees the already-hashed value. The flow looks like: 1. Generate plaintext OTP 2. Hash it via `storeOTP()` → SHA-256 [[3]](https://github.com/better-auth/better-auth/blob/a844c7dd087715678787cb10bf9670fad46e535b/packages/better-auth/src/plugins/email-otp/otp-token.ts#L22-L24) 3. Insert hashed value into DB 4. `captureOTP` hook fires → captures the hashed value ❌ A fix would involve capturing the OTP **before** hashing — either via a `create.before` hook or by intercepting at OTP generation time in the `emailOTP` plugin, as you suggested. **Workaround for now:** Use `storeOTP: "plain"` in your test environment configuration, which avoids the hashing step and lets `captureOTP` work correctly: ```ts emailOTP({ sendVerificationOTP() {}, storeOTP: process.env.NODE_ENV === "test" ? "plain" : "hashed", }) ``` <!-- Dosu Comment Footer --> *To reply, just mention [@dosu](https://go.dosu.dev/dosubot?utm_source=github&utm_medium=bot-comment&utm_campaign=github-assistant&utm_content=brand-link).* --- <sup>How did I do? [Good](https://app.dosu.dev/response-feedback?utm_source=github&utm_medium=bot-comment&utm_campaign=github-assistant&utm_content=feedback-great_response#message_id=83c1acbf-7a35-4e4d-9957-039555c631e3&feedback_type=great_response) | [Irrelevant](https://app.dosu.dev/response-feedback?utm_source=github&utm_medium=bot-comment&utm_campaign=github-assistant&utm_content=feedback-irrelevant_answer#message_id=83c1acbf-7a35-4e4d-9957-039555c631e3&feedback_type=irrelevant_answer) | [Incorrect](https://app.dosu.dev/response-feedback?utm_source=github&utm_medium=bot-comment&utm_campaign=github-assistant&utm_content=feedback-incorrect_sources#message_id=83c1acbf-7a35-4e4d-9957-039555c631e3&feedback_type=incorrect_sources) | [Verbose](https://app.dosu.dev/response-feedback?utm_source=github&utm_medium=bot-comment&utm_campaign=github-assistant&utm_content=feedback-too_verbose#message_id=83c1acbf-7a35-4e4d-9957-039555c631e3&feedback_type=too_verbose) | [Hallucination](https://app.dosu.dev/response-feedback?utm_source=github&utm_medium=bot-comment&utm_campaign=github-assistant&utm_content=feedback-hallucination#message_id=83c1acbf-7a35-4e4d-9957-039555c631e3&feedback_type=hallucination) | [Report 🐛](https://app.dosu.dev/response-feedback?utm_source=github&utm_medium=bot-comment&utm_campaign=github-assistant&utm_content=feedback-bug_report#message_id=83c1acbf-7a35-4e4d-9957-039555c631e3&feedback_type=bug_report) | [Other](https://app.dosu.dev/response-feedback?utm_source=github&utm_medium=bot-comment&utm_campaign=github-assistant&utm_content=feedback-other#message_id=83c1acbf-7a35-4e4d-9957-039555c631e3&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&utm_medium=bot-comment&utm_campaign=github-assistant&utm_content=chat-badge)&nbsp;[![Open in Cursor](https://dosu.dev/dosu-in-cursor.svg)](https://cursor.com/link/prompt?text=This%20is%20a%20valid%20issue.%20The%20root%20cause%20is%20an%20architectural%20timing%20problem%3A%20the%20%60emailOTP%60%20plugin%20hashes%20the%20OTP%20%2A%2Abefore%2A%2A%20database%20insertion%20%5B%5B1%5D%5D%28https%3A//github.com/better-auth/better-auth/blob/a844c7dd087715678787cb10bf9670fad46e535b/packages/better-auth/src/plugins/email-otp/index.ts%23L35-L52%29%2C%20but%20the%20%60testUtils%60%20plugin%27s%20%60captureOTP%60%20uses%20a%20%60create.after%60%20database%20hook%20on%20the%20verification%20table%20%5B%5B2%5D%5D%28https%3A//github.com/better-auth/better-auth/blob/a844c7dd087715678787cb10bf9670fad46e535b/packages/better-auth/src/plugins/test-utils/index.ts%23L116-L143%29%2C%20which%20only%20sees%20the%20already-hashed%20value.%0A%0AThe%20flow%20looks%20like%3A%0A%0A1.%20Generate%20plaintext%20OTP%0A2.%20Hash%20it%20via%20%60storeOTP%28%29%60%20%E2%86%92%20SHA-256%20%5B%5B3%5D%5D%28https%3A//github.com/better-auth/better-auth/blob/a844c7dd087715678787cb10bf9670fad46e535b/packages/better-auth/src/plugins/email-otp/otp-token.ts%23L22-L24%29%0A3.%20Insert%20hashed%20value%20into%20DB%0A4.%20%60captureOTP%60%20hook%20fires%20%E2%86%92%20captures%20the%20hashed%20value%20%E2%9D%8C%0A%0AA%20fix%20would%20involve%20capturing%20the%20OTP%20%2A%2Abefore%2A%2A%20hashing%20%E2%80%94%20either%20via%20a%20%60create.before%60%20hook%20or%20by%20intercepting%20at%20OTP%20generation%20time%20in%20the%20%60emailOTP%60%20plugin%2C%20as%20you%20suggested.%0A%0A%2A%2AWorkaround%20for%20now%3A%2A%2A%20Use%20%60storeOTP%3A%20%22plain%22%60%20in%20your%20test%20environment%20configuration%2C%20which%20avoids%20the%20hashing%20step%20and%20lets%20%60captureOTP%60%20work%20correctly%3A%0A%0A%60%60%60ts%0AemailOTP%28%7B%0A%20%20sendVerificationOTP%28%29%20%7B%7D%2C%0A%20%20storeOTP%3A%20process.env.NODE_ENV%20%3D%3D%3D%20%22test%22%20%3F%20%22plain%22%20%3A%20%22hashed%22%2C%0A%7D%29%0A%60%60%60)&nbsp;[![Join Discord](https://img.shields.io/badge/join-5865F2?logo=discord&logoColor=white&label=)](https://go.dosu.dev/discord-bot?utm_source=github&utm_medium=bot-comment&utm_campaign=github-assistant&utm_content=join-discord)&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/9229)
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: github-starred/better-auth#28640