Things I Got Stuck On When Automating Okta MFA (TOTP) Login with Playwright

Things I Got Stuck On When Automating Okta MFA (TOTP) Login with Playwright

Introducing implementation patterns for automating Okta MFA (TOTP) login with Playwright. This covers TOTP 30-second window management, UI branching with Promise.race, and debugging techniques using video and screenshots.
2026.07.23

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.

Okta.ts
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();

playwright-okta-totp-automation-totp-window

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.

Playwright.ts
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.

playwright-okta-totp-automation-promise-race

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.

AzureAuth.ts
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.

Share this article