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

@desolint/otp-server

v0.0.1

Published

OTP generation, verification and delivery for Desol Int. projects

Readme

@desolint/otp-server

One-time-password issuing and verification for Node backends.

  • Every side effect is injected, not built in — JWT signing, hashing, persistence, delivery. The same package fits a service that already has its own token and hashing utilities just as well as a brand-new one that has none of that yet.
  • No bundled express, jsonwebtoken, bcrypt, or config code. You supply five plain functions; this package composes them into multi-channel OTP issuing and verification.
  • Never throws on a bad or missing code. Verification returns a typed result per channel instead.

Requirements

  • Node.js 22 or newer
  • No peer dependencies of any kind — see Install

Install

npm install @desolint/otp-server

@desolint/otp-shared is a pinned regular dependency and is installed automatically; you never install it yourself. There is nothing else to install — this package wraps no library, so whatever JWT signer and hasher your project already uses can be passed in directly.


Quick start

A complete, runnable file for a brand-new Express app — no database, email provider, or SMS provider required to try it. It logs the code instead of sending it.

npm install express @desolint/otp-server jsonwebtoken bcryptjs
// server.js
const express = require('express');
const jwt = require('jsonwebtoken');
const bcrypt = require('bcryptjs');
const {createOtpService} = require('@desolint/otp-server');

const JWT_SECRET = 'replace-with-a-real-secret';

// The five things otp-server needs — plain functions, nothing otp-specific.
const signToken = ({payload, expiry, tokenSecret}) => {
  const secret = tokenSecret ?? JWT_SECRET;
  const token = jwt.sign(
    payload,
    secret,
    expiry ? {expiresIn: expiry} : undefined
  );
  return {token, secretKey: tokenSecret ?? null};
};
const verifyToken = ({token, dbSecretKey}) => {
  try {
    return jwt.verify(token, dbSecretKey ?? JWT_SECRET);
  } catch {
    return false;
  }
};
const decodeToken = ({token}) => {
  try {
    return jwt.decode(token);
  } catch {
    return false;
  }
};
const hashString = async ({string}) => bcrypt.hash(string, 10);
const compareHashedString = async ({simpleString, hashedString}) =>
  bcrypt.compare(simpleString, hashedString);

// Stand-in for your real database — same load/save/clear shape either way.
const usersById = new Map();
function getOrCreateUser(userId) {
  if (!usersById.has(userId)) {
    usersById.set(userId, {
      id: userId,
      email: `${userId}@example.com`,
      otps: {},
      otpSecret: undefined,
    });
  }
  return usersById.get(userId);
}

const otpService = createOtpService({
  signToken,
  verifyToken,
  decodeToken,
  hashString,
  compareHashedString,

  channels: {
    email: {
      // Swap this for a real email call once you're ready — logging is
      // enough to try the whole issue/verify flow right now.
      send: ({to, code}) => console.log(`OTP for ${to.email}: ${code}`),
    },
  },

  store: {
    load: async ({owner}) => ({
      otps: {email: owner.otps.email},
      otpSecret: owner.otpSecret,
    }),
    save: async ({owner, otps, otpSecret}) => {
      owner.otps = {...owner.otps, ...otps};
      owner.otpSecret = otpSecret;
    },
    clear: async ({owner}) => {
      owner.otps = {};
      owner.otpSecret = undefined;
    },
  },

  getRecipient: ({owner}) => ({email: owner.email}),
  getTokenPayload: ({owner}) => ({userId: owner.id}),
});

const app = express();
app.use(express.json());

app.post('/otp/issue', async (req, res) => {
  const owner = getOrCreateUser(req.body.userId);
  res.json(await otpService.issue({owner}));
});

app.post('/otp/verify', async (req, res) => {
  const owner = getOrCreateUser(req.body.userId);
  const {isVerified, results} = await otpService.verify({
    owner,
    codes: {email: req.body.emailOtp},
  });
  if (!isVerified) return res.status(400).json({results});
  res.json({ok: true});
});

app.listen(4000, () => console.log('listening on http://localhost:4000'));

Try it end to end:

  1. POST /otp/issue with {"userId": "1"} — read the logged code from the console.
  2. POST /otp/verify with {"userId": "1", "emailOtp": "<the logged code>"}.

For a fuller reference version of this — multiple channels, a resend cooldown, a debug route for reading the last "sent" code — see expressapp/ in this repository.


The five things you provide

Every crypto operation is a plain function you pass in, typed as a structural shape rather than tied to a specific library. If your project already has a JWT util and a hashing util, check the signatures below and wire them straight in — no adapter layer needed.

| Name | Signature | What it must do | | --------------------- | ----------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | | signToken | ({payload, expiry?, tokenSecret?}) => {token, secretKey?} | Signpayload into a JWT whose exp reflects expiry (seconds), under a secret derived from tokenSecret when one is given. | | verifyToken | ({token, dbSecretKey?}) => claims \| false | Verify and decode, returning a falsy value on any failure. Expired, tampered, and wrong-secret tokens do not need to be distinguishable here. | | decodeToken | ({token}) => claims \| null \| false | Decode without verifying the signature. Used only to readexp off a token that already failed verification, to tell 'expired' apart from 'invalid'. | | hashString | ({string}) => Promise<string> | One-way and salted, so the same input produces a different digest on each call. | | compareHashedString | ({simpleString, hashedString}) => Promise<boolean> | Constant-time comparison against a digesthashString produced. |

If one of these throws — a bad key, a hashing fault — the error propagates to your caller unchanged. That's an infrastructure failure rather than a domain outcome, and handling it is left to you.

The quickstart above wires all five with jsonwebtoken and bcryptjs; that snippet is the complete set, and none of it is specific to otp-server — it's the same JWT and hashing setup most Node services already need for sessions or password storage.


Persistence: implementing store

store: {
  load: async ({owner}) => ({
    otps: {email: owner.otps.email, phone: owner.otps.phone},
    otpSecret: owner.otpSecret,
  }),
  save: async ({owner, otps, otpSecret}) => {
    await db.users.update(owner.id, {otps, otpSecret});
  },
  clear: async ({owner}) => {
    await db.users.update(owner.id, {otps: {}, otpSecret: null});
  },
}

There's no assumed schema, no query, and no .save() call made on your behalf — store is called, and what it does with each call is entirely up to you. Three things are worth knowing:

  • save persists a signed otp token per channel, never the plaintext code. The plaintext is only ever passed to send.
  • One otpSecret covers every channel issued in the same call. It's a single field on your owner record, not one per channel.
  • clear runs once, after a fully verified result, unless you set clearOnVerified: false. This is coarse by design. If a flow needs to keep one channel's otp alive while clearing another, call the core functions directly instead (see single-channel flows below) and control that granularity yourself.

Delivery: implementing send

channels: {
  email: {send: ({to, code}) => sendEmail({to: to.email, body: `Code: ${code}`})},
  phone: {
    isEnabled: featureFlags.smsOtp,
    send: ({to, code}) => sendSms({to: to.phoneNo, body: `Code: ${code}`}),
  },
},
isDeliveryEnabled: process.env.NODE_ENV === 'production',
onDeliveryError: ({channel, error}) => logger.error(`otp delivery failed on ${channel}`, error),

isEnabled (per channel) and isDeliveryEnabled (global) are two separate switches:

  • isEnabled: false removes a channel from the flow entirely. It's never issued, never required, never verified, and never appears in issue()'s or verify()'s channels list.
  • isDeliveryEnabled: false keeps every enabled channel issuing and requiring a code, but skips the send call itself. This is usually what a non-production environment wants: pair it with a fixed generateCode (below) so a developer can type a known code instead of reading a real inbox.
generateCode: process.env.NODE_ENV === 'production' ? undefined : () => '123456',

By default generateOtpCode (and therefore issueOtps/createOtpService) draws a 6-digit numeric code. Pass charset: 'alphanumeric' for an uppercase-letters-and-digits code instead (or 'alpha' for letters only) — matching the charset prop @desolint/otp-client's OtpCard accepts on the input side:

generateCode: () => generateOtpCode({length: 6, charset: 'alphanumeric'}),

An explicit alphabet string always wins over charset if both are given.

send is called fire-and-forget: its result is never awaited, and a rejection or a synchronous throw is routed to onDeliveryError rather than reaching your response. Persistence always completes before any send is attempted, so a failing provider can never leave you with a delivered code that was never actually stored.


Enforcing a resend cooldown

issue() stamps a regenerateAllowedAfter timestamp into the returned flow token, but the package itself never checks it. Whether a resend is allowed is a decision your route makes, over claims you decode yourself:

import {getSecondsRemaining} from '@desolint/otp-server';

function secondsUntilResendAllowed(previousFlowToken) {
  if (!previousFlowToken) return 0;
  const claims = verifyToken({token: previousFlowToken});
  if (!claims || typeof claims === 'string') return 0;
  return getSecondsRemaining({until: claims.otpRegenerateAllowedAfter});
}

app.post('/signup/send-otp', async (req, res) => {
  const retryAfter = secondsUntilResendAllowed(req.body.previousFlowToken);
  if (retryAfter > 0) return res.status(429).json({retryAfter});

  const owner = await db.users.findById(req.body.userId);
  res.json(await otpService.issue({owner}));
});

[!WARNING] Verify the previous token; do not just decode it. A decode-only check trusts whatever otpRegenerateAllowedAfter the caller's token claims, including a hand-crafted one claiming a timestamp in the past, which would skip the cooldown entirely.

The window is configured with regenerateAfterSeconds in the service config, and defaults to 30 seconds.


A production-shaped example

A signup flow that emails and texts a code, then verifies both — the same createOtpService from the quickstart, extended with real persistence, delivery, and the store/delivery/cooldown behavior described above.

import {createOtpService} from '@desolint/otp-server';
import {
  signToken,
  verifyToken,
  decodeToken,
  hashString,
  compareHashedString,
} from './crypto';
import {sendEmail} from './email';
import {sendSms} from './sms';
import {db} from './db';

const otpService = createOtpService({
  signToken,
  verifyToken,
  decodeToken,
  hashString,
  compareHashedString,

  channels: {
    email: {
      send: ({to, code}) =>
        sendEmail({to: to.email, subject: 'Your code', body: `Code: ${code}`}),
    },
    phone: {
      send: ({to, code}) =>
        sendSms({to: to.phoneNo, body: `Your code is ${code}`}),
    },
  },

  store: {
    load: async ({owner}) => ({
      otps: {email: owner.otps.email, phone: owner.otps.phone},
      otpSecret: owner.otpSecret,
    }),
    save: async ({owner, otps, otpSecret}) => {
      await db.users.update(owner.id, {otps, otpSecret});
    },
    clear: async ({owner}) => {
      await db.users.update(owner.id, {
        otps: {email: null, phone: null},
        otpSecret: null,
      });
    },
  },

  getRecipient: ({owner}) => ({email: owner.email, phoneNo: owner.phoneNo}),
  getTokenPayload: ({owner}) => ({userId: owner.id}),

  isDeliveryEnabled: process.env.NODE_ENV === 'production',
  onDeliveryError: ({channel, error}) =>
    logger.error(`otp delivery failed on ${channel}`, error),
});

// -- route handlers --

app.post('/signup/send-otp', async (req, res) => {
  const owner = await db.users.findById(req.body.userId);
  const {token, expiresAt, regenerateAllowedAfter, channels} =
    await otpService.issue({owner});
  res.json({token, expiresAt, regenerateAllowedAfter, channels});
});

app.post('/signup/verify-otp', async (req, res) => {
  const owner = await db.users.findById(req.body.userId);
  const {isVerified, results} = await otpService.verify({
    owner,
    codes: {email: req.body.emailOtp, phone: req.body.phoneOtp},
  });
  if (!isVerified) return res.status(400).json({results});
  res.json({ok: true});
});

The shape of every call site is issue({owner}) on the way out and verify({owner, codes}) on the way back. Neither the otp code, the otp token, nor the secret ever leaves issue; the response carries only a flow token for the client to hold onto and the list of channels in play. The plaintext code is only ever passed to send.


Single-channel and partial flows

createOtpService is assembled from a small set of stateless functions — generateOtpCode, issueOtps, verifyOtps, createOtpToken, getMissingOtpCodes — and adds no capability beyond what they already have. Its channel set is fixed at configuration time, though: every issue()/verify() call touches every enabled channel. Most apps want exactly that. When one flow needs a different subset — an email-only verification step partway through onboarding, with no phone code involved at all — call the underlying functions directly instead. They take channels as a per-call argument:

import {issueOtps, verifyOtps, createOtpToken} from '@desolint/otp-server';

app.post('/verify-email/send-otp', async (req, res) => {
  const owner = await db.users.findById(req.body.userId);

  const {otpSecret, otps} = await issueOtps({
    channels: ['email'],
    signToken,
    hashString,
  });

  await db.users.update(owner.id, {otps: {email: otps.email.token}, otpSecret});
  await sendEmail({to: owner.email, body: `Code: ${otps.email.code}`});

  const {token, expiresAt, regenerateAllowedAfter} = createOtpToken({
    payload: {userId: owner.id},
    signToken,
  });
  res.json({token, expiresAt, regenerateAllowedAfter});
});

app.post('/verify-email/verify-otp', async (req, res) => {
  const owner = await db.users.findById(req.body.userId);

  const {isVerified, results} = await verifyOtps({
    channels: ['email'],
    codes: {email: req.body.emailOtp},
    otps: {email: owner.otps.email},
    otpSecret: owner.otpSecret,
    verifyToken,
    decodeToken,
    compareHashedString,
  });

  if (isVerified) await db.users.update(owner.id, {'otps.email': null});
  res.json({isVerified, results});
});

[!NOTE] otpSecret is a single value per owner, shared across whichever channels were last issued together. Issuing email-only after a full issue — or the reverse — for the same owner rotates that shared secret, which invalidates any other still-outstanding code for that owner. This follows directly from one secret covering every channel; it's expected behavior, not a bug. If two flows for the same owner can genuinely be in progress at once, give them separate secret storage rather than sharing owner.otpSecret.


Handling verification results

verify() (and verifyOtps) never throws for a wrong or missing code. Instead it returns a status per channel, which you map to your own errors:

const {isVerified, results} = await otpService.verify({owner, codes});

if (!isVerified) {
  const failed = Object.entries(results).filter(
    ([, status]) => status !== 'verified'
  );
  // [['phone', 'expired']], [['email', 'mismatch'], ['phone', 'missing']], etc.
  return res.status(400).json({
    error: 'otp_verification_failed',
    details: Object.fromEntries(failed),
  });
}

| Status | Meaning | | ---------- | --------------------------------------------------------------- | | verified | Submitted code matched a live otp. | | mismatch | Otp is live, submitted code is wrong. | | expired | Otp token parsed but is past itsexp. | | missing | No otp stored, or no code submitted, for this channel. | | invalid | Otp token failed the signature/secret check, or is unparseable. |

Every requested channel receives a result; there's no short-circuit on the first failure, so a caller can report exactly which code was wrong rather than only that verification failed.


API

| Export | What it is | | ------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | | generateOtpCode(args?) | One random code. Pure,node:crypto-backed, no modulo bias. charset: 'numeric' \| 'alpha' \| 'alphanumeric', or a custom alphabet. | | issueOtps(args) | Generates, hashes, and signs an otp per channel. Persists and sends nothing. | | verifyOtps(args) | Checks submitted codes against stored otp tokens. Consumes nothing. | | createOtpToken(args) | Mints the flow token handed back to the client. | | getMissingOtpCodes(args) | Reports which required channels have no submitted code yet. | | createOtpService(config) | Binds the above to your adapters, channels, and store; returns{issue, verify, getMissingCodes, createToken}. | | getCurrentEpochSeconds(), getSecondsRemaining(args) | Re-exported from@desolint/otp-shared — the package's one shared unit of time, epoch seconds throughout. |

Every function takes zero arguments or a single object, never a positional list.


What this package won't do

  • Persist anything. No database, no .save(), no assumed schema. store is yours to implement.
  • Send anything. No email client, no SMS client, no provider credentials. send is yours to implement.
  • Sign or verify its own tokens. signToken and verifyToken are yours; a second implementation here would drift from the one the rest of your app trusts.
  • Read environment or config. Nothing here reads process.env or a config file for you. Anything that varies by environment, such as isDeliveryEnabled or generateCode, is passed in explicitly.
  • Define error classes or HTTP status codes. A verification result is yours to map onto whatever your app already uses.
  • Sequence a request flow. Fetching a user, deciding whether to resend, verifying, and continuing all reach into your own model and your own error handling. That composition stays a few lines in your route rather than middleware this package ships.

Development

npm install        # install dependencies (from the repo root)
npm run build      # build all three packages
npm test           # type-check + jest
npm run lint       # eslint

This package is part of the package-otp workspaces monorepo — run the commands from the repository root, not this directory.


License

MIT © Desolint — see LICENSE.