This article breaks down how Time-Based One-Time Passwords (TOTP) work and how to implement them from scratch. If you want to jump straight to trying it out or using it in production, here are some helpful links:
- Live Demo
- Source Code
- NPM Packages: otplib, otpauth
For production applications, It's recommend to use the battle-tested packages instead of building from scratch. This article is for understanding what's happening under the hood.
What is TOTP?
TOTP is a method to generate temporary passwords that change every 30 seconds. It's used for two-factor authentication (2FA) to add an extra security layer when logging in. When you use Google Authenticator or Microsoft Authenticator, you're using TOTP.
Unlike a regular password that stays the same, a TOTP code is valid for only a short time. After 30 seconds, a new code is generated.
How TOTP Works?
TOTP generates an code using three things:
- A secret key unique for each user: Saved in server and pass it to authenticator app when QR code is scanned.
- The current time.
- A cryptographic algorithm (HMAC-SHA1)
The Process
-
Get the time counter: The current time in seconds is divided by 30 to create a counter. For example, if it's second 1700572800, the counter would be 56685760. Everyone using phone during the same 30-second window gets the same counter.
-
Convert counter to bytes: The counter is converted to an 8-byte number. This byte format is needed for the cryptographic calculation.
-
Create the signature: The secret key is combined with the byte counter using HMAC-SHA1. This produces a unique 20-byte signature. Without the secret key, you cannot recreate this signature.
-
Extract 4 bytes: The last byte of the signature determines where to look. Using that position, 4 bytes are extracted from the signature and converted to a number.
-
Get 6 digit code: The number is divided by 1,000,000 to get a 6-digit code (0-999,999). This is your code.
The same process happens in your authenticator app and the server. Since both use the same time and same secret, both generate the same code.
Why Time-Based?
- Your phone just reads its clock, no need to keep track of anything else.
- The app automatically generates new code every 30 seconds.
- The server can verify any code submitted during a valid time window.
Implementing Time based OTP
class TOTP {
private period: number;
private algorithm: string;
private digits: number;
constructor() {
this.period = 30;
this.algorithm = "SHA-1";
this.digits = 6;
}
}- period: How long each code is valid (30 seconds).
- algorithm: The cryptographic method (SHA-1).
- digits: How many digits in the code (6).
Generate secret
public generateSecret(): string {
const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
const length = 32;
const bytes = new Uint8Array(length);
crypto.getRandomValues(bytes);
let secret = "";
for (const b of bytes) {
secret += alphabet[b % 32];
}
return secret;
}Every user needs a unique secret key. This creates 32 random bytes and converts them to base32 format. The randomness is cryptographically secure, so the secret cannot be guessed.
Create QR code
public generateTOTPURL({
secret,
username,
issuer,
}: {
secret: string;
username: string;
issuer: string;
}): string {
return `otpauth://totp/${encodeURIComponent(issuer)}:${encodeURIComponent(
username
)}?secret=${secret}&issuer=${encodeURIComponent(issuer)}&algorithm=${
this.algorithm
}&digits=${this.digits}&period=${this.period}`;
}This URL that can be turned into a QR code. When someone scans it with their authenticator app, the app automatically stores the secret and settings. The URL includes the service name (issuer), username, secret, and configuration.
Generate the OTP
private async generateOTP(secret: string): Promise<string> {
const timestamp = Date.now();
const keyBytes = this.base32ToBytes(secret);
// Calculate which 30-second window we're in
const counter = Math.floor(timestamp / 1000 / this.period);
// Create 8-byte buffer with the counter
const msg = new ArrayBuffer(8);
const view = new DataView(msg);
view.setUint32(4, counter);
// Get the signature
const hmac = await this.hmacSHA1(keyBytes, msg);
// Find which 4 bytes to use
const offset = hmac[hmac.length - 1] & 0xf;
// Extract 4 bytes and convert to number
const binary =
((hmac[offset] & 0x7f) << 24) |
(hmac[offset + 1] << 16) |
(hmac[offset + 2] << 8) |
hmac[offset + 3];
// Get last 6 digits
const otp = (binary % 1_000_000).toString().padStart(6, "0");
return otp;
}
This does the actual work:
- Get the current time and convert the secret to bytes.
- Calculate which 30-second window we're in.
- Create the 8-byte counter buffer.
- Calculate HMAC-SHA1 signature.
- Use the last byte to find an offset (0-15) in the signature.
- Extract 4 bytes starting at that offset.
- Apply modulo 1,000,000 to get 6 digits.
- Pad with zeros if needed.
HMAC-SHA1 Calculation
private async hmacSHA1(
key: Uint8Array,
message: ArrayBuffer
): Promise<Uint8Array> {
const keyBuffer = new Uint8Array(key);
const cryptoKey = await crypto.subtle.importKey(
"raw",
keyBuffer,
{ name: "HMAC", hash: { name: "SHA-1" } },
false,
["sign"]
);
const sig = await crypto.subtle.sign("HMAC", cryptoKey, message);
return new Uint8Array(sig);
}This uses the browser's built-in crypto API to calculate HMAC-SHA1 (In production whole process is done on server). It takes the secret key and the time counter message, and produces a signature. Only someone with the correct secret can produce the same signature.
Verify OTP
public async verifyOTP(secret: string, otp: string): Promise<boolean> {
const generatedOTP = await this.generateOTP(secret);
return generatedOTP === otp;
}To verify a code, the server generates what the code should be right now and compares it to what the user entered. If they match, the code is valid.
How to Use It
Here's the basic flow:
-
User enables 2FA:
- Server generates a new secret for user:
generateSecret(). - Server creates a QR code URL using secret and username:
generateTOTPURL(). - Display the QR code to the user.
- Server generates a new secret for user:
-
User scans the QR code:
- User scans the QR code using authenticator app (Google Authenticator, Authy, etc.)
- The app stores the secret and starts generating codes.
-
User confirms setup:
- Ask the user to enter the 6-digit code from their app.
- Verify it with:
verifyOTP(secret, userCode). - If it matches, 2FA is now enabled.
-
Login with 2FA:
- User logs in with email and password.
- Server asks for the 6-digit code from their authenticator app.
- User enters the code, Verify it and allow login if correct.
Important Notes
- Time must match: Both your phone and server need accurate time. If the server time is wrong by more than a few minutes, code won't work.
- Secret is permanent: Store secrets safely. On the server, keep secrets encrypted and backed up. On the phone, the OS encrypts them automatically.
- Code only work once: Each 6-digit code is only valid for 30 seconds. After that, a new code is generated.