npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@exortek/otp

v1.0.1

Published

TOTP (RFC 6238) and HOTP (RFC 4226) one-time passwords for Node.js — secure defaults, tunable window / algorithm / digits, opt-in replay defense via any @exortek/security store, backup codes with unambiguous alphabet, and Google-Authenticator-compatible p

Downloads

253

Readme

@exortek/otp

Zero-dependency TOTP + HOTP for Node.js 22+ — built on node:crypto.

npm tests node install size types zero-deps license

RFC 4226 HOTP and RFC 6238 TOTP with secure defaults, tunable window / algorithm / digits, opt-in replay defense via any store exposing an atomic incr(key, ttlMs), unambiguous backup codes, and Google-Authenticator-compatible provisioning URIs for QR enrollment. No runtime dependencies — pure node:crypto.

📖 Docs: auth.memet.dev/otp

Why

Every auth flow that adds 2FA rewrites the same ~200 lines of code, and most of them get one of these wrong:

  • Timing-safe compare. code === candidate leaks the first matching character. crypto.timingSafeEqual fixes it — if you remember to use it.
  • Skew tolerance. A user's phone drifts, the code changes while they're typing. If you don't accept the previous / next period, you reject valid users.
  • Replay defense. A one-time code should be one-time. Without a server-side "seen" cache, an attacker who reads a valid code from a phishing page or a proxy log can use it inside the skew window.
  • Backup codes. Random Math.random() codes with O/0/1/I glyphs that users can't tell apart on a printout. Or worse, storing them in plain text.
  • QR provisioning URI. The otpauth:// grammar is subtle — miss a URL-encode and half your users can't scan.

@exortek/otp ships every one of these correctly, and defaults to the values Google Authenticator + Authy + 1Password all agree on.

Install

npm  install @exortek/otp
yarn add     @exortek/otp
pnpm add     @exortek/otp

Requires Node.js 22 or newer. Zero runtime dependencies.

Quick start

import { generateSecret, provisioningUri, totp, verifyTotp, backupCodes } from '@exortek/otp';

// 1. Enrollment — mint a secret and turn it into a QR
const secret = generateSecret(); // base32, 20 bytes
const uri = provisioningUri({
  label: '[email protected]',
  issuer: 'MyApp',
  secret,
});
// Save `secret` to your users table. Render `uri` as a QR code
// (use any QR library — `qrcode` on npm is fine).

// 2. Backup codes — hand these to the user, hash before storing
const codes = backupCodes(10); // ['A3F4-9K2M', 'X7QP-5NB2', …]

// 3. Login — verify the 6-digit code
const ok = await verifyTotp(userInput, secret, { window: 1 });
if (!ok) return res.status(401).end('invalid code');

Ten lines and you have working 2FA. Add replay defense for high-security flows.

API

generateSecret(options?)

generateSecret({
  bytes?:    number,               // default 20  (16..128)
  encoding?: 'base32'              // default — no padding
           | 'base32padded'
           | 'hex'
           | 'raw',
}): string

Cryptographically random. Default matches Google Authenticator's enrollment convention: 20 bytes, base32, no padding.

totp(secret, options?)

totp(secret, {
  digits?:    6 | 7 | 8 | 9 | 10,           // default 6 — see compat table
  algorithm?: 'SHA1' | 'SHA224' | 'SHA256'  // default SHA1 — see compat table
           | 'SHA384' | 'SHA512',
  period?:    number,                       // seconds, default 30
  timestamp?: number,                       // ms since epoch — testing only
  t0?:        number,                       // RFC 6238 epoch offset (default 0)
}): string

Current TOTP for the given secret. Accepts base32 strings, hex, Buffer, or Uint8Array.

verifyTotp(code, secret, options?)

verifyTotp(code, secret, {
  digits?:    6 | 7 | 8 | 9 | 10,
  algorithm?: 'SHA1' | 'SHA224' | 'SHA256' | 'SHA384' | 'SHA512',
  period?:    number,
  window?:    number,              // default 1  (±30s slop)
  timestamp?: number,
  t0?:        number,
  replay?: { store, key: string }, // opt-in — see below
}): Promise<boolean>

Returns true on match, false on any failure. Never throws on user-input problems — a wrong code is a normal auth outcome, not an error.

remainingSeconds(period?, timestamp?)

Seconds until the current TOTP code rolls over. Handy for the countdown ring most 2FA screens show.

hotp(secret, counter, options?) / verifyHotp(code, secret, counter, options?)

Counter-based cousin. verifyHotp returns the matched counter (so you can advance your stored value) or null on no match. Only looks forward — used counters can never replay.

provisioningUri(options)

provisioningUri({
  label:      string,              // usually the user's email
  secret:     string,              // base32
  issuer?:    string,              // your app name
  type?:      'totp' | 'hotp',     // default 'totp'
  digits?:    6 | 7 | 8 | 9 | 10,  // see compat table below
  period?:    number,              // TOTP only
  counter?:   number,              // HOTP — required
  algorithm?: 'SHA1' | 'SHA256' | 'SHA512',
}): string

Emits the otpauth:// URI Google Authenticator, Authy, 1Password, Bitwarden, Yubico Authenticator, and Aegis all understand. Non-default parameters are omitted for maximum scanner compatibility.

backupCodes(n?, options?)

backupCodes(n = 10, {
  length?:   number,               // default 10 chars per code
  groups?:   number,               // default 2 — 'ABCD-EFGH' style
  alphabet?: string,               // default Crockford (no O/0/1/I/L)
}): string[]

Unbiased CSPRNG draw from an unambiguous alphabet. You hash them before storage (bcrypt / argon2 / a strong HMAC keyed with a server secret) — this package does not touch persistence.

normalizeBackupCode(input) / compareBackupCode(candidate, stored)

Case-insensitive, whitespace / dash-tolerant, timing-safe verify for codes the user typed. Length mismatch does not short-circuit.

Errors

Every recoverable failure throws OtpError with a stable code. Branch on code, not on the message.

import { OtpError, ErrorCode } from '@exortek/otp'

try {
  totp(malformedSecret)
} catch (err) {
  if (err instanceof OtpError && err.code === ErrorCode.INVALID_SECRET) { … }
}

Codes: INVALID_ARGUMENT, INVALID_SECRET, INVALID_CODE, THROTTLED, REPLAY_DETECTED, UNSUPPORTED_ALGORITHM.

Authenticator app compatibility

If you're rendering a QR at enrollment, the values below reach every mainstream 2FA app. Deviate from them only when you control the client.

Universal safe defaults:

  • Algorithm: SHA-1
  • Digits: 6
  • Period: 30 seconds

What each app accepts (as of 2026):

| App | Algorithms | Digits | Period | | ----------------------- | ----------------------- | ------- | ------------ | | Google Authenticator | SHA-1 only | 6 only | 30 s only | | Microsoft Authenticator | SHA-1 only | 6 only | 30 s only | | Twilio Authy | SHA-1, SHA-256 | 6, 7 | 10 or 30 s | | Aegis (Android) | SHA-1, SHA-256, SHA-512 | 6, 7, 8 | 10 s – 60 s+ | | 2FAS (iOS/Android) | SHA-1, SHA-256, SHA-512 | 6, 7, 8 | flexible | | 1Password / Bitwarden | SHA-1, SHA-256, SHA-512 | 6–10 | flexible | | FreeOTP (RedHat) | SHA-1, SHA-256, SHA-512 | 6, 7, 8 | flexible | | Yubico Authenticator | SHA-1, SHA-256, SHA-512 | 6, 8 | 30 or 60 s |

Notes:

  • provisioningUri will refuse to emit SHA-224 / SHA-384 — those work in the raw hotp / totp functions for server-server flows but are not in Google's Key URI Format spec and no Authenticator app parses them.
  • digits > 8 is accepted programmatically (up to 10) because Bitwarden and 1Password support it, but Google / Microsoft users won't be able to enroll.

Replay defense

TOTP within its skew window is technically reusable — a code accepted at T-1 still verifies at T and T+1. Attackers who read a valid code from a phishing page have up to 90 seconds to use it.

The replay option makes verify single-use per counter per key:

import { verifyTotp } from '@exortek/otp';
// Any store exposing an atomic `incr(key, ttlMs)` — the @exortek/security
// stores (memory / Redis / custom) all fit. `incr` is used as a
// compare-and-set so two concurrent requests with the same code can't both pass.
import { rateLimit } from '@exortek/security';

const store = rateLimit.stores.memory();
// or rateLimit.stores.redis(client) for multi-worker deployments

async function verify(userId, code, secret) {
  return verifyTotp(code, secret, {
    window: 1,
    replay: { store, key: `user:${userId}` },
  });
}

Internally: on a successful verify, the matched counter is written to the store with a TTL that covers the remaining acceptance window. Subsequent verifies inside that window with the same code will fail silently — the caller sees a boolean false, no separate reason. Use REPLAY_DETECTED in your own logs if you want to distinguish.

Redis for cluster deployments. For high-security flows across multiple workers / regions, back the replay store with Redis so a code accepted on one worker can't be replayed on another.

Rate limiting

TOTP with a 6-digit code and window: 1 gives an attacker a 3/1,000,000 chance per guess. Multiply by allowed retries and you'll want a throttle in front:

import { rateLimit } from '@exortek/security';

const throttle = rateLimit.sliding({
  requests: 5,
  window: '10m',
  store: rateLimit.stores.memory(),
});

async function verify(userId, code, secret) {
  const rl = await throttle.check({ key: `otp:${userId}` });
  if (!rl.allowed) throw new OtpError(ErrorCode.THROTTLED, `retry in ${rl.retryAfter}s`);
  return verifyTotp(code, secret, {
    window: 1,
    replay: { store: throttle.store, key: `user:${userId}` },
  });
}

Highlights

  • Correct RFC test vectors. Passes RFC 4226 Appendix D + RFC 6238 Appendix B for SHA-1, SHA-256, and SHA-512.
  • Timing-safe compare everywhere. verifyTotp / verifyHotp / compareBackupCode all use crypto.timingSafeEqual on equal-length Buffers.
  • Secure defaults. SHA-1 + 6 digits + 30s period + window: 1 — the only combination every mainstream Authenticator app understands. Everything else opt-in.
  • Unambiguous backup codes. Crockford Base32 alphabet — no 0/O/1/I/L on a printout.
  • Enrollment paste-friendly. decodeSecret accepts spaces, mixed case, hex, and Buffer — matches how users copy from any 2FA app.

Links

License

MIT © ExorTek.