@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, orconfigcode. 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:
POST /otp/issuewith{"userId": "1"}— read the logged code from the console.POST /otp/verifywith{"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:
savepersists a signed otp token per channel, never the plaintext code. The plaintext is only ever passed tosend.- One
otpSecretcovers every channel issued in the same call. It's a single field on your owner record, not one per channel. clearruns once, after a fully verified result, unless you setclearOnVerified: 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: falseremoves a channel from the flow entirely. It's never issued, never required, never verified, and never appears inissue()'s orverify()'schannelslist.isDeliveryEnabled: falsekeeps every enabled channel issuing and requiring a code, but skips thesendcall itself. This is usually what a non-production environment wants: pair it with a fixedgenerateCode(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
otpRegenerateAllowedAfterthe 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]
otpSecretis 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 sharingowner.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.storeis yours to implement. - Send anything. No email client, no SMS client, no provider
credentials.
sendis yours to implement. - Sign or verify its own tokens.
signTokenandverifyTokenare yours; a second implementation here would drift from the one the rest of your app trusts. - Read environment or config. Nothing here reads
process.envor a config file for you. Anything that varies by environment, such asisDeliveryEnabledorgenerateCode, 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 # eslintThis package is part of the package-otp workspaces monorepo — run the commands
from the repository root, not this directory.
License
MIT © Desolint — see LICENSE.
