otp-validator-totp
v1.0.2
Published
A zero-dependency, production-ready TOTP (Time-Based One-Time Password) generator and validator using Node.js native crypto.
Downloads
353
Maintainers
Readme
otp-validator-totp
A zero-dependency, production-ready npm package for generating and validating Time-Based One-Time Passwords (TOTP) with built-in replay attack protection.
Built entirely on Node.js native crypto module — no otplib, no speakeasy, no external libraries.
Features
- 🔐 HMAC-SHA256 hashing with RFC 4226 Dynamic Truncation
- ⏱️ Time-based — stateless core, no database required
- 🛡️ Anti-replay protection via pluggable storage adapters (IoC pattern)
- 🔒 Timing-safe comparison via
crypto.timingSafeEqual - 🕐 ±1 time-window drift tolerance for clock skew / network latency
- 📦 Zero runtime dependencies
- 🔷 Written in TypeScript with full type declarations
Installation
npm install otp-validator-totpQuick Start
import { generateOTP, validateOTP } from 'otp-validator-totp';
const SECRET = process.env.OTP_SECRET!;
const TTL = 300; // 5 minutes
// --- Generate ---
const otp = generateOTP('[email protected]', TTL, SECRET);
console.log(`Your OTP is: ${otp}`); // e.g. "482913"
// Send this to the user via SMS, email, etc.
// --- Validate (with built-in anti-replay) ---
const isValid = await validateOTP({
userId: '[email protected]',
userProvidedOtp: otp,
secretKey: SECRET,
ttlSeconds: TTL,
});
console.log(isValid); // true (first use)
// Same OTP again → blocked (replay attack)
const isReplay = await validateOTP({
userId: '[email protected]',
userProvidedOtp: otp,
secretKey: SECRET,
ttlSeconds: TTL,
});
console.log(isReplay); // falseAPI Reference
generateOTP(userId, ttlSeconds, secretKey)
Generates a 6-digit TOTP. Synchronous.
| Parameter | Type | Description |
| ------------ | -------- | ---------------------------------------- |
| userId | string | Unique identifier for the user |
| ttlSeconds | number | Validity window of the OTP in seconds |
| secretKey | string | Backend's private secret used for hashing |
Returns: string — A 6-digit OTP, zero-padded.
validateOTP(options)
Validates a user-provided OTP with optional anti-replay protection. Asynchronous.
| Option | Type | Required | Description |
| ----------------- | ----------------------- | -------- | ------------------------------------------------------------------- |
| userId | string | ✅ | Unique identifier for the user |
| userProvidedOtp | string | ✅ | The 6-digit OTP from the client |
| secretKey | string | ✅ | Backend's private secret |
| ttlSeconds | number | ✅ | Validity window in seconds |
| store | IStore | false | ❌ | Anti-replay store. Defaults to built-in MemoryStore. Pass false to disable. |
Returns: Promise<boolean> — true if valid and not replayed, false otherwise.
Anti-Replay Protection
By default, validateOTP uses a built-in in-memory store to block replay attacks — the same OTP cannot be used twice. This is perfect for single-process apps.
For multi-server deployments, you can plug in any storage backend by implementing the IStore interface.
The IStore Interface
import type { IStore } from 'otp-validator-totp';
interface IStore {
checkAndStore(
userId: string,
timeBlock: number,
ttlSeconds: number,
): boolean | Promise<boolean>;
}- Return
true→ first use (valid) - Return
false→ already consumed (replay attack)
Custom Adapter: Redis
import { createClient } from 'redis';
import type { IStore } from 'otp-validator-totp';
import { validateOTP } from 'otp-validator-totp';
const redis = createClient();
await redis.connect();
class RedisStore implements IStore {
async checkAndStore(
userId: string,
timeBlock: number,
ttlSeconds: number,
): Promise<boolean> {
const key = `otp:${userId}:${timeBlock}`;
// SET with NX (only if not exists) + automatic expiry
const result = await redis.set(key, '1', {
NX: true,
EX: ttlSeconds * 3, // 3× TTL covers ±1 drift window
});
return result === 'OK'; // null means key already existed → replay
}
}
// Use it:
const isValid = await validateOTP({
userId: '[email protected]',
userProvidedOtp: '482913',
secretKey: process.env.OTP_SECRET!,
ttlSeconds: 300,
store: new RedisStore(),
});Custom Adapter: Prisma / PostgreSQL
import { PrismaClient } from '@prisma/client';
import type { IStore } from 'otp-validator-totp';
import { validateOTP } from 'otp-validator-totp';
const prisma = new PrismaClient();
class PrismaStore implements IStore {
async checkAndStore(
userId: string,
timeBlock: number,
ttlSeconds: number,
): Promise<boolean> {
try {
// Unique constraint on (userId, timeBlock) prevents duplicates
await prisma.usedOTP.create({
data: {
userId,
timeBlock,
expiresAt: new Date(Date.now() + ttlSeconds * 3 * 1000),
},
});
return true; // Insert succeeded → first use
} catch (error: any) {
if (error.code === 'P2002') {
return false; // Unique constraint violation → replay
}
throw error; // Re-throw unexpected errors
}
}
}
// Prisma schema addition:
// model UsedOTP {
// id Int @id @default(autoincrement())
// userId String
// timeBlock Int
// expiresAt DateTime
// @@unique([userId, timeBlock])
// }
const isValid = await validateOTP({
userId: '[email protected]',
userProvidedOtp: '482913',
secretKey: process.env.OTP_SECRET!,
ttlSeconds: 300,
store: new PrismaStore(),
});Disabling Replay Protection
For testing or stateless scenarios, pass store: false:
const isValid = await validateOTP({
userId: '[email protected]',
userProvidedOtp: otp,
secretKey: SECRET,
ttlSeconds: 300,
store: false, // Math-only validation, no replay check
});How It Works
- Time Block —
Math.floor(Date.now() / 1000 / ttlSeconds)divides time into fixed-size windows. - HMAC Key — The
secretKeyanduserIdare concatenated to form a per-user HMAC key. - HMAC-SHA256 — The time block is encoded as an 8-byte Big Endian buffer and hashed.
- Dynamic Truncation (RFC 4226) — A 4-byte slice is extracted from the hash, masked to 31 bits, then reduced modulo 10⁶ to produce a 6-digit code.
- Validation — The validator regenerates OTPs for blocks
[current−1, current, current+1]and compares usingcrypto.timingSafeEqual. - Anti-Replay — On a successful math match, the store marks the
(userId, timeBlock)pair as consumed, blocking reuse.
Project Structure
src/
├── core.ts # Shared helpers: validation, time blocks, HMAC truncation
├── generator.ts # generateOTP (synchronous)
├── validator.ts # validateOTP (asynchronous, with store integration)
├── store.ts # IStore interface + MemoryStore implementation
└── index.ts # Public API re-exportsSecurity Notes
- Never expose
secretKeyto the client. OTP generation and validation should happen server-side only. - The ±1 window drift means an OTP is valid for up to 3× the TTL in the worst case. Choose your TTL accordingly.
- All comparisons use constant-time equality to mitigate timing side-channel attacks.
- Always enable anti-replay in production to prevent OTP reuse.
License
MIT
