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

  1. Before opening the page, rent an inbox with POST /v1/orders, sending a new Idempotency-Key. Your own app is not in the service list, so use ot (Any service); a listed third-party service uses its code from GET /v1/services.
  2. Type data[0].email into the form and submit it.
  3. Poll GET /v1/orders/{orderId} every 3–5 seconds until otp has an entry, then take the last one: the newest code, or a verification link.
  4. Type the code, or open the link, behind an explicit wait.
  5. In teardown, cancel the order if no code came: POST /v1/orders/{orderId}/cancel refunds 100%. An order you forget cancels itself at its codeDeadlineAt (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

Parallel runs, rate limits and cleanup

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.

Related guides

Get your verification code now

Sign up free. You are only charged when an inbox is issued. No code within 25 minutes — 100% refunded automatically.

Can't find the service you need? Use Add service on the rental page and we will add it if our infrastructure supports it.

Need a Gmail account you can sign in to, not just a code? See buy short-lived Gmail accounts · Refund and warranty