Why Passwords Alone Aren’t Enough
Two years ago, while working on an e-wallet project (similar to integrating PayOS with Node.js), my boss asked: “If a hacker dumps the database containing hashed passwords, will our customers’ money disappear?”. I stayed silent. In reality, with a rig equipped with an RTX 4090, brute-forcing simple MD5 or SHA1 hashes is just a matter of seconds. That was the moment I realized 2FA (Two-Factor Authentication) isn’t optional—it’s vital. For those looking to move beyond traditional methods, implementing passkeys (WebAuthn) is another excellent step.
TOTP (Time-based One-Time Password) is currently the gold standard for this. You’re likely familiar with opening Google Authenticator to get a 6-digit code that changes every 30 seconds. This mechanism is incredibly smart: it doesn’t cost $0.05 per SMS, and there’s no risk of SIM swapping. Everything operates based on a synchronized algorithm between the server and the user’s phone.
Implementing this system with Node.js is actually very straightforward. Let’s get started.
Setting Up the Tools
We will use the two most popular libraries available today:
- speakeasy: The “heart” that handles secret key generation and OTP verification logic.
- qrcode: Turns dry character strings into QR codes for users to scan.
Initialize the project quickly with these commands:
mkdir node-2fa-lab && cd node-2fa-lab
npm init -y
npm install speakeasy qrcode express
A 3-Step Real-World Implementation Process
In large systems, I usually divide this process into 3 separate stages for easier maintenance and scalability.
Step 1: Initialize the “Secret Key”
Each account needs its own Secret Key. This key acts like a secondary password, stored on both the server (database) and the user’s app.
const speakeasy = require('speakeasy');
const secret = speakeasy.generateSecret({
name: "MyApp ([email protected])",
});
console.log("Base32 Key:", secret.base32); // Save this to the DB
console.log("OTP Auth URL:", secret.otpauth_url);
Important Note: Always use the base32 format. This is the universal standard required by Google Authenticator or Authy to function correctly.
Step 2: Create a Smooth QR Code Scanning Experience
Forcing users to manually type a 32-character string is the fastest way to make them uninstall your app. Instead, use a QR code. In a project I worked on, adding QR codes reduced support tickets related to “incorrect key” errors by 80%.
const QRCode = require('qrcode');
app.get('/setup-2fa', async (req, res) => {
const secret = speakeasy.generateSecret({ name: "SecureApp" });
// Convert URL to Base64 format to embed directly into an img tag
const dataUrl = await QRCode.toDataURL(secret.otpauth_url);
res.send(`
<h3>Scan the code to activate security</h3>
<img src="${dataUrl}">
<p>Enter the 6-digit code from your app to confirm</p>
`);
});
Step 3: Token Verification
When the user enters the 6 digits, the server compares them against the stored Secret Key. This is where the magic happens.
app.post('/verify-2fa', (req, res) => {
const { userToken, storedSecret } = req.body;
const verified = speakeasy.totp.verify({
secret: storedSecret,
encoding: 'base32',
token: userToken,
window: 1 // Allows for a 30-second clock skew
});
if (verified) {
return res.json({ success: true, message: "Authentication successful!" });
}
res.status(400).json({ success: false, message: "Invalid code." });
});
Quick Tip: The window: 1 parameter is extremely valuable. The time on a phone and a server often drifts by a few seconds. If left at the default (window: 0), users will get frustrated because a code might be rejected the moment it changes.
“Traps” to Avoid in Production
Getting code running locally is only 50% of the journey. Here are some “hard-earned” lessons I’ve learned after handling various incidents:
1. Backup Codes are Mandatory
Users losing their phones is a daily occurrence. Without backup codes, they will be permanently locked out of their accounts. Generate 10 random codes, hash them like passwords, and ask the user to save them in a safe place as soon as 2FA is enabled.
2. Never Store Secret Keys in Plaintext
If the database is compromised and Secret Keys are exposed, 2FA becomes useless. Encrypt the Key using AES-256 before storing it in the DB. Only decrypt it at the logic layer when authorization is needed.
3. Prevent Brute-force Attacks
An OTP only has 1 million possibilities (000000 – 999999). A simple script can sweep through them in minutes. You must limit the number of attempts. This level of protection is just as important as ensuring idempotency in REST APIs to prevent duplicate actions. Using express-rate-limit is the fastest way to implement this.
4. Server Clock Drift
Since TOTP relies on time, if the server clock is off by just 1 minute, the entire system “crashes.” Always install NTP (Network Time Protocol) to synchronize time. On Linux, run timedatectl to ensure everything is accurate.
I hope these insights help you feel more confident when implementing security for your projects. Remember, in security, you can never be too careful!

