Skip to content
ExponaBLOG
PLAYWRIGHT & BROWSER AUTOMATION · DAY 038 / 100

Testing two-factor authentication: a step-by-step guide

Handle OTP codes in tests.

Expona Admin7 min read

Testing two-factor authentication: a step-by-step guide

Two-factor authentication (2FA) is standard security practice across modern SaaS platforms, yet it remains one of the trickiest flows to automate in end-to-end tests. When your application gates login behind OTP codes, TOTP apps, or SMS verification, Playwright tests hit a wall: they can't intercept real authentication tokens or read SMS messages. Testing two-factor authentication: a step-by-step guide walks through pragmatic strategies we've used in production testing across e-commerce, trading, and CRM systems to handle these scenarios without sacrificing security or test reliability.

Generate TOTP codes from a test secret

Time-based One-Time Password (TOTP) is the most common 2FA mechanism. Apps like Google Authenticator or Authy generate codes from a shared secret. In tests, we replicate this by storing the test secret and computing the same codes programmatically.

Most applications provide a secret during 2FA setup, typically as a base32-encoded string displayed in a QR code. We capture this secret and store it in our test environment. Then, during test execution, we generate valid TOTP codes on demand.

Start by installing a TOTP library:

typescript
npm install speakeasy

Here's a helper function to generate TOTP codes:

typescript
import speakeasy from 'speakeasy';

export function generateTOTPCode(secret: string): string {
  const code = speakeasy.totp({
    secret: secret,
    encoding: 'base32',
  });
  return code;
}

// Usage in a test
const testSecret = process.env.TEST_2FA_SECRET;
const totpCode = generateTOTPCode(testSecret);
await page.fill('input[name="otp"]', totpCode);
await page.click('button:has-text("Verify")');

The code returned is valid for 30 seconds by default. If your test runs slowly or encounters delays, generate a fresh code or use the window parameter to extend validity. Some applications tolerate codes from the previous time window—check your implementation.

In practice, we've seen this approach work reliably across hundreds of test runs in CI. The key is storing the secret securely (as a CI environment variable, never in code) and regenerating it fresh for each test user account if your test harness creates ephemeral accounts.

For applications using backup codes instead of time-based tokens, store these in your test configuration and consume them one per test run:

typescript
export class TestUser2FAManager {
  private backupCodes: string[] = [];

  constructor(codes: string[]) {
    this.backupCodes = [...codes];
  }

  getNextBackupCode(): string {
    const code = this.backupCodes.shift();
    if (!code) throw new Error('No backup codes remaining');
    return code;
  }
}

Or disable 2FA in test environments

Sometimes the most pragmatic solution is to skip 2FA entirely in test environments. Not all testing scenarios require validating the 2FA flow itself—often you're testing downstream functionality after login. Disabling 2FA for test accounts or in staging environments eliminates complexity without sacrificing coverage of the features that matter.

Work with your backend team to add a feature flag or environment variable that disables 2FA for test users:

typescript
// Backend example (pseudocode)
if (isTestEnvironment() && userEmail.includes('test+')) {
  // Skip 2FA verification
  req.user = authenticatedUser;
  return next();
}

Or, configure your application to bypass 2FA for accounts provisioned with a specific marker:

typescript
// In your test setup
const testUser = {
  email: 'testuser+no2fa@example.com',
  password: 'secure-password-here',
};

// Your app recognizes the +no2fa tag and disables 2FA

This approach scales well for broad test coverage. In our experience, 80% of test scenarios don't explicitly require validating 2FA—they care about post-login behavior. Reserve the TOTP generation approach for the 20% of tests that specifically verify the 2FA flow works correctly.

The tradeoff: you're testing less of your production code path. Mitigate this by running full 2FA flows in a smaller set of dedicated integration tests, possibly less frequently (nightly rather than per-commit).

Test the 2FA flow separately

When you do need to test 2FA end-to-end, isolate it into dedicated test suites. Don't mix 2FA verification with other test logic. A focused test that only validates the OTP input, verification, and session establishment is easier to debug and maintain.

Here's a practical example using Playwright:

typescript
import { test, expect } from '@playwright/test';
import { generateTOTPCode } from './totp-helper';

test.describe('Two-factor authentication', () => {
  test('should verify valid TOTP code and grant access', async ({ page }) => {
    const testSecret = process.env.TEST_2FA_SECRET;
    if (!testSecret) throw new Error('TEST_2FA_SECRET not set');

    // Step 1: Navigate to login
    await page.goto('/login');
    await page.fill('input[name="email"]', 'test@example.com');
    await page.fill('input[name="password"]', 'password123');
    await page.click('button:has-text("Sign In")');

    // Step 2: Wait for 2FA page
    await expect(page).toHaveURL('/verify-2fa');
    await expect(page.locator('text=Enter your authenticator code')).toBeVisible();

    // Step 3: Generate and enter TOTP code
    const totpCode = generateTOTPCode(testSecret);
    await page.fill('input[name="otp"]', totpCode);
    await page.click('button:has-text("Verify")');

    // Step 4: Verify successful login
    await expect(page).toHaveURL('/dashboard');
    await expect(page.locator('text=Welcome')).toBeVisible();
  });

  test('should reject invalid TOTP code', async ({ page }) => {
    await page.goto('/verify-2fa');
    await page.fill('input[name="otp"]', '000000');
    await page.click('button:has-text("Verify")');

    await expect(page.locator('text=Invalid code')).toBeVisible();
    await expect(page).toHaveURL('/verify-2fa');
  });

  test('should allow backup code when TOTP unavailable', async ({ page }) => {
    const backupCode = process.env.TEST_BACKUP_CODE;
    if (!backupCode) throw new Error('TEST_BACKUP_CODE not set');

    await page.goto('/verify-2fa');
    await page.click('text=Use backup code instead');
    await page.fill('input[name="backup-code"]', backupCode);
    await page.click('button:has-text("Verify")');

    await expect(page).toHaveURL('/dashboard');
  });
});

Notice the structure: each test is single-purpose, with clear setup and assertions. If TOTP generation fails, the error is obvious. If the verify button doesn't redirect, you catch it immediately.

In our experience with trading and e-commerce platforms, these focused 2FA tests run in under 5 seconds each and catch real regressions—like a developer accidentally removing the redirect after OTP verification.

Keep secrets in CI variables

Never commit TOTP secrets, backup codes, or test account credentials to your repository. Store them as CI environment variables, and load them at runtime.

For GitHub Actions:

typescript
// .github/workflows/test.yml
name: E2E Tests
on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    env:
      TEST_2FA_SECRET: ${{ secrets.TEST_2FA_SECRET }}
      TEST_BACKUP_CODE: ${{ secrets.TEST_BACKUP_CODE }}
    steps:
      - uses: actions/checkout@v3
      - uses: actions/setup-node@v3
        with:
          node-version: 18
      - run: npm ci
      - run: npm run test:e2e

Load them in your test configuration:

typescript
// playwright.config.ts
import { defineConfig } from '@playwright/test';

export default defineConfig({
  use: {
    baseURL: process.env.BASE_URL || 'http://localhost:3000',
  },
  // Secrets are available via process.env
});

Rotate test secrets periodically, especially if developers leave the team or if a secret is accidentally exposed. We recommend quarterly rotation as a baseline, or immediately if a breach occurs.

Common mistakes

Hardcoding secrets in test files: We've seen this in code reviews multiple times. A developer commits const SECRET = 'base32secret123' directly into a test file, then it sits in Git history permanently. Use environment variables exclusively.

Not accounting for clock skew: If your CI machine's clock drifts slightly, TOTP codes may become invalid. Some applications reject codes from previous time windows. If you see flaky 2FA tests, check system time on the CI runner or request your backend to accept codes from both current and previous windows.

Generating TOTP once per suite: A common pattern is to generate the code once before all tests run, then reuse it. This fails because TOTP codes expire every 30 seconds. Generate a fresh code for each test that needs it.

Mixing 2FA tests with unrelated tests: If a 2FA verification test fails, it shouldn't block tests for profile editing or payment processing. Keep 2FA tests isolated and, if appropriate, mark them with a tag so you can run them separately.

Not testing the "Cannot verify" path: It's easy to focus on the happy path. Always test invalid codes, expired codes, and backup code flows. We caught a real bug once where expired codes triggered a 500 error instead of a user-friendly message.

Key takeaways

  • Use the speakeasy library to generate TOTP codes from a test secret in your test suite.
  • Consider disabling 2FA entirely for test accounts in non-production environments—most tests don't require it.
  • Isolate 2FA flow tests into their own test suite with focused assertions on login success and error states.
  • Store all secrets in CI environment variables; never commit them to version control.
  • Generate fresh TOTP codes per test, not once per suite, since codes expire every 30 seconds.
  • Test both happy paths (valid code) and error paths (invalid code, backup code fallback, expired code).
  • Rotate test secrets periodically and monitor for clock skew on CI runners if TOTP tests become flaky.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles