Skip to content

Hooks ​

Hooks let you customise how OTP tokens are encoded and validated, enabling non-standard OTP variants (e.g., Steam Guard) without modifying the core library.

When no hooks are provided, the standard RFC 4226 numeric encoding is used.

The OTPHooks Type ​

typescript
import type { OTPHooks } from "@otplib/core";

type OTPHooks = {
  readonly truncateDigest?: (hmacResult: Uint8Array) => number;
  readonly encodeToken?: (truncatedValue: number, digits: number) => string;
  readonly validateToken?: (token: string, digits: number) => void;
};
HookPurposeDefault Behaviour
truncateDigestExtracts a 31-bit integer from the raw HMAC digestRFC 4226 dynamic truncation
encodeTokenConverts the 31-bit truncated HMAC integer into a token stringDecimal modulo (10^digits) zero-padded
validateTokenChecks that a token string is well-formed before verificationAsserts digits-only and correct length

How Hooks Integrate ​

Hooks are passed via the hooks option on generate / verify (and their sync variants). They apply at the HOTP layer and propagate automatically through TOTP.

HMAC digest (Uint8Array)
      │
      ├─── truncateDigest provided? ──► hooks.truncateDigest(hmac)
      └─── no                       ──► dynamicTruncate(hmac)
      │
      ▼
31-bit integer
      │
      ├─── encodeToken provided? ──► hooks.encodeToken(value, digits)
      └─── no                    ──► truncateDigits(value, digits)
      │
      ▼
 token string

During verification the same flow applies in reverse — validateToken is called before the token is compared:

token string
      │
      ├─── validateToken provided? ──► hooks.validateToken(token, digits)
      └─── no                      ──► default digit-only check
      │
      ▼
 compare against generated tokens

Usage ​

Generation ​

typescript
import { generate } from "otplib";

const token = await generate({
  secret,
  hooks: {
    encodeToken: myEncodeToken,
  },
});

Verification ​

When using a custom encoding, provide both hooks so that the library validates the token format correctly:

typescript
import { verify } from "otplib";

const result = await verify({
  secret,
  token,
  hooks: {
    encodeToken: myEncodeToken,
    validateToken: myValidateToken,
  },
});

Both Hooks Together

If you provide encodeToken, you should also provide validateToken. Without it, the default validator rejects any token that is not purely numeric — which will cause verification to fail for non-numeric encodings.

Synchronous API ​

Hooks work identically with generateSync and verifySync:

typescript
import { generateSync, verifySync } from "otplib";

const token = generateSync({
  secret,
  hooks: { encodeToken: myEncodeToken },
});

const result = verifySync({
  secret,
  token,
  hooks: {
    encodeToken: myEncodeToken,
    validateToken: myValidateToken,
  },
});

Writing Custom Hooks ​

truncateDigest ​

Your truncateDigest function receives:

ParameterTypeDescription
hmacResultUint8ArrayRaw HMAC digest (20–64 bytes)

It must return a 31-bit unsigned integer (0–2,147,483,647).

Example: Static truncation (always use the first 4 bytes instead of the dynamic offset):

typescript
function staticTruncate(hmacResult: Uint8Array): number {
  return (
    ((hmacResult[0] & 0x7f) << 24) | (hmacResult[1] << 16) | (hmacResult[2] << 8) | hmacResult[3]
  );
}

Using it:

typescript
import { generate } from "otplib";

const token = await generate({
  secret,
  hooks: { truncateDigest: staticTruncate },
});

encodeToken ​

Your encodeToken function receives:

ParameterTypeDescription
truncatedValuenumber31-bit unsigned integer from HMAC dynamic truncation
digitsnumberDesired token length

It must return a string of the appropriate length.

validateToken ​

Your validateToken function receives:

ParameterTypeDescription
tokenstringToken to validate
digitsnumberExpected token length

It should throw an error if the token is malformed. Returning without throwing means the token format is valid.

Guidelines ​

  • Keep hooks pure and deterministic — the same inputs must always produce the same output.
  • encodeToken should produce tokens of exactly digits length.
  • validateToken should reject anything that encodeToken could not have produced.
  • Hooks are called on every generate/verify invocation, so keep them lightweight.

Hooks vs Plugins ​

AspectPluginsHooks
PurposeSwap infrastructure (crypto backend, Base32 lib)Customise OTP behaviour (token format)
ScopeAffects HMAC computation or secret encodingAffects token encoding and validation only
Examplesplugin-crypto-node, plugin-base32-scureSteam Guard encoding, custom alphabets
RequiredYes (crypto plugin is mandatory)No (defaults to RFC 4226 numeric encoding)

Examples ​

Steam Guard Encoding ​

Steam Guard uses a 5-character alphanumeric token instead of the standard 6-digit numeric OTP.

A note on the examples

The following provides a working code but is not exhuastively tested.

Steam may change their implementation any time.

typescript
const STEAM_CHARS = "23456789BCDFGHJKMNPQRTVWXY";

function steamEncodeToken(truncatedValue: number, digits: number): string {
  let code = "";
  let value = truncatedValue;
  for (let i = 0; i < digits; i++) {
    code += STEAM_CHARS[value % STEAM_CHARS.length];
    value = Math.floor(value / STEAM_CHARS.length);
  }
  return code;
}

function steamValidateToken(token: string, digits: number): void {
  if (token.length !== digits) {
    throw new Error(`Expected ${digits} characters, got ${token.length}`);
  }
  for (const ch of token) {
    if (!STEAM_CHARS.includes(ch)) {
      throw new Error(`Invalid character: ${ch}`);
    }
  }
}

Using it:

typescript
import { generate, verify } from "otplib";

const steamHooks = {
  encodeToken: steamEncodeToken,
  validateToken: steamValidateToken,
};

// Generate a Steam Guard-style token (5 characters)
const token = await generate({
  secret,
  digits: 5,
  hooks: steamHooks,
});
// e.g., "V4XBK"

// Verify
const result = await verify({
  secret,
  token,
  digits: 5,
  hooks: steamHooks,
});

MOTP-Style Hex Encoding ​

Mobile-OTP (mOTP) produces short hexadecimal tokens instead of decimal digits. The actual mOTP algorithm uses MD5 internally, but you can achieve the same hex-encoded token format on top of the standard HOTP/TOTP pipeline using hooks.

Not a full mOTP implementation

True mOTP replaces the entire HMAC step with MD5(epoch/10s + secret + PIN). The example below only reproduces the hex-encoded output format — the underlying HMAC computation still follows RFC 4226. If you need full mOTP compatibility, you would also need a custom crypto plugin.

typescript
function motpEncodeToken(truncatedValue: number, digits: number): string {
  return truncatedValue.toString(16).toLowerCase().slice(0, digits).padStart(digits, "0");
}

function motpValidateToken(token: string, digits: number): void {
  if (token.length !== digits) {
    throw new Error(`Expected ${digits} characters, got ${token.length}`);
  }
  if (!/^[0-9a-f]+$/.test(token)) {
    throw new Error("Token must contain only hex characters (0-9, a-f)");
  }
}

Using it:

typescript
import { generate, verify } from "otplib";

const motpHooks = {
  encodeToken: motpEncodeToken,
  validateToken: motpValidateToken,
};

// Generate a 6-character hex token
const token = await generate({
  secret,
  digits: 6,
  hooks: motpHooks,
});
// e.g., "a3f1b2"

// Verify
const result = await verify({
  secret,
  token,
  digits: 6,
  hooks: motpHooks,
});

Released under the MIT License.