Automating logins to websites protected by Two-Factor Authentication (2FA) is a common requirement for web scraping, API testing, and building automated workflows. However, 2FA adds a layer of complexity that simple scripts often fail to handle correctly. The login process becomes a multi-step conversation with the server, and your script must maintain state across each step.
This guide explains how to manage persistent sessions with requests.Session, handle manual 2FA code entry, and fully automate code generation using the pyotp library.
Why Sessions Matter for 2FA
The most critical mistake when handling 2FA is using independent requests.post() calls for each step. A 2FA login is not a single request. It is a sequence of requests where the server tracks your progress through cookies, session tokens, and CSRF tokens. If you lose that state between steps, the server will reject your 2FA code because it has no way to associate it with the password you just provided.
The Wrong Way: Independent Requests
import requests
login_url = "https://example.com/api/login"
verify_url = "https://example.com/api/verify-2fa"
response = requests.post(login_url, json={"user": "admin", "pass": "secret123"})
otp_code = input("Enter code: ")
verify_resp = requests.post(verify_url, json={"token": otp_code})
print(verify_resp.status_code)The Right Way: Using requests.Session
import requests
session = requests.Session()
login_url = "https://example.com/api/login"
verify_url = "https://example.com/api/verify-2fa"
payload = {"user": "admin", "pass": "secret123"}
response = session.post(login_url, json=payload)
if response.status_code == 200:
otp_code = input("Enter the code sent to your device: ")
verify_resp = session.post(verify_url, json={"token": otp_code})
if verify_resp.ok:
print("Login complete. Session is now fully authenticated.")
protected_data = session.get("https://example.com/api/dashboard")
print(protected_data.json())Handling the Full Login Flow
Real-world 2FA login flows often involve additional details like CSRF tokens, custom headers, or intermediate redirects. Here is a more complete example that accounts for these:
import requests
session = requests.Session()
login_url = "https://example.com/api/login"
verify_url = "https://example.com/api/verify-2fa"
login_page = session.get(login_url)
credentials = {"user": "admin", "pass": "secret123"}
login_response = session.post(login_url, json=credentials)
if login_response.status_code != 200:
print(f"Login failed: {login_response.status_code}")
print(login_response.text)
else:
print("Credentials accepted. 2FA code required.")
otp_code = input("Enter the code sent to your device: ")
verify_response = session.post(verify_url, json={"token": otp_code})
if verify_response.ok:
print("Fully authenticated.")
profile = session.get("https://example.com/api/profile")
print(profile.json())
else:
print(f"2FA verification failed: {verify_response.status_code}")Fully Automating 2FA with pyotp
If you are building an automated script and have access to the secret key (the string used when initially setting up an authenticator app like Google Authenticator), you can generate the time-based codes directly in Python without any manual input.
Installing pyotp
pip install pyotpGenerating TOTP Codes
import pyotp
SECRET = "JBSWY3DPEHPK3PXP"
totp = pyotp.TOTP(SECRET)
current_code = totp.now()
print(f"Current 2FA code: {current_code}")
print(f"Is '123456' valid? {totp.verify('123456')}")Output (example, varies by time):
Current 2FA code: 492039
Is '123456' valid? FalseCombining Sessions with pyotp for Full Automation
import requests
import pyotp
import os
session = requests.Session()
login_url = "https://example.com/api/login"
verify_url = "https://example.com/api/verify-2fa"
username = os.environ.get("APP_USERNAME")
password = os.environ.get("APP_PASSWORD")
totp_secret = os.environ.get("APP_TOTP_SECRET")
login_response = session.post(login_url, json={
"user": username,
"pass": password
})
if login_response.status_code == 200:
totp = pyotp.TOTP(totp_secret)
otp_code = totp.now()
verify_response = session.post(verify_url, json={"token": otp_code})
if verify_response.ok:
print("Fully authenticated via automated 2FA.")
data = session.get("https://example.com/api/protected-data")
print(data.json())
else:
print(f"2FA verification failed: {verify_response.status_code}")
else:
print(f"Login failed: {login_response.status_code}")Handling Timing Issues
TOTP codes are valid for a 30-second window. If your code is generated near the end of that window, it might expire before the server processes it. You can handle this with a simple retry:
import pyotp
import time
import requests
def submit_2fa_with_retry(session, verify_url, totp_secret, max_retries=2):
"""Submit a 2FA code, retrying with a fresh code if the first attempt fails."""
totp = pyotp.TOTP(totp_secret)
for attempt in range(max_retries):
code = totp.now()
response = session.post(verify_url, json={"token": code})
if response.ok:
return response
print(f"Attempt {attempt + 1} failed. Waiting for next code window...")
time.sleep(30)
return NoneQuick Reference
| Requirement | Solution | Key Tool |
|---|---|---|
| Maintain login state across requests | Persistent session with automatic cookie handling | requests.Session() |
| Submit a 2FA code manually | Multi-step POST with user input | session.post() + input() |
| Generate 2FA codes automatically | Programmatic TOTP generation from secret key | pyotp.TOTP() |
| Store secrets securely | Environment variables or secrets manager | os.environ or vault service |
Summary
Accessing 2FA-protected sites with Python Requests requires two key techniques.
First, always use requests.Session() to maintain cookies and authentication state across the multi-step login flow. Without a persistent session, the server cannot associate your 2FA code with the credentials you submitted earlier.
Second, if you need full automation, use the pyotp library to generate time-based one-time passwords from your 2FA secret key, eliminating the need for manual code entry.
Keep your secret keys secure by loading them from environment variables or a secrets manager, and handle timing edge cases with simple retry logic.