JSPM

otp-validator-totp

1.0.2
  • ESM via JSPM
  • ES Module Entrypoint
  • Export Map
  • Keywords
  • License
  • Repository URL
  • TypeScript Types
  • README
  • Created
  • Published
  • Downloads 12
  • Score
    100M100P100Q77300F
  • License MIT

A zero-dependency, production-ready TOTP (Time-Based One-Time Password) generator and validator using Node.js native crypto.

Package Exports

  • otp-validator-totp
  • otp-validator-totp/dist/index.js

This package does not declare an exports field, so the exports above have been automatically detected and optimized by JSPM instead. If any package subpath is missing, it is recommended to post an issue to the original package (otp-validator-totp) to support the "exports" field. If that is not possible, create a JSPM override to customize the exports field for this package.

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-totp

Quick Start

import { generateOTP, validateOTP } from 'otp-validator-totp';

const SECRET = process.env.OTP_SECRET!;
const TTL    = 300; // 5 minutes

// --- Generate ---
const otp = generateOTP('user@example.com', 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: 'user@example.com',
  userProvidedOtp: otp,
  secretKey: SECRET,
  ttlSeconds: TTL,
});
console.log(isValid); // true (first use)

// Same OTP again β†’ blocked (replay attack)
const isReplay = await validateOTP({
  userId: 'user@example.com',
  userProvidedOtp: otp,
  secretKey: SECRET,
  ttlSeconds: TTL,
});
console.log(isReplay); // false

API 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` ❌

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: 'user@example.com',
  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: 'user@example.com',
  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: 'user@example.com',
  userProvidedOtp: otp,
  secretKey: SECRET,
  ttlSeconds: 300,
  store: false, // Math-only validation, no replay check
});

How It Works

  1. Time Block β€” Math.floor(Date.now() / 1000 / ttlSeconds) divides time into fixed-size windows.
  2. HMAC Key β€” The secretKey and userId are concatenated to form a per-user HMAC key.
  3. HMAC-SHA256 β€” The time block is encoded as an 8-byte Big Endian buffer and hashed.
  4. 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.
  5. Validation β€” The validator regenerates OTPs for blocks [currentβˆ’1, current, current+1] and compares using crypto.timingSafeEqual.
  6. 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-exports

Security Notes

  • Never expose secretKey to 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