Troubleshooting
Common issues and solutions when using otplib.
OTP not matching Google Authenticator
Legacy authenticators may accept any plain text as a secret without validating it as Base32. This can cause mismatches between tokens generated by otplib (which expects Base32 by default) and the authenticator app.
Normalizing Non-Base32 Strings
If your tokens do not match the authenticator output, it might be due to character normalization performed by the app.
You can use the following helper to normalize your secret string:
// This is not included in this library.
// Common normalisation undertaken in past Google Authenticators.
function normaliseCharset(input) {
return input
.toUpperCase()
.replace(/ /g, "") // Remove spaces
.replace(/1/g, "I") // Replace '1' with 'I' (capital i)
.replace(/0/g, "O"); // Replace '0' with 'O' (capital o)
}
const token = generate({
secret: normaliseCharset("1234567123456712"),
// ... other options
});TIP
You might need to adjust the library guardrails to allow for shorter secrets. See Danger Zone - Guardrails for more information.
Token Verification Failures
"Token is always invalid"
The most common causes of verification failures:
1. Clock Drift (TOTP)
TOTP tokens are time-sensitive. If the server and client clocks are out of sync, verification will fail.
// Increase tolerance to allow for clock drift
const result = await verify({
secret,
token,
epochTolerance: 30, // Allow ±30 seconds tolerance
});2. Counter Mismatch (HOTP)
HOTP tokens are counter-based. The server counter must match or be behind the client counter.
// Use counter tolerance to handle counter drift
const result = await verify({
secret,
token,
counter: serverCounter,
counterTolerance: 10, // Allow up to 10 counters ahead
});
if (result.valid) {
// Update and persist your counter to prevent replay
}Replay Prevention
After successful HOTP verification, persist the updated counter in your system. See Replay Attack Prevention.
3. Secret Encoding Issues
If you need to accept secrets that are not Base32-encoded or configure Base32 decoding, see Plugins for Base32 plugin setup and bypass options.
4. Algorithm Mismatch
Ensure both generation and verification use the same algorithm:
// If token was generated with SHA-256
const result = await verify({
secret,
token,
algorithm: "sha256", // Must match generation algorithm
});"Tolerance validation failed"
The tolerance parameters have maximum values to prevent DoS attacks:
// This will throw EpochToleranceTooLargeError
const result = await verify({
secret,
token,
epochTolerance: 5000, // Too large!
});
// Use a reasonable tolerance value
const result = await verify({
secret,
token,
epochTolerance: 30, // For TOTP: ±30 seconds
// counterTolerance: 10, // For HOTP: look-ahead of 10
});Secret-Related Errors
"SecretTooShortError"
Secrets must be at least 16 bytes (128 bits):
// This will fail - secret is too short
const result = await generate({
secret: new Uint8Array([1, 2, 3]), // Only 3 bytes!
counter: 0,
crypto,
});
// Use proper secret length
const result = await generate({
secret: new Uint8Array(20).fill(0), // 20 bytes minimum
counter: 0,
crypto,
});
// Or generate a proper secret
import { generateSecret } from "otplib";
const secret = generateSecret(); // 20 bytes by default"SecretTooLongError"
Secrets must not exceed 64 bytes (512 bits):
// Use appropriate secret lengths
// SHA-1: 20 bytes (160 bits) - default
// SHA-256: 32 bytes (256 bits)
// SHA-512: 64 bytes (512 bits) - maximum"String secrets require a Base32Plugin"
When using Base32-encoded string secrets, you must provide a base32 plugin. You may also choose to use a bypass if you want utilise non-base32 strings.
import { base32 } from "@otplib/plugin-base32-scure";
// Wrong - missing base32 plugin
const result = await generate({
secret: "GEZDGNBVGY3TQOJQGEZDGNBVGY",
crypto,
});
// Correct
const result = await generate({
secret: "GEZDGNBVGY3TQOJQGEZDGNBVGY",
crypto,
base32,
});Plugin Errors
"Crypto plugin is required"
All operations require a crypto plugin:
import { crypto } from "@otplib/plugin-crypto-node";
// or
import { crypto } from "@otplib/plugin-crypto-web";
// or
import { crypto } from "@otplib/plugin-crypto-noble";"WebCrypto not available"
The Web Crypto API requires a secure context (HTTPS) in browsers:
// Check if WebCrypto is available
if (typeof globalThis.crypto?.subtle === "undefined") {
// Fall back to noble crypto
const { crypto } = await import("@otplib/plugin-crypto-noble");
}Solutions:
- Use HTTPS in production
- For local development, use
localhost(treated as secure) - Use
NobleCryptoPluginas a fallback
Time-Related Issues
"TimeNegativeError"
Time values cannot be negative:
// Wrong
const result = await generate({
secret,
epoch: -1000, // Negative time!
crypto,
});
// Correct
const result = await generate({
secret,
epoch: Math.floor(Date.now() / 1000), // Current Unix timestamp
crypto,
});"PeriodTooSmallError" / "PeriodTooLargeError"
Period must be between 1 and 3600 seconds:
// Valid periods
const result = await generate({
secret,
period: 30, // Default, recommended
crypto,
});
// Period range: 1 to 3600 (1 second to 1 hour)Token Format Errors
"TokenLengthError"
Token length must match the digits parameter:
// If digits is 6 (default), token must be exactly 6 characters
const result = await verify({
secret,
token: "123456", // Correct - 6 digits
digits: 6,
});
// This will fail
const result = await verify({
secret,
token: "12345678", // Wrong - 8 digits but expecting 6
digits: 6,
});"TokenFormatError"
Tokens must contain only digits (0-9):
// Wrong - contains non-digit characters
const result = await verify({
secret,
token: "12345a", // 'a' is not a digit
});
// Correct
const result = await verify({
secret,
token: "123456",
});QR Code / URI Issues
"Label is required" / "Issuer is required"
When generating URIs for QR codes, both label and issuer are required:
import { generateURI } from "otplib";
import { crypto } from "@otplib/plugin-crypto-node";
import { base32 } from "@otplib/plugin-base32-scure";
const uri = generateURI({
secret: "GEZDGNBVGY3TQOJQGEZDGNBVGY",
issuer: "MyApp", // Required
label: "user@example.com", // Required
crypto,
base32,
});
// otpauth://totp/MyApp:user@example.com?secret=GEZDGNBVGY3TQOJQGEZDGNBVGY&issuer=MyApp"URI generation requires secret to be a Base32 string"
When using the generateURI function, the secret must be a Base32 string:
// Wrong - raw bytes won't work with generateURI()
const uri = generateURI({
secret: new Uint8Array([1, 2, 3]),
});
// Error!
// Correct - use Base32 string
const uri = generateURI({
secret: "GEZDGNBVGY3TQOJQGEZDGNBVGY",
});
// Works!Debugging Tips
Inspecting Error Causes
When errors occur in crypto or Base32 plugins, otplib wraps them with descriptive error types. Use the cause property to access the original error for detailed debugging:
import { Base32DecodeError, HMACError } from "@otplib/core";
try {
const token = await generate({
secret: "invalid-base32!@#",
crypto,
base32,
});
} catch (error) {
console.log("Error type:", error.constructor.name);
console.log("Error message:", error.message);
// Access the underlying plugin error
if (error.cause) {
console.log("Caused by:", error.cause.message);
console.log("Original stack:", error.cause.stack);
}
}
// Output:
// Error type: Base32DecodeError
// Error message: Base32 decoding failed: Invalid character at position 14
// Caused by: Invalid character at position 14
// Original stack: Error: Invalid character...This is especially useful when debugging issues with custom plugins or unusual input data.
Getting Help
If you're still experiencing issues:
- Check the API Reference for detailed function signatures
- Review Advanced Usage for best practices
- Open an issue on GitHub with:
- otplib version
- Runtime environment (Node.js version, browser, etc.)
- Minimal reproduction code
- Error message and stack trace