Email OTP in Selenium: Java and Python Examples
To handle an OTP in Selenium when the code arrives by email, keep the browser out of the mailbox. The test gets an inbox it can query over HTTP, types that address into the form, polls for the code with a timeout, then types the code back. The pattern is the same in Python and Java.
QA engineers with an existing Selenium suite get the most from it, especially when the app under test turns away throwaway domains or the email step belongs to a third-party sign-up. On Playwright or Cypress instead? The Playwright and Cypress version runs the same flow.
Should the test read a real email at all?
Selenium's own documentation advises against automating two-factor and OTP steps, and suggests a special token for the test environment or switching the check off for test users (Selenium on two-factor authentication). For most runs of your own app, that is the right default.
| Approach | Speed | What it proves | Cost per run |
|---|---|---|---|
| Fixed test code or a test-only flag in staging | Instant | Your form logic | None |
| Local mail catcher (an SMTP sink you run) | Seconds | Your app builds and formats the email | None |
| Real inbox read through an API | Seconds to minutes | The email crosses real delivery and the code works | One inbox, refunded when no code arrives |
A real inbox earns its place in four cases: a pre-release check of the sign-up flow itself, an app that accepts only well-known mail domains, a third-party sign-up inside your flow where you cannot add a test switch, and delivery smoke tests. Many teams run these on a schedule, not on every commit.
The flow behind OTP handling in Selenium
- Before opening the page, rent an inbox with
POST /v1/orders, sending a newIdempotency-Key. Your own app is not in the service list, so useot(Any service); a listed third-party service uses its code fromGET /v1/services. - Type
data[0].emailinto the form and submit it. - Poll
GET /v1/orders/{orderId}every 3–5 seconds untilotphas an entry, then take the last one: the newest code, or a verification link. - Type the code, or open the link, behind an explicit wait.
- In teardown, cancel the order if no code came:
POST /v1/orders/{orderId}/cancelrefunds 100%. An order you forget cancels itself at itscodeDeadlineAt(25 minutes at most) with the same refund, as the rental refund rules describe.
The test never signs in to the inbox, so no Google sign-in page ever appears in the browser under test.
Python with pytest
import os, time, uuid, pytest, requests
from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
HOST = "otpgmail.net"
H = {"Authorization": "Bearer " + os.environ["OTPGMAIL_API_KEY"]}
def api(method, path, idem=None, **kw):
headers = {**H, "Idempotency-Key": idem} if idem else H
body = requests.request(method, f"https://{HOST}{path}", headers=headers, timeout=30, **kw).json()
if not body["success"]:
raise RuntimeError(body["error"]["code"])
return body["data"]
@pytest.fixture
def inbox():
order = api("POST", "/v1/orders", idem=str(uuid.uuid4()), json={"service": "ot"})[0]
yield order
if not order.get("got_code"):
try:
api("POST", f"/v1/orders/{order['orderId']}/cancel") # refund
except RuntimeError:
pass # a code arrived after all, or the order already closed
def wait_for_code(order, timeout=300):
end = time.time() + timeout
while time.time() < end:
o = api("GET", f"/v1/orders/{order['orderId']}")
if o["otp"]:
order["got_code"] = True
return o["otp"][-1]["code"]
if o["status"] == "cancelled":
break
time.sleep(4)
pytest.fail("no email code before the timeout")
def test_signup_with_email_code(inbox):
driver = webdriver.Chrome()
try:
driver.get("https://staging.example.com/signup")
driver.find_element(By.NAME, "email").send_keys(inbox["email"])
driver.find_element(By.CSS_SELECTOR, "button[type=submit]").click()
code = wait_for_code(inbox)
WebDriverWait(driver, 10).until(EC.element_to_be_clickable((By.NAME, "code"))).send_keys(code)
driver.find_element(By.CSS_SELECTOR, "button[type=submit]").click()
WebDriverWait(driver, 10).until(EC.url_contains("/welcome"))
finally:
driver.quit()
Use requests rather than bare urllib: the CDN in front of the API answers urllib's default user agent with HTTP 403.
Java with TestNG
A small client on the JDK's own HttpClient (Java 11+) and Jackson, then the test:
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.net.URI;
import java.net.http.*;
import java.time.Duration;
import java.util.UUID;
public class OtpInbox {
private static final String HOST = "otpgmail.net";
private static final HttpClient HTTP = HttpClient.newHttpClient();
private static final ObjectMapper JSON = new ObjectMapper();
public final String orderId, email;
private boolean gotCode;
private OtpInbox(JsonNode o) { orderId = o.get("orderId").asText(); email = o.get("email").asText(); }
private static JsonNode call(String method, String path, String body, String idem) throws Exception {
HttpRequest.Builder req = HttpRequest.newBuilder(URI.create("https://" + HOST + path))
.timeout(Duration.ofSeconds(30))
.header("Authorization", "Bearer " + System.getenv("OTPGMAIL_API_KEY"))
.header("Content-Type", "application/json")
.method(method, body == null ? HttpRequest.BodyPublishers.noBody() : HttpRequest.BodyPublishers.ofString(body));
if (idem != null) req.header("Idempotency-Key", idem);
JsonNode res = JSON.readTree(HTTP.send(req.build(), HttpResponse.BodyHandlers.ofString()).body());
if (!res.get("success").asBoolean()) throw new IllegalStateException(res.at("/error/code").asText());
return res.get("data");
}
public static OtpInbox rent(String service) throws Exception {
String body = JSON.createObjectNode().put("service", service).toString();
return new OtpInbox(call("POST", "/v1/orders", body, UUID.randomUUID().toString()).get(0));
}
public String waitForCode(Duration timeout) throws Exception {
long end = System.nanoTime() + timeout.toNanos();
while (System.nanoTime() < end) {
JsonNode o = call("GET", "/v1/orders/" + orderId, null, null), otp = o.get("otp");
if (otp.size() > 0) { gotCode = true; return otp.get(otp.size() - 1).get("code").asText(); }
if ("cancelled".equals(o.get("status").asText())) break;
Thread.sleep(4000);
}
throw new AssertionError("no email code before the timeout");
}
public void cancelIfUnused() {
if (gotCode) return;
try { call("POST", "/v1/orders/" + orderId + "/cancel", null, null); } catch (Exception ignored) { }
}
}
import java.time.Duration;
import org.openqa.selenium.*;
import org.openqa.selenium.chrome.ChromeDriver;
import org.openqa.selenium.support.ui.*;
import org.testng.annotations.*;
public class SignupTest {
private WebDriver driver;
private OtpInbox inbox;
@BeforeMethod
public void setUp() throws Exception {
inbox = OtpInbox.rent("ot"); // Any service: your own app
driver = new ChromeDriver();
}
@Test
public void signUpWithEmailCode() throws Exception {
WebDriverWait wait = new WebDriverWait(driver, Duration.ofSeconds(10));
driver.get("https://staging.example.com/signup");
driver.findElement(By.name("email")).sendKeys(inbox.email);
driver.findElement(By.cssSelector("button[type=submit]")).click();
String code = inbox.waitForCode(Duration.ofMinutes(5));
wait.until(ExpectedConditions.elementToBeClickable(By.name("code"))).sendKeys(code);
driver.findElement(By.cssSelector("button[type=submit]")).click();
wait.until(ExpectedConditions.urlContains("/welcome"));
}
@AfterMethod(alwaysRun = true)
public void tearDown() {
if (driver != null) driver.quit();
if (inbox != null) inbox.cancelIfUnused(); // refund when no code came
}
}
Split OTP boxes, links and stale codes
- Six boxes instead of one field. Typing the whole code into the first box often works because the page moves the focus. If it does not, send one character per box:
for box, ch in zip(driver.find_elements(By.CSS_SELECTOR, ".otp input"), code): box.send_keys(ch). - A link instead of a code. If the value starts with
https://, check that the host is your app's domain, thendriver.get(value). - Resend tests. Note
len(otp)before clicking "Resend" and wait until the array grows. The newest entry is last, andreceivedAt(Unix seconds) says when it arrived. - A whole email instead of a code. When no code can be extracted, the email comes back verbatim. A value longer than 80 characters, with a line break or HTML, is the full message: parse it yourself, or make the code easier to find in your template.
- Two kinds of timeout.
WebDriverWaitcovers the page, in seconds; the API wait covers delivery, in minutes. Keep the API wait below your runner's test timeout. The order'scodeDeadlineAtcaps it anyway.
Parallel runs, rate limits and cleanup
- One inbox per test. Parallel workers then never read each other's codes; a shared mailbox is where many flaky OTP tests start.
- Rate limits. By default each API key allows 20 requests per second and 900 per minute; the current values are on the API docs page. A test polling every 4 seconds makes 15 requests a minute, so one key serves about 60 tests waiting at once. Above the limit you get HTTP 429,
RATE_LIMITED, with aRetry-Afterheader in seconds. - Waiting orders are capped per account (
WAITING_LIMIT_REACHED), so keep Grid concurrency below that cap. - Rent late, never in a loop. Rent right before the form is submitted. Renting pauses (
RATE_LIMITED) after too many cancellations without a code in the last 60 minutes. - Retries. A new
Idempotency-Keyfor each new order; reuse a key only to resend a request that timed out. Allow about 15 seconds for order creation. - Secrets. Keep the key in your CI secret store. Grid nodes never need it, because the API calls run in the test process.
Test your own product, or a third-party service within its terms. Image or slider captchas sit outside this pattern; they belong to the app owner's test configuration. The Gmail OTP API overview lists every endpoint used here.
Frequently asked questions
Can Selenium sign in to Gmail and read the code there?
Not reliably. Google says it may stop sign-ins from browsers "controlled through software automation rather than a human", and a Gmail session adds a second fragile UI to the test. Read the code over an API instead, from a mailbox you own or from a rented inbox, as compared in Gmail API, IMAP or a rented inbox.
How do I handle an SMS OTP in Selenium?
Same shape, different channel: fetch the code from wherever the SMS lands (your SMS provider's test numbers or a number your team controls) and type it in. OTPGmail handles email only.
What about authenticator (TOTP) codes?
They are never sent anywhere. Store the test account's shared secret and compute the current code with a TOTP library at the moment you type it; the usual time step is 30 seconds.
Can I use JUnit 5 instead of TestNG?
Yes. Swap @BeforeMethod for @BeforeEach and @AfterMethod(alwaysRun = true) for @AfterEach; OtpInbox stays the same.
