tutorials
Detect OTP and verification emails automatically
August 7, 2026

You want one-time codes pulled out of incoming mail the moment they arrive: to auto-fill a login, drive a test, or surface a copy-the-code button in your inbox. The pattern looks trivial until your "OTP detector" starts grabbing order numbers and invoice totals.
OTP, 2FA, and verification emails are one of the few email categories with a clean, useful payload: a short code you want extracted and surfaced fast. The temptation is to write a five-line regex and move on. That regex works in the demo and then quietly matches Order #483920 in production. This post builds the detector properly: the two signals you actually need, the extraction logic, the eval suite that keeps it honest, and an LLM fallback for the cases regex cannot read. Then it shows the version Tinbox ships so you do not maintain any of it. OTP extraction is the friendliest of the email interpreter recipes for smarter inbound handling, so it is a good first one to wire up before the harder, tone-based interpreters.
How do I detect an OTP email automatically?
Require two signals in the same message before you call it an OTP. First, a context phrase: verification code, one-time password, security code, login code, 2FA, passcode, or similar. Second, a code-shaped token: 4-8 digits, or two groups like 123 456 or 123-456. Detecting on the code shape alone is the mistake everyone makes, because four-to-six-digit numbers are everywhere in normal mail (order IDs, invoice numbers, years, prices). The context phrase is the precision filter.
Here is the naive version first, so the failure is concrete:
// The tempting one-liner. Do not ship this.
function badOtpDetector(body: string): string | null {
const m = body.match(/\b(\d{4,6})\b/);
return m ? m[1] : null;
}
badOtpDetector("Your code is 552113"); // "552113" correct
badOtpDetector("Order #483920 confirmed"); // "483920" WRONG
badOtpDetector("Invoice 7781 for $1200"); // "7781" WRONG
badOtpDetector("Our 2024 highlights"); // "2024" WRONG
Three out of four are false positives. A login-autofill driven by badOtpDetector would happily paste an order number into a 2FA field. The fix is not a cleverer number regex; it is requiring OTP context before you extract anything.
What regex actually works for OTP detection?
Use three patterns: a context phrase to gate on, a labelled-code pattern to extract precisely, and a loose code pattern as a fallback. The gate is what separates a real detector from a number-grabber.
/** Phrases that signal a verification/login code is present. */
const CODE_CONTEXT =
/\b(one[\s-]?time (?:code|password|pin)|verification code|security code|login code|authentication code|2fa|otp|passcode|access code|confirm(?:ation)? code|your code is)\b/i;
/** A code-looking token: 4-8 digits, or grouped like "123 456" / "123-456". */
const CODE_NEAR = /\b(\d{4,8}|\d{3}[\s-]\d{3})\b/;
/** Strong "code is right here" pattern: "code is 123456", "code: 123456". */
const CODE_LABELLED =
/\b(?:code|otp|passcode|pin)\b[^\d]{0,12}(\d{3}[\s-]?\d{3}|\d{4,8})/i;
Three patterns instead of one, and each earns its place:
CODE_CONTEXTis the gate. If a message has no OTP phrasing, you do not extract a code from it, full stop. This is what kills the order-number and invoice false positives.CODE_LABELLEDis the precise extractor. When the message sayscode: 552113oryour one-time passcode is 8472, you want exactly those digits, not some other number elsewhere in the footer.CODE_NEARis the fallback. Some OTP emails put the code in the subject (123456 is your Google verification code) with nocode:label in the body. Once context is confirmed, the first code-shaped token is a reasonable guess.
How do I extract the code once I know it is an OTP?
Prefer a labelled code, fall back to a loose token, then normalize. Run the labelled pattern over the subject and body first; only if nothing is labelled do you take the first code-shaped token. Normalizing strips spaces and hyphens so 481 902 becomes 481902, which is what you actually paste.
function normalizeCode(raw: string): string {
return raw.replace(/[\s-]/g, "");
}
/** Pull the most likely code out of subject+body, given context is confirmed. */
function extractCode(subject: string, body: string): string | null {
const haystacks = [subject, body];
// Prefer a clearly-labelled code.
for (const h of haystacks) {
const m = h.match(CODE_LABELLED);
if (m?.[1]) return normalizeCode(m[1]);
}
// Else, a code-shaped token (we already know the message has OTP context).
for (const h of haystacks) {
const m = h.match(CODE_NEAR);
if (m?.[1]) return normalizeCode(m[1]);
}
return null;
}
/** The whole detector: gate on context, then extract. */
function detectOtp(subject: string, body: string): { code: string } | null {
const hay = `${subject}\n${body}`;
const hasContext = CODE_CONTEXT.test(hay);
const labelled = CODE_LABELLED.test(hay);
if (!hasContext && !labelled) return null; // not an OTP email
const code = extractCode(subject, body);
return code ? { code } : null;
}
Run the four earlier examples through detectOtp and the false positives disappear, because Order #483920 and Invoice 7781 never pass the context gate. That is the entire trick: two signals, in order, gate before extract.
How do I stop my OTP detector from silently regressing?
Keep a fixed table of fixtures and run it on every change. The dangerous failure mode for an OTP detector is not "missed a code" (annoying), it is "fired on a normal email" (it pastes garbage into an auth field, or flags an invoice as a time-critical login code). You catch both with a small eval suite of real positives and real negatives.
interface Case {
name: string;
subject: string;
body: string;
expect: { matched: boolean; code?: string };
}
const cases: Case[] = [
// Positives: must match AND extract the right code.
{
name: "google 2fa subject",
subject: "123456 is your Google verification code",
body: "Use the code 123456 to sign in. Don't share it.",
expect: { matched: true, code: "123456" },
},
{
name: "labelled code in body",
subject: "Your security code",
body: "Your one-time passcode is: 8472. It expires in 10 minutes.",
expect: { matched: true, code: "8472" },
},
{
name: "grouped 6-digit code",
subject: "Login code",
body: "Enter this verification code to continue: 481 902",
expect: { matched: true, code: "481902" },
},
// Negatives: must NOT match (this is where naive regex dies).
{
name: "order confirmation",
subject: "Order #483920 confirmed",
body: "Thanks for your order 483920. It ships Tuesday.",
expect: { matched: false },
},
{
name: "invoice with amounts",
subject: "Invoice 7781 - $1200 due",
body: "Your invoice 7781 for 1200 dollars is attached.",
expect: { matched: false },
},
{
name: "newsletter with a year",
subject: "Our 2024 highlights",
body: "In 2024 we shipped a lot. Read more below.",
expect: { matched: false },
},
];
function runEvals(): void {
let failed = 0;
for (const c of cases) {
const got = detectOtp(c.subject, c.body);
const matched = got !== null;
const ok =
matched === c.expect.matched &&
(!c.expect.code || got?.code === c.expect.code);
if (!ok) {
failed++;
console.error(`FAIL ${c.name}: expected`, c.expect, "got", got);
}
}
if (failed) {
console.error(`${failed}/${cases.length} OTP eval cases failed`);
process.exit(1);
}
console.log(`OTP evals passed (${cases.length} cases)`);
}
runEvals();
Wire runEvals into CI. Now if someone "improves" CODE_NEAR to \d{3,} to catch a stray case, the invoice and order negatives fail and the change does not merge. Eval-gating an algorithmic rule is the same discipline you would apply to an LLM classifier; the rule is just cheaper to run. For the LLM-classifier version of this, see writing eval suites for LLM email classifiers, and for why this matters at all, the reasons your email AI needs eval tests spell out what a silent regression actually costs.
When do I actually need an LLM for OTP detection?
Reach for an LLM only for the messages the regex genuinely cannot read, and run the cheap check first. The algorithmic detector handles the overwhelming majority of OTP mail at zero cost and zero latency, with no risk of a model inventing a code. An LLM earns its place on the long tail: a code rendered inside an image with text around it, an unusual layout, or non-English phrasing your context regex does not cover.
The pattern is algorithmic-first, LLM-fallback. Only the misses cost a token:
import { z } from "zod";
const OtpOutput = z.object({
isOtp: z.boolean(),
code: z.string().nullable(),
});
async function llmOtpFallback(
subject: string,
body: string,
callModel: (prompt: string) => Promise<string>,
): Promise<{ code: string } | null> {
const raw = await callModel(
`You classify whether an email contains a one-time verification code (OTP/2FA).
Return JSON: { "isOtp": boolean, "code": string|null }.
Extract the code ONLY if the email is clearly delivering a login/verification code.
Do NOT treat order numbers, invoice numbers, tracking numbers, or years as codes.
Subject: ${subject}
Body: ${body}`,
);
// Validate: never trust raw model text. A bad shape is treated as "no OTP".
const parsed = OtpOutput.safeParse(JSON.parse(raw));
if (!parsed.success || !parsed.data.isOtp || !parsed.data.code) return null;
return { code: parsed.data.code };
}
/** Cheap deterministic check first; model only on the misses. */
async function detectOtpWithFallback(
subject: string,
body: string,
callModel: (prompt: string) => Promise<string>,
): Promise<{ code: string } | null> {
return detectOtp(subject, body) ?? (await llmOtpFallback(subject, body, callModel));
}
Two things make this safe rather than a liability. The model output goes through a strict schema (OtpOutput.safeParse), so a malformed or off-prompt response degrades to "no OTP" instead of crashing or pasting nonsense. And the prompt explicitly names the false-positive classes (order, invoice, tracking, year) that the algorithmic rule already learned to reject. An LLM detector needs the same eval table as the regex one; do not skip it just because a model is involved.
Or skip all of this: the Tinbox OTP interpreter
Everything above is genuinely useful to own for one address, but you have now signed up to maintain a regex, an extractor, an eval suite, and an optional model fallback, and to keep the negatives green as real traffic surprises you. If you would rather not, this is the part Tinbox already did.
Tinbox runs inbound mail through an interpreter pipeline on your own Cloudflare account, and one of the built-ins is an algorithmic OTP interpreter. It does exactly what this post built: gate on an OTP context phrase, prefer a labelled code, fall back to a code-shaped token, normalize the result. On a match it writes the code into a structured field, flags the message as realtime_important (you want that code now), and tells the inbox to render an otp-quick-copy component. It ships with the same positive-and-negative eval suite shown above as its ground truth, so a change cannot silently start matching invoices.
npx tinbox init yourdomain.com
That scaffolds the inbound handler and provisions D1, R2, and Queues. The OTP interpreter is on by default. If you want to tune it, the configuration is one line in app/interpreters.config.ts:
// app/interpreters.config.ts
import { defineInterpreters, defaultInterpreters, otp } from "tinbox/cf/interpreters";
export default defineInterpreters([
...defaultInterpreters(),
// Override the built-in: file detected codes under an "auth" category.
otp({ category: "auth" }),
]);
The detected code lands as structured data on the message, so the inbox can show a copy button and your automations can read extra.otp_value directly instead of re-parsing the body. For the messages the algorithmic rule cannot read, you add an LLM interpreter alongside it (instructions plus a Zod output schema, same shape as the fallback above), and the engine runs the model and caches the result. If you want an assistant to act on a detected code rather than just display it, the same parsed data feeds the MCP email server so Claude or another agent can read it over a tool call.
For how inbound mail gets parsed and routed to interpreters in the first place, see turning inbound email into a webhook with Cloudflare Workers. For the bigger picture of receiving, classifying, and sending on one Cloudflare-native stack, see the Tinbox overview.
Deploy this yourself: npx tinbox init yourdomain.com. Full docs for the interpreter pipeline, the built-in OTP detector, and writing your own LLM interpreters are in the Tinbox documentation.
Frequently asked questions
How do I detect an OTP or verification email automatically?
Require two signals before you treat a message as an OTP. First, a context phrase such as verification code, one-time password, security code, login code, 2FA, or passcode. Second, a code-shaped token near that phrase: 4-8 digits, or two groups like 123 456 or 123-456. Firing on the code shape alone produces false positives on order numbers, invoices, and years, so the context phrase is what keeps precision high.
How do I extract the verification code from an email body?
Prefer a clearly labelled code: match patterns like 'code: 123456', 'your code is 552113', or 'passcode 8472', and capture the digits. If nothing is labelled but the message already passed the context check, fall back to the first code-shaped token in the subject or body. Normalize the result by stripping spaces and hyphens so '481 902' becomes '481902'.
Why does my OTP regex match the wrong number?
A bare digit pattern like \d{4,6} matches order numbers (Order #483920), invoice totals (Invoice 7781), tracking numbers, and years (2024). The fix is to require an OTP context phrase in the same message before extracting any code, and to prefer a labelled code (code is X) over a loose token. Test both positives and negatives in an eval suite so a regex tweak cannot silently start matching invoices.
Do I need an LLM to detect OTP emails?
No, not for the common case. Most OTP and 2FA emails are caught by a deterministic algorithmic check (context phrase plus code token) that is instant, free, and never hallucinates. An LLM is useful as a fallback for messages where the code sits inside an image, an unusual layout, or non-English phrasing the regex does not cover. Run the cheap algorithmic check first and only escalate the misses.
How do I stop OTP detection from regressing when I edit the rule?
Keep a fixed table of labelled fixtures: real OTP emails that must match and extract the right code, plus normal mail (order confirmations, invoices, newsletters) that must NOT match. Run that suite in CI on every change. If a regex edit makes an invoice look like an OTP, the negative case fails before it ships. This is the same eval discipline used for LLM classifiers, applied to the algorithmic rule.
What does Tinbox use to detect OTP emails?
Tinbox ships a built-in algorithmic OTP interpreter that detects one-time codes, extracts the value into a structured field, flags the message as real-time important, and renders a quick-copy component in the inbox. It comes with its own eval suite of positive and negative fixtures. You can tune it (change its category, add instructions) or pair it with an LLM interpreter for the cases regex cannot read.


