Skip to content

feat(otp): accept raw-byte and CryptoKey secrets in createOTP#27

Open
holistis wants to merge 1 commit into
better-auth:mainfrom
holistis:feat/otp-raw-key-secret
Open

feat(otp): accept raw-byte and CryptoKey secrets in createOTP#27
holistis wants to merge 1 commit into
better-auth:mainfrom
holistis:feat/otp-raw-key-secret

Conversation

@holistis

Copy link
Copy Markdown

What

createOTP (and the HOTP/TOTP/verify/url it returns) currently only accepts a string secret, which is run through TextEncoder (UTF-8) to derive the HMAC key. That's fine for an ASCII secret, but it corrupts any byte >= 0x80 (it expands into a 2-byte UTF-8 sequence), so a raw key can't be used.

This adds support for passing the secret as raw bytes (Uint8Array / ArrayBuffer) or an already-imported CryptoKey, alongside the existing string.

Why

Most authenticator libraries (Auth.js, Lucia, oslo, otplib, speakeasy, …) and the otpauth:// URI itself treat the secret as raw bytes, with base32 only as the transport encoding. Today there's no way to feed those raw bytes in — you'd have to pass the base32 string, which then gets UTF-8 encoded and produces the wrong HMAC key for roughly half of all real keys (any with a byte >= 0x80).

That blocks migrating existing TOTP enrollments into anything built on @better-auth/utils. It's the root cause behind better-auth/better-auth#9944.

With this change you decode the base32 once and pass the bytes:

import { createOTP } from "@better-auth/utils/otp";
import { base32 } from "@better-auth/utils/base32";

const isValid = createOTP(base32.decode(storedSecret)).verify(code);

Notes

  • string secrets are unchanged (UTF-8), so this is fully backwards compatible.
  • Raw bytes are imported directly as the HMAC key (no TextEncoder). The sign path and the QR / url() path both read the secret through its underlying ArrayBuffer bytes, so they always agree — including for views with a non-zero byteOffset.
  • Byte detection uses ArrayBuffer.isView rather than instanceof CryptoKey, to avoid assuming a CryptoKey global (consistent with the getWebcryptoSubtle approach already used here).
  • A CryptoKey has no extractable bytes, so url() throws a clear TypeError for that case.

Tests

  • RFC 4226 Appendix D HOTP test vectors, fed as raw bytes.
  • A key with bytes >= 0x80 matches an independent raw HMAC computation and differs from the old latin-1 string path (the bug).
  • Full otpauth:// URL round-trip (build url → decode the base32 secret → verify), so the QR and sign paths can't silently drift apart.
  • ArrayBuffer parity and a multi-byte-view regression test.

createOTP/HOTP/TOTP only accepted a string secret, run through TextEncoder
(UTF-8) to derive the HMAC key. That corrupts any byte >= 0x80, so secrets from
RFC 4226 / RFC 6238 libraries (Lucia, Auth.js, oslo, otplib, ...) — which treat
base32 as transport for RAW key bytes — cannot be verified after migration.
See better-auth/better-auth#9944.

Accept string | ArrayBuffer | Uint8Array | CryptoKey for the secret:
- string: unchanged (UTF-8 via TextEncoder), backwards compatible
- ArrayBuffer / Uint8Array: imported directly as the raw HMAC key (no TextEncoder)
- CryptoKey: used as-is (must match the OTP hash; not usable with url())

Byte detection uses ArrayBuffer.isView, never 'instanceof CryptoKey', so it does
not assume a CryptoKey global (matching the lib's getWebcryptoSubtle discipline).

Tests: RFC 4226 Appendix D vectors via raw bytes; a >=0x80 key matches an
independent raw HMAC and differs from the latin-1 string path; ArrayBuffer
parity; full otpauth-URL round-trip (base32 decode -> verify) proving the QR and
sign paths agree. README: raw-byte migration example.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant