OTP Verification: How It Works and How to Automate It

OTP codes are everywhere — sign-up flows, password resets, magic link alternatives. Understanding how they work helps you implement them properly and test them reliably.

What is a one-time passcode?

A one-time passcode (OTP) is a short numeric or alphanumeric code generated by a server specifically for one user, at one moment, for one purpose. The server generates the code, stores a hash of it alongside the user identifier and an expiry timestamp, sends the code to the user's email or phone, and then waits for the user to submit it. When the user submits the code, the server looks it up, verifies it matches, checks the expiry, marks it used, and completes the action.

Three properties define a proper OTP: it is generated with a cryptographically secure random number generator (not Math.random()); it has a short expiry (typically 5–15 minutes); and it is single-use (invalidated after first successful verification).

OTP vs magic links

OTPs and magic links solve the same problem — proving that you control the email address — but work differently. An OTP is a short code you enter into a form on the originating site. A magic link is a URL you click; clicking it sends the token to the server and authenticates you directly. Magic links have a slightly lower friction floor (no typing), but require the email client to be on the same device (or easily accessible). OTPs work well when the user is on a device where clicking email links is inconvenient (smart TV, console, or an environment where email and the app are separated).

Many services send both: a magic link for desktop users and a 6-digit code for users on a different device. TempMailGrab handles both — it extracts OTP codes and highlights verification links.

Why OTP codes look like they do

Most OTP codes are 4–8 digits long. The length reflects a tradeoff between security (more digits → more combinations → harder to brute-force) and usability (fewer digits → faster to type). With 6 digits and a 10-minute window, there are 1,000,000 possible codes. Combined with rate limiting (typically 5 attempts before a lockout), brute-forcing is not feasible for a real attacker. If a service sends a 4-digit code without rate limiting, it is poorly implemented — 10,000 combinations with no lockout is guessable in minutes.

Implementing OTP correctly

Common OTP implementation mistakes and how to avoid them:

  • Using Math.random() for code generation: Math.random() is a pseudorandom number generator, not a cryptographically secure one. Use crypto.randomInt() (Node.js) or crypto.getRandomValues() (Web Crypto API / Cloudflare Workers). The difference matters: a predictable PRNG allows an attacker to predict future codes if they can observe past ones.
  • No rate limiting: Implement per-email or per-IP rate limiting on the OTP submission endpoint. 5 attempts per code, with an exponential backoff on failure, is a reasonable floor.
  • Storing the code in plaintext: Store a hash of the code (SHA-256 is sufficient — OTPs are not passwords, but hashing prevents plaintext exposure in a DB breach).
  • Long validity windows: 15 minutes is the maximum reasonable window. 24-hour validity OTPs (some services do this) significantly increase the window for credential-stuffing attacks that intercept the code.
  • Not invalidating on use: Mark the code as used atomically with the action it authorizes. If the invalidation and the action are separate transactions, a race condition allows double-use.

Automating OTP verification in tests

The TempMailGrab API exposes OTP extraction as a structured field on every message object. After creating an inbox and triggering your application's OTP flow, poll the messages endpoint:

GET /api/v1/inbox/{id}/messages
Authorization: Bearer YOUR_API_KEY

// Response:
{
  "messages": [{
    "id": "msg_01j...",
    "subject": "Your verification code",
    "sender": "noreply@yourapp.com",
    "timestamp": "2025-06-14T10:23:41Z",
    "extracted_otp": "847291",
    "extracted_links": [
      "https://yourapp.com/verify?token=abc123"
    ],
    "text_body": "Your code is 847291. It expires in 10 minutes.",
    "html_body": "..."
  }]
}

The extracted_otp field returns the detected code or null. The extraction algorithm identifies 4–8 digit sequences in proximity to contextual keywords (code, OTP, verification, PIN, passcode) and deprioritizes numbers that match phone numbers, years, currency amounts, or order numbers.

Handling multi-step OTP flows

Some applications send multiple emails to the same inbox during a test (welcome email, then OTP, then onboarding). To ensure you pick up the right OTP, filter messages by subject or sender before checking extracted_otp:

const messages = await getMessages(inboxId);
const otpMessage = messages.find(
  m => m.sender.includes('noreply@yourapp.com')
    && m.subject.includes('verification')
    && m.extracted_otp
);
if (!otpMessage) throw new Error('OTP message not received');

OTP expiry and test timing

If your OTP has a 5-minute expiry and your CI job is slow, a test might arrive at the OTP entry step after the code has expired. Strategies to avoid this: increase the OTP validity window in test environments; add a configurable validity override via environment variable; or use webhook delivery to eliminate the polling latency that adds up in slow pipelines. The TempMailGrab API delivers via webhook within milliseconds of processing, which typically means the OTP arrives in your test runner within 2–5 seconds of the sending server dispatching it.

Related articles

See also: OTP use case · API docs