otplib review
otplib implements counter-based HOTP and time-based TOTP for authenticator apps and hardware-token workflows. Version 13 is a TypeScript rewrite with functional and class APIs, Base32 codecs, otpauth URI creation, and crypto plugins for Node, Bun, Deno, and browsers. Version 13.5.0 makes hash selection strict: unsupported algorithm strings now throw AlgorithmUnsupportedError across every crypto plugin instead of behaving differently or silently falling back. The library generates and checks codes. Your application still owns enrollment state, encrypted secret storage, attempt limits, recovery, and replay prevention. Our package test found bundled types and working require() and ESM imports.
otplib 13.5.0 installed with 0 audit findings and made an 11.9 KB gzipped browser bundle in our sandbox; its strict algorithm checks and replay parameters are good foundations for an application-owned MFA flow. Do not install it expecting secret storage, throttling, recovery codes, or a drop-in v12 upgrade.
We installed it
| Install | ✓ · 3.7s | 9 packages on disk · 3 MB |
| Import | ✓ | ESM import works · require() works · ESM package with exports map |
| Browser | 11.9 KB | gzipped (35.8 KB minified), bundled with esbuild |
| Types | ✓ | TypeScript types bundled |
| Known vulns | 0 | 0 critical · 0 high · 0 moderate · 0 low (npm audit) |
Answers from our run
Does otplib install cleanly?
Yes. In a fresh container with an empty cache, npm install otplib finished in 4 seconds, leaving 9 packages and 3 MB on disk. npm audit reported no known vulnerabilities.
How much does otplib add to a browser bundle?
11.9 KB gzipped (35.8 KB minified) when the whole package is bundled for the browser with esbuild. Importing only part of it is usually smaller.
Does otplib work with both ESM and CommonJS?
Yes. Both import 'otplib' and require('otplib') worked in Node 22 in our run. The package is published as ESM with an exports map.
Does otplib include TypeScript types?
Yes, type declarations ship inside the package, so no @types install is needed.
otplib or otpauth: which should you use?
otpauth: Use it for a focused HOTP and TOTP implementation with portable runtime support. otplib 13.5.0 installed with 0 audit findings and made an 11.9 KB gzipped browser bundle in our sandbox; its strict algorithm checks and replay parameters are good foundations for an application-owned MFA flow.
When should you not use otplib?
A v11 or v12 codebase cannot budget a migration; v13 removed the separate authenticator object and changed imports, plugins, secrets, and verify results
Use it if
- An account system needs RFC 6238 codes compatible with mainstream authenticator apps
- HOTP counters and TOTP time steps should share one typed API with verification deltas
- OTP code must run across Node, Bun, Deno, and browsers through explicit crypto plugins
- The login flow will persist accepted time steps or counters to block token replay
- A v11 or v12 codebase cannot budget a migration; v13 removed the separate authenticator object and changed imports, plugins, secrets, and verify results
- You expect a complete MFA service; otplib does not store secrets, issue recovery codes, rate-limit attempts, track enrollment, or recover accounts
- Legacy users have short Base32 secrets and re-enrollment is impossible; v13 rejects secrets below 16 decoded bytes unless a guardrail is deliberately weakened
- External configuration may contain misspelled algorithm names and cannot be cleaned before upgrade; 13.5.0 throws instead of accepting unknown values or falling back
- The chosen authenticator cannot match custom digits, period, or hash settings; the documented broad-compatibility profile is SHA-1, 6 digits, and 30 seconds
- A boolean-returning verifier is required; verify() returns an object, and treating that object itself as truthy accepts failed attempts
Setup reality
Our fresh otplib 13.5.0 install finished in 3.7 seconds under Node 22. It left 9 packages and 3 MB on disk, with 0 known vulnerabilities from npm audit. The package is 668 KB unpacked, has 6 direct dependencies and 0 peers, and includes TypeScript declarations. It is ESM with an exports map, but both require() and ESM import worked. Our all-exports browser bundle measured 35.8 KB minified and 11.9 KB gzipped.
There is no service credential or config file, but every account needs a secret. generateSecret() produces a 20-byte random secret encoded as Base32. Store it encrypted and never log it. Version 13 rejects Base32 input shorter than 16 decoded bytes, so the common JBSWY3DPEHPK3PXP tutorial value throws SecretTooShortError. generateURI() creates an otpauth URI rather than a QR image. Render it with another library and stop exposing the URI once enrollment is confirmed.
verify() is async-first and returns { valid, ... }, never a bare boolean. Read result.valid. TOTP tolerance is 0 by default; epochTolerance can be a [pastSeconds, futureSeconds] tuple when past-only drift is desired. To stop replay, save the last successful timeStep per account and pass it back as afterTimeStep. Persist that update atomically with the login. HOTP needs the same discipline for its counter, advancing to the matched counter plus 1 after successful verification.
Version 13.5.0 accepts sha1, sha256, and sha512 with case-insensitive single-separator aliases such as SHA-1, then throws AlgorithmUnsupportedError for everything else. Older noble-plugin behavior could silently compute SHA-512 for an unknown name, so accounts enrolled through that bug may stop matching after upgrade and need corrected sha512 configuration or re-enrollment. Keep the issuer, digits, period, algorithm, secret encoding, and HOTP counter identical on both sides. Rate-limit attempts and protect recovery routes outside otplib.
Patterns
Create a fresh Base32 enrollment secret generate-secret
import { generateSecret } from 'otplib'
const secret = generateSecret()
await saveEncryptedSecret(userId, secret)generateSecret() defaults to 20 random bytes in version 13.5.0. Encrypt the value at rest and exclude it from logs.
Produce the current TOTP generate-totp
import { generate } from 'otplib'
const token = await generate({ secret })
console.log(token)The default strategy is TOTP, and string secrets are decoded as Base32. Raw passphrases need an explicit byte conversion or alternate plugin.
Read the verification result correctly verify-totp
import { verify } from 'otplib'
const result = await verify({ secret, token: submittedCode })
if (!result.valid) {
throw new Error('Invalid code')
}
console.log(result.delta, result.timeStep)verify() returns an object in version 13. Testing the object itself always sees a truthy value, even when valid is false.
Create an authenticator URI build-enrollment-uri
import { generateURI } from 'otplib'
const uri = generateURI({
issuer: 'Acme',
label: user.email,
secret,
})The result is an otpauth URI, not a QR image. Keep the issuer and label stable, then render the URI with a separate QR package.
Accept one past 30-second step allow-past-drift
const result = await verify({
secret,
token: submittedCode,
epochTolerance: [30, 0],
})The tuple means past and future seconds. [30, 0] avoids accepting the next 30-second token early.
Reject a reused time step prevent-totp-replay
const lastStep = await loadLastTimeStep(userId)
const result = await verify({
secret,
token: submittedCode,
afterTimeStep: lastStep ?? undefined,
epochTolerance: [30, 0],
})
if (result.valid) {
await saveLastTimeStep(userId, result.timeStep)
}afterTimeStep is a counter rather than a Unix timestamp. Save the successful step atomically with the authenticated session.
Generate from a stored HOTP counter generate-hotp
const token = await generate({
strategy: 'hotp',
secret,
counter: 42,
})HOTP requires an explicit counter. Counter 42 must not be reused after its token is accepted.
Look ahead across 5 HOTP counters resync-hotp
const result = await verify({
strategy: 'hotp',
secret,
token: submittedCode,
counter: storedCounter,
counterTolerance: 5,
})
if (result.valid) {
await saveCounter(userId, storedCounter + result.delta + 1)
}A numeric tolerance searches forward. Advance the stored counter to the matched position plus 1 in the same transaction.
Use an explicit supported digest set-algorithm
const token = await generate({
secret,
algorithm: 'sha256',
digits: 8,
period: 60,
})Version 13.5.0 accepts sha1, sha256, and sha512 plus documented aliases. Unsupported strings throw AlgorithmUnsupportedError.
Reject bad external hash configuration catch-invalid-algorithm
import { AlgorithmUnsupportedError, generate } from 'otplib'
try {
return await generate({ secret, algorithm: configuredAlgorithm })
} catch (error) {
if (error instanceof AlgorithmUnsupportedError) {
throw new Error('Unsupported OTP algorithm')
}
throw error
}Do not silently substitute a digest. Version 13.5.0 makes all crypto plugins reject an unknown algorithm consistently.
Share strategy through an OTP instance use-class-api
import { OTP } from 'otplib'
const otp = new OTP({ strategy: 'totp' })
const secret = otp.generateSecret()
const token = await otp.generate({ secret })
const result = await otp.verify({ secret, token })The functional API is simpler for isolated calls. OTP is useful when the same strategy or plugin setup belongs to several operations.
Mark raw secret bytes explicitly pass-binary-secret
import { generate, stringToBytes } from 'otplib'
const rawSecret = stringToBytes(process.env.OTP_PASSPHRASE)
const token = await generate({ secret: rawSecret })String inputs are Base32 by default. Uint8Array distinguishes raw bytes, but the 16-byte minimum guardrail still applies.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| otpauth | npm | Use it for a focused HOTP and TOTP implementation with portable runtime support |
| @oslojs/otp | npm | Use it when small explicit OTP primitives fit an application-owned enrollment flow |
| @epic-web/totp | npm | Use it for TOTP-only work built around Web Crypto-compatible runtimes |
More security guides
cryptography · pyjwt · jose · requests-oauthlib · oauthlib · dompurify · the whole shelf →
How this guide is made: grounded in the library's documentation, release notes, changelog, and issue history, on a fixed rubric — not a hands-on install of every release. The 50 most-downloaded entries are additionally install-verified in clean containers. Corrections: contact the desk.

