TextVeilPad

Regex patterns for emails, phones and cards

7 min readUpdated May 2026
Close-up of a bank card, its embossed number and expiry date catching the light
Close-up of a bank card, its embossed number and expiry date catching the light

Writing a regular expression that finds email addresses is easy. Writing one that finds email addresses and nothing else is impossible, and chasing that goal produces monsters. The practical approach is different: use a deliberately simple pattern to find candidates, then apply a second, cheap test — a digit count, a checksum, an entropy estimate — to throw away the ones that only look right. This guide walks through the patterns behind a text redactor and the validation that makes each of them usable.

Email: the perfect pattern is a trap

RFC 5322 allows quoted local parts, comments in parentheses, folding whitespace and bracketed IP literals as domains. The well-known regular expression that implements it faithfully runs to several thousand characters, and it still accepts addresses no mail server would deliver to. Matching the grammar is not the goal; finding the thing people will recognise as an address is.

A pattern along the lines of [A-Za-z0-9._%+-]+@[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)*\.[A-Za-z]{2,24} covers everything you will meet in a log file, including plus-addressing and long modern top-level domains. The two details that matter more than the body are the boundaries: a negative lookbehind stops the match from starting in the middle of a word, and requiring at least two letters in the final label stops it from swallowing a trailing dot.

Expect to catch a few things that are not addresses — a Twitter handle glued to a domain, a Maven coordinate — and accept it. In redaction, a false positive costs you one unnecessary placeholder; a false negative costs you a leaked address.

Phone numbers: count the digits, do not trust the shape

E.164 caps an international number at fifteen digits including the country code, and national numbering plans start at around eight. Everything between those bounds is written in a dozen notations: +7 903 555 01 42, 8 (903) 555-01-42, +1 (415) 555-2671, 415.555.2671, +44 20 7946 0958. A pattern flexible enough to cover them will also match a date, a version string and an order number.

The fix is to validate after matching rather than to complicate the pattern. Strip everything that is not a digit and require between ten and fifteen of them; require at least one separator or a leading plus, which eliminates bare digit runs; and reject strings where every digit is the same. Those three rules remove most of the noise without touching a single real number.

Ordering also matters. If your card and IBAN detectors run before the phone detector and win on overlap, a sixteen-digit card written with spaces never gets reported as a phone number, and an ISO timestamp is claimed by the date detector instead. Detection quality often comes from arbitration between patterns rather than from the patterns themselves.

Card numbers: Luhn is what makes the pattern usable

Payment card numbers are thirteen to nineteen digits, and the last one is a check digit defined by the Luhn algorithm in ISO/IEC 7812-1. Walk the digits from right to left, double every second one, subtract nine when the result exceeds nine, sum everything, and the total must be divisible by ten. It costs a few microseconds and removes almost every false positive a bare digit pattern produces.

The leading digits identify the issuer: 4 for Visa, 51–55 and 2221–2720 for Mastercard, 34 and 37 for American Express (fifteen digits), 6011 and 65 for Discover, 2200–2204 for Mir, 62 for UnionPay, 3528–3589 for JCB. Reporting the brand alongside the finding is a small touch that helps the person deciding what to do, and it costs one extra regular expression.

When you replace a card number, keeping it Luhn-valid is worth the effort if anything downstream will parse the text. Build the replacement from the same brand prefix, fill the middle deterministically, then compute the final digit so the checksum passes. The result is obviously fake to a human and perfectly acceptable to a validator.

IBAN, SNILS, INN: the same idea in three alphabets

An IBAN under ISO 13616 is a two-letter country code, two check digits and up to thirty more characters. To verify it, move the first four characters to the end, replace every letter with its position in the alphabet plus nine (A becomes 10, Z becomes 35), read the result as one long integer and take it modulo 97: a valid IBAN gives exactly 1. Because the integer is longer than a 64-bit type, you process it digit by digit, keeping a running remainder.

Russian identifiers use modulus 11. SNILS weights its first nine digits by 9, 8, 7 down to 1, sums them, and compares the result with the last two digits, with the rules that 100 and 101 become 00 and anything above 101 is taken modulo 101 first. INN uses fixed weight vectors — one pass for the ten-digit company form, two for the twelve-digit personal form — and takes the sum modulo 11 and then modulo 10.

The Estonian isikukood works the same way: ten digits weighted 1 to 9 and then 1, modulo 11, and if the remainder is 10 the calculation repeats with a second weight vector starting at 3. Patterns like these are why identifier detection can be nearly exact — and why detectors for numbers without a checksum, such as passport series or postal codes, deserve to be switched off by default.

Boundaries, Unicode and the \b problem

In JavaScript, \w means [A-Za-z0-9_], so \b is a boundary between those characters and everything else. In a Russian sentence every Cyrillic letter counts as a non-word character, which means \b fires in places you did not intend and fails in places you did. For mixed-language text, prefer explicit lookarounds over \b — (?<![\d-]) before a number and (?![\d-]) after it say exactly what you mean.

Sentence punctuation is the other classic failure. A card number at the end of a sentence is followed by a full stop, so a lookahead that forbids a dot will silently refuse to match it. Forbid only what would genuinely change the meaning — more digits, another hyphen — and trim trailing punctuation from URL matches afterwards instead of trying to exclude it inside the pattern.

If you need letters from any script, use the u flag and Unicode property escapes: \p{L} for any letter, \p{Lu} for uppercase, \p{Script=Cyrillic} for a specific script. They are supported in every current browser engine and they make intent obvious to the next person reading the pattern.

Test with both lists, and watch the backtracking

Keep two fixtures for every detector: a list of values it must find and a list of look-alikes it must ignore. For phone numbers the second list contains ISO timestamps, semantic versions, order numbers and long digit runs; for the high-entropy detector it contains base64-encoded images, git hashes and long constant names. A pattern is finished when both lists pass, not when the first one does.

Watch out for catastrophic backtracking. Nested unbounded quantifiers such as (\d+[\s-]*)+ can take exponential time on a long non-matching string, which is a denial-of-service bug when the input comes from a user. Bound your repetitions — {2,4} instead of + — and test each pattern against a few kilobytes of adversarial input before you trust it.

Finally, measure on real data. A detector tuned on synthetic examples looks perfect until it meets a production log full of base64 payloads and UUIDs. Run it over a redacted sample of the traffic you actually handle, look at what it flags, and adjust the validation rather than the pattern wherever you can.

The takeaway

Keep patterns simple and put the intelligence in the second step. Count digits, run the Luhn and mod-97 checks, estimate entropy, arbitrate overlaps by priority, and prefer explicit lookarounds to \b in multilingual text. A detector that finds candidates and then proves them is both more accurate and easier to maintain than one enormous expression nobody dares to touch.

Related tool
TextVeilPad · PII redactor

Open the editor and check a piece of text