
Things I Got Stuck On When Automating Okta MFA (TOTP) Login with Playwright
This page has been translated by machine translation. View original
Introduction
When creating business automation scripts, there are inevitably situations where you want to automate login to services that require MFA. In the process of obtaining tokens through an OAuth authorization flow, manually entering an MFA code every time is not practical.
This article introduces the implementation and several pitfalls I encountered when automating Okta MFA (TOTP method) login with Playwright.
How TOTP Auto-Generation Works
The one-time password displayed in the Okta Verify app is based on a standard called TOTP (Time-based One-Time Password). Since it is deterministically calculated from a secret key and the current time, you can generate it yourself if you have the same secret key.
import { TOTP } from "totp-generator";
class Okta {
private lastOTPCallTime: number = 0;
private readonly OTP_LIFESPAN_SECONDS = 30; // TOTP validity period
private async waitForNextOTP(lastCallTime: number): Promise<void> {
const now = Date.now();
const timeSinceLastCall = (now - lastCallTime) / 1000;
if (timeSinceLastCall < this.OTP_LIFESPAN_SECONDS) {
// Wait if 30 seconds have not passed since the last generation
const timeToWait = this.OTP_LIFESPAN_SECONDS - timeSinceLastCall;
await new Promise((resolve) => setTimeout(resolve, timeToWait * 1000));
}
}
public async generateOTP(): Promise<string> {
await this.waitForNextOTP(this.lastOTPCallTime);
const { otp } = TOTP.generate(process.env.TOTP_SECRET_KEY!);
this.lastOTPCallTime = Date.now();
return otp;
}
}
export default new Okta();

Why is waitForNextOTP necessary?
TOTP is updated every 30 seconds, but an OTP generated within the same 30-second window can typically only be used once (to prevent replay attacks). In situations where OTPs are generated and used in succession, you need to confirm that 30 seconds have passed since the last generation before generating a new one.
By recording lastOTPCallTime, if "time elapsed since last generation < 30 seconds" at the time of the next generation, it waits for the remaining time before generating.
Automating the Okta Login Flow
Once TOTP can be generated, we automate the login flow with Playwright.
public async handleOktaLogin(url: string) {
await this.checkContext(); // Lazy initialization of browser context
const page = await this.context.newPage();
try {
await page.goto(url);
// Enter username
const usernameInput = page.getByLabel("Username");
await usernameInput.waitFor({ state: "visible", timeout: 10000 });
await usernameInput.fill(process.env.USERNAME!);
// Enter password
const passwordInput = page.getByLabel("Password");
await passwordInput.waitFor({ state: "visible", timeout: 10000 });
await passwordInput.fill(process.env.PASSWORD!);
await page.getByRole("button", { name: "Sign in" }).click();
// Select MFA method: "Enter a code from Okta Verify"
const mfaLink = page.getByRole("link", {
name: "Select to enter a code from",
});
await mfaLink.waitFor({ state: "visible", timeout: 10000 });
await mfaLink.click();
// Generate and enter OTP
const otpInput = page.getByRole("textbox", {
name: "Enter code from Okta Verify",
});
await otpInput.waitFor({ state: "visible", timeout: 10000 });
const mfaOtp = await Okta.generateOTP(); // ← Generate here
await otpInput.fill(mfaOtp);
await page.getByRole("button", { name: "Verify" }).click();
// Confirm login completion
await page
.getByText("login complete, you may close this page")
.waitFor({ timeout: 100000 }); // Set longer timeout as OAuth redirect completion may take time
Logger.success("Okta login complete");
} catch (error) {
// Save screenshot on error
await this.handleException(page, "Okta login failed.", error);
} finally {
await this.handleVideo(page); // Save video and close context
}
}
Handling UI Branching with Promise.race
The tricky part of the login flow is that the next screen displayed differs depending on the service.
For example, after logging into another service through an OAuth flow, the next screen displayed might be "first login → password input screen" or "session remains → already logged in screen." Which one is displayed changes depending on the execution timing.

To branch with if/else, you need to know "which will appear first." Using Promise.race allows you to branch based on whichever element appears first.
public async getAzureCode(callback: string): Promise<string> {
const page = await this.context.newPage();
await page.goto(callback);
const emailInput = page.getByLabel("Type your email address here");
const noButton = page.getByRole("button", { name: "No" }); // Displayed when an existing session exists
// Proceed processing when whichever appears first
await Promise.race([
this.waitForLocator(emailInput),
this.waitForLocator(noButton),
]);
if (await emailInput.isVisible()) {
// First login: enter email address and OTP
await emailInput.fill(process.env.EMAIL!);
await page.getByRole("button", { name: "Next" }).click();
await page.getByRole("link", {
name: "Select to enter a code from",
}).click();
const otpInput = page.getByLabel("Enter code from Okta Verify");
const otp = await Okta.generateOTP();
await otpInput.fill(otp);
await page.getByRole("button", { name: "Verify" }).click();
}
// Both paths ultimately reach the "No" button
await noButton.click();
// Get authorization code from URL
await page.getByText("Your call is authenticated").waitFor();
const currentUrl = page.url();
return currentUrl.split("code=")[1].split("&")[0];
}
private waitForLocator = (locator: Locator): Promise<Locator> => {
return locator.waitFor().then(() => locator);
};
waitForLocator is a helper that wraps Locator.waitFor() in a Promise. Promise.race returns the value of the first resolved Promise, but here we branch using the subsequent isVisible() check rather than "which one appeared."
For other services, the screen patterns after login were even more numerous, and there were cases where three or more were passed to Promise.race.
// Box case: Monitor 3 patterns simultaneously
await Promise.race([
this.waitForLocator(boxMailInput), // Box-specific email input
this.waitForLocator(oktaMailInput), // Okta authentication screen
this.waitForLocator(grantButton), // Access permission screen when already authenticated
]);
Video and Screenshot Recording for Debugging
Browser automation is difficult to debug. When an error occurs, if you don't know "on which screen which element was not found," it takes time to trace the cause.
Screenshot on error:
private async handleException(page: Page, message: string, error: any) {
const screenshotPath = `output/screenshots/error-${dayjs().format("YYYYMMDDHHmmss")}.png`;
await page.screenshot({ path: screenshotPath, fullPage: true });
Logger.error(message);
throw new Error();
}
Video recording of the entire session:
private async checkContext() {
if (this.context) return;
const dir = `output/videos/${dayjs().format("YYYY-MM-DD")}`;
const screenSize = await PowerShell.getScreenSize(); // Getting screen size is environment-dependent (Windows: PowerShell, Mac: screencapture, etc.)
this.context = await this.browser.newContext({
recordVideo: {
dir,
size: { width: screenSize.width, height: screenSize.height },
},
});
}
private async handleVideo(page: Page) {
await page.close();
await this.context.close(); // Video is saved when context is closed
this.context = null;
}
The video is not saved with page.close() alone. By calling context.close(), the video recorded in that context is written to a file.
Retry Implementation
Browser automation can become unstable (network delays, element rendering timing, etc.). I implemented a maximum of 3 retries for important flows.
public async getAzureCode(callback: string): Promise<string> {
const maxRetries = 3;
for (let attempt = 1; attempt <= maxRetries; attempt++) {
Logger.task(`Attempt ${attempt}/${maxRetries}`);
let page: Page | null = null;
try {
page = await this.context.newPage();
// ... login process ...
return code; // Return immediately on success
} catch (error) {
if (attempt < maxRetries) {
Logger.warn(`Failed. Retrying in 2 seconds...`);
if (page) await page.close();
await new Promise((resolve) => setTimeout(resolve, 2000));
} else {
// Final attempt also failed
await this.handleException(page!, "All retries failed.", error);
throw error;
}
} finally {
if (page) await this.handleVideo(page); // Save video for each attempt
}
}
throw new Error("unreachable"); // For TypeScript type checking
}
Videos for each attempt are saved in the finally block. Since videos of failed attempts are also retained, you can later confirm "on which retry attempt and at which screen it got stuck."
Summary
Here are the key points for automating Playwright + Okta TOTP.
TOTP window management: An OTP from the same 30-second window can only be used once. Record lastCallTime and wait if necessary before the next generation.
Handle UI branching with Promise.race: When "you don't know which screen will appear next," monitor multiple elements simultaneously and branch based on whichever appears first.
Ensure observability with video and screenshots: Video is finalized with context.close(). Combined with screenshots on error, this makes headless browser debugging practical.
Leave videos for each attempt in retries: Not just "videos of successful attempts" but "videos of failed attempts" are useful for tracing the cause.
Login flows involving MFA are complex, but by combining UI branching processing with Promise.race and TOTP management, it was possible to achieve stable automation at a practically usable level.