Automate 2FA Testing Securely
September 17, 2024
Testing 2FA/MFA: Practical Approaches Without Compromising Security
Testing two-factor authentication (2FA) and multi-factor authentication (MFA) is tricky: you need automated tests, but you can't compromise security. Here are proven strategies for different test levels.
The Challenge
How do you automate login flows that require:
- Time-based one-time passwords (TOTP)
- SMS codes
- Email verification links
- Push notification approvals
Without either:
- Manually entering codes during test runs
- Creating security backdoors in production
Five Testing Strategies
1. Test Environment Backdoors (Most Common)
Create bypass mechanisms that only work in non-production environments:
# Environment-specific bypass
if os.getenv('ENV') == 'test' and request.headers.get('X-Test-Bypass') == 'true':
return authenticate_without_2fa(user)
# Master test code (non-prod only)
if code == '000000' and not is_production():
return validate_success()
Use when: You need quick E2E test execution without external dependencies.
2. Programmatic TOTP Generation (Recommended)
Generate valid time-based codes during test execution:
import pyotp
# Store test account TOTP secret in test config
totp = pyotp.TOTP('JBSWY3DPEHPK3PXP')
current_code = totp.now()
# Use in login flow
login(username='test@example.com', password='pass', mfa_code=current_code)
Use when: Testing against real 2FA implementation without manual intervention.
3. SMS/Email Code Interception
For SMS or email-based verification:
Option A: Test services
- Twilio test credentials: Don't actually send SMS, return predictable codes
- Mailtrap/Mailinator: Automated email verification code extraction
Option B: Test endpoint
# Test-only API endpoint (disabled in production)
@app.route('/test/last-verification-code', methods=['GET'])
def get_last_code():
if not is_test_environment():
abort(404)
return jsonify({'code': get_last_sent_code_for_user(request.args['email'])})
Use when: Testing SMS/email delivery flow is critical to your test scenarios.
4. Mock the 2FA Service
In lower environments, stub external providers:
# Mock Twilio in integration tests
@patch('twilio.rest.Client.messages.create')
def test_2fa_flow(mock_sms):
mock_sms.return_value.sid = 'test-message-id'
result = send_2fa_code(phone='+15551234567')
assert result.success
mock_sms.assert_called_once()
Use when: You want to test error handling without external service costs.
5. Session Token Injection
Skip authentication entirely for tests that don't focus on login:
# Generate valid session directly
def create_authenticated_session(user_id):
token = jwt.encode({'user_id': user_id, 'exp': ...}, SECRET_KEY)
return token
# In E2E tests
token = create_authenticated_session(test_user_id)
browser.set_cookie('session_token', token)
browser.goto('/dashboard') # Already authenticated
Use when: Authentication isn't what you're testing—you just need an authenticated state.
Strategy by Test Level
| Test Level | Recommended Approach |
|---|---|
| Unit Tests | Mock 2FA verification entirely |
| Integration Tests | Programmatic TOTP generation |
| E2E Tests | Test backdoors or TOTP generation |
| Security/Pentest | Real 2FA (no shortcuts) |
Example: Complete E2E Test with TOTP
import pyotp
from playwright.sync_api import sync_playwright
def test_login_with_2fa():
# Test account configuration
test_email = 'test@example.com'
test_password = 'SecurePassword123!'
totp_secret = 'JBSWY3DPEHPK3PXP' # Stored in test config
with sync_playwright() as p:
browser = p.chromium.launch()
page = browser.new_page()
# Step 1: Login with username/password
page.goto('https://app.example.com/login')
page.fill('#email', test_email)
page.fill('#password', test_password)
page.click('#login-button')
# Step 2: Generate and enter current TOTP code
totp = pyotp.TOTP(totp_secret)
current_code = totp.now()
page.fill('#mfa-code', current_code)
page.click('#verify-button')
# Step 3: Assert successful login
assert page.url == 'https://app.example.com/dashboard'
browser.close()
Security Best Practices
Never compromise production:
- ✅ Environment checks for all bypass mechanisms
- ✅ Separate test accounts with known TOTP secrets
- ✅ Feature flags that cannot be enabled in production
- ✅ Code reviews specifically for auth-related changes
Configuration example:
# config/test.yml
security:
two_factor:
allow_bypass: true
test_master_code: "000000"
# config/production.yml
security:
two_factor:
allow_bypass: false # Hardcoded, not overridable
Common Mistakes to Avoid
❌ Hardcoded bypass in production code without environment checks
# DON'T DO THIS
if code == '000000':
return True # Backdoor always active!
✅ Environment-gated bypass
if code == '000000' and os.getenv('ENV') in ['test', 'dev']:
return True
❌ Using production 2FA credentials in tests
- Burns through SMS credits
- Rate limiting issues
- Slower test execution
✅ Test-specific accounts with known TOTP secrets or mock services
Quick Decision Guide
Use programmatic TOTP generation if:
- You're using TOTP/authenticator apps
- You need realistic test coverage
- External services aren't involved
Use test backdoors if:
- Speed is critical (E2E tests)
- 2FA isn't the focus of your test
- You're testing across multiple environments
Use mocks if:
- Testing unit/integration level
- External service costs are a concern
- You need to test error scenarios
Conclusion
Testing 2FA/MFA doesn't have to be painful. The key is choosing the right approach for your test level:
- Unit/Integration: Mock or generate codes programmatically
- E2E: TOTP generation or environment-specific bypasses
- Security: Real 2FA with no shortcuts
The golden rule: Never create a backdoor that could work in production.
How do you handle 2FA/MFA in your test automation? Share your approach on LinkedIn.