What this covers
An email address or phone number is usually the strongest pivot in an investigation, because platforms treat them as account keys. From one address you can often reach a display name, a profile photo, a set of registered services and sometimes a physical location.
What makes identifier work different from name work is that the answers are mostly binary and mostly cheap. A name returns a ranked list of maybes; an identifier returns yes or no per service. That is also the trap: a “no” from a service is four different claims wearing the same word, and most of the mistakes on this page come from recording the wrong one.
Method
- Validate before enriching. Confirm the address or number is structurally real and routable. Time spent enriching a typo, a placeholder or a drama-range number is wasted, and the check costs nothing.
- Normalise, then keep the original. Decide which spellings are the same mailbox before you count hits or deduplicate a list. Record the string as found as well as the normalised form — the as-found spelling is what you search for in dumps and code.
- Take the free, non-interactive signals first. DNS records, a Gravatar hash, provider-side profile data. None of this touches the owner and none of it can be rate-limited into a false negative.
- Then the registration oracles, knowing the cost. Account-existence checks against signup and password-reset flows reveal which services know the identifier, and some of them generate mail or SMS to the target.
- Pivot outward as a plain string. Code search, paste sites, breach indexes, the target’s own site, PDF metadata. Addresses get committed to repositories constantly, and numbers get left in vCards and footers.
- Pivot inward to durable keys. An address can change; a Google Gaia ID, a Telegram user ID or an internal account ID cannot. Trade the identifier you were given for the one the platform uses internally.
- Convert to a name, then switch approach. Once you have a name, you are doing people search, not identifier work.
The judgement calls: decide before you start whether this is a passive engagement, because that one decision rules out half the tools below and no tool will remind you. Decide whether you need the subscriber or the account — a recovery phone, a work number and a partner’s handset all answer “what number is attached to this account” and none of them answers “what number does this person carry”. And decide what a negative is worth to you, because the cheapest way to be wrong here is to write down “not registered” when what happened was a rate limit.
The existence oracle
Everything on the email side reduces to asking some service whether it knows a string, and the useful part is knowing exactly which string you asked about. Providers normalise differently, so the same mailbox has many spellings and every one of them is a distinct key to every tool, hash and dataset you will use.
Gmail ignores dots in the local part — Google’s own help states that if a sender “added dots to
your address, you’ll still get that email” — and, like any provider implementing subaddressing,
treats everything from a + to the @ as a tag on the same mailbox. So:
d.whitfield@example.com \
dwhitfield@example.com > one mailbox, if the provider is Gmail
dwhitfield+shop@example.com /
...and three unrelated keys, everywhere else:
sha256("d.whitfield@example.com") = fc9518a0411a19b071630d4b153e4774e0697053e4015e5eb9e1ab5c934021a3
sha256("dwhitfield@example.com") = 6a2ae976758ac8100467b0bea363301fcb6fe756c8262b95336be1d03dcbd919
sha256("dwhitfield+shop@example.com") = bbb5413ad03bc64773f79b7804cd4868f34e7fd7a5fa2e1808617ed45289369f
That matters immediately, because the cheapest non-interactive oracle in this whole area is a hash
lookup. Gravatar’s documented recipe is: trim leading and trailing whitespace, force the string to
lower case, hash it with SHA-256, and request https://gravatar.com/avatar/<hash>. The d=404
parameter turns the avatar endpoint into an existence test — ask for a default image of 404 and
you get an HTTP status instead of a fallback picture.
# the recipe, reproduced against the example in Gravatar's own documentation
printf '%s' "myemailaddress@example.com" | shasum -a 256
# 84059b07d4be67b806386c0aad8070a23f18836bbaae342275dc0a83414c32ee
# the probe: 200 means an avatar exists for that exact string, 404 means it does not
H=$(printf '%s' "d.whitfield@example.com" | shasum -a 256 | cut -d' ' -f1)
curl -s -o /dev/null -w '%{http_code}\n' "https://gravatar.com/avatar/$H?d=404&s=200"
# 404
Nothing reaches the owner, there is no rate limit worth worrying about, and a 200 hands you a photograph plus, often, a WordPress-ecosystem account. Older code and older datasets used MD5 of the same lower-cased string, which is why historic breach corpora and forum databases are full of MD5 avatar URLs; the current documentation specifies SHA-256, so compute both when you are matching against old data. A 404 speaks only for the exact string you hashed — run it again for the dot-stripped and tag-stripped spellings before you call it absent.
The interactive oracles are the signup and password-reset flows, and their answers fall into four classes that tools flatten into two:
what the flow says what you may record
-------------------------------------------- ------------------------------------------
"we have sent a reset link to that address" the account exists, on this spelling
"no account found for that address" no account, on this spelling only
the same reassuring page for every input nothing -- the site is enumeration-hardened
429, captcha, timeout, silence nothing -- and you are now rate-limited
holehe and ignorant both print [+] for the first, [-] for the second and [x] for the
fourth, and the third is indistinguishable from the second unless you test with a control address
you know does not exist. Always run one. A [-] column with no control is an unmeasured
instrument.
E.164 arithmetic
The phone side has less to probe and more to compute. E.164 is the international format every tool expects, and it decomposes into exactly three parts with a hard length cap:
+44 7700 900123
| | |
| | +-- subscriber number
| +-------- national destination code (here, a UK mobile block)
+------------ country code (1-3 digits)
E.164 allows at most 15 digits after the plus:
44 + 7700900123 = 2 + 10 = 12 digits, inside the limit
Two separate questions get confused here, and libphonenumber keeps them apart deliberately. Possible means the digit count is plausible for that country. Valid means the number matches a block the numbering plan actually defines. Neither means the number is assigned to a subscriber, and nothing short of ringing it or paying for an HLR lookup establishes that it is in service.
+1 415 555 0123 possible: True valid: True <- and reserved for fiction
+44 7700 900123 possible: True valid: False <- Ofcom drama range
+44 7700 9001 possible: True valid: False <- IS_POSSIBLE_LOCAL_ONLY (4)
+44 770090012345 possible: False valid: False <- TOO_LONG (ValidationResult 3)
The first line is the one to remember. +1 415 555 0123 passes validation because 555-01xx sits
inside a legitimate NANP structure, while the North American numbering plan reserves 555-0100
through 555-0199 for fictitious use. Ofcom is more honest about its equivalents and publishes
them: 07700 900000 to 900999 for mobile, 020 7946 0000 to 0999 for London, and similar
thousand-number blocks for other cities, with the note that “the numbers will not be allocated to
communications providers in the foreseeable future”. Memorise both. A number from either space in
a dataset tells you the record is synthetic, and it tells you that for free in the first second of
the investigation.
The third line hides a second trap. is_possible_number returns True there not because the
length is right but because the library counts a number that could be dialled locally, without its
area code, as possible — the docstring says so outright. The reason code is
IS_POSSIBLE_LOCAL_ONLY, which is 4; TOO_SHORT is 2 and TOO_LONG is 3. The boolean
collapses all of that, so call is_possible_number_with_reason when you intend to write down why
a number failed rather than that it did.
The other arithmetic worth having is what a masked recovery number is actually worth. Reset screens show you a tail — “we will text ●●●●●●23” — and the temptation is to enumerate forwards:
known prefix from a CV footer : 07700 9 (5 of 10 national digits)
masked tail from a reset page : 23 (2 of 10)
unknown digits : 10 - 5 - 2 = 3
candidates : 10^3 = 1000 <- far too many to probe quietly
So go the other way. Take a number you already have from an independent source and test whether it fits the mask. That is a confirmation with a known false-positive rate: a two-digit tail matches by chance one time in a hundred, which is weak on its own and strong in combination. A Google two-digit tail and a courier SMS three-digit tail that both fit the same candidate is a one in 100,000 coincidence, and that is the kind of statement you can defend.
Epieos
The first tool to reach for, because it is one query, it runs server-side and nothing it does reaches the address. Enter the address at epieos.com and it returns the accounts it can see attached to the string — Google and Microsoft account data, a Gravatar hit, and a spread of consumer services. The Google portion is the valuable half: a public account ID, the profile photo, and whatever Maps or calendar activity the owner left visible.
1. Paste the address. Do the dot-stripped and tag-stripped spellings separately; the
back end is matching strings, not mailboxes.
2. Read the Google block first and copy the account ID out of it. That is the Gaia ID,
and it is the durable key -- feed it to `ghunt gaia` rather than re-querying the
address later.
3. Treat an empty module as "no answer", not "no account". The result page does not
distinguish a service that said no from a service that failed or was not checked on
your tier.
4. Archive the result page before you close it. These lookups are a snapshot of someone's
privacy settings on one day and are not reproducible later.
The free lookup returns a subset of the modules and the rest sits behind a paid tier, which is the usual source of a phantom negative: a module you did not pay for looks exactly like a module that found nothing. Capture evidence properly — see Archiving & Evidence — because “Epieos said so last Tuesday” is not a citation.
GHunt
Deeper on Google specifically, and the tool that converts an address into Google’s internal account
ID. Install it with pipx and authenticate once; ghunt login offers three routes, and the
listening-mode handshake with the GHunt Companion browser extension is the one that does not
involve pasting cookies by hand.
pipx install ghunt
ghunt login # listening mode, base64 cookies, or manual entry
# the subcommands, from --help, before you guess at one
ghunt -h # {login,email,gaia,drive,geolocate,spiderdal}
ghunt email target@gmail.com
ghunt email target@gmail.com --json target_email.json
# the internal account ID, which survives an address change
ghunt gaia 1234567890123456789 --json target_gaia.json
# a Drive file or folder ID: owner, permissions, and the other files it reveals
ghunt drive 1A2b3C4d5E6f7G8h9I0jKlMnOpQrStUv
# a BSSID, for when the pivot is a wireless network rather than a person
ghunt geolocate -b 00:11:22:33:44:55
Read the output in that order: the Gaia ID first, because it is the thing you cite and the thing
you pivot on; then the profile photo URL, which goes straight into
reverse image search; then the service-by-service blocks. Use
--json on every run you intend to quote. The terminal rendering is for reading and the JSON is
for keeping, and the two are not equally easy to re-derive in six months.
How it misleads you: every query runs as an authenticated Google session, so use an account you are willing to lose, and expect the unusual request volume to be exactly what Google’s abuse systems are built to notice. More subtly, an empty module reflects the target’s visibility settings rather than their absence — a person with a locked-down profile and a person with no Google account produce output that looks similar at a glance. And the Drive module reports what your session can see, so a file shared with your throwaway account and a genuinely public file are not distinguished for you.
holehe
The broad sweep: it checks an address against 120-plus sites by watching how their registration and forgotten-password flows respond. The positional argument takes more than one address, which is how you run a whole normalisation set in a single pass.
pipx install holehe # or: pip3 install holehe
holehe target@example.com
# several spellings at once -- the positional argument is variadic
holehe d.whitfield@example.com dwhitfield@example.com dwhitfield+shop@example.com
# only the hits, which is the only readable form on a 120-site run
holehe target@example.com --only-used
# skip the password-recovery methods: fewer sites checked, much less chance of mail
holehe target@example.com -NP
# keep the run: writes holehe_<timestamp>_<email>_results.csv in the working directory
holehe target@example.com -C
# slow or rate-limiting sites, and output that survives a pipe
holehe target@example.com -T 20 --no-color --no-clear
The output is one line per site, prefixed [+] used, [-] not used and [x] rate-limited. That
is the whole vocabulary — holehe’s own legend prints those three and nothing else, so a module that
threw an exception is folded into one of them rather than flagged, and you cannot tell a broken
check from a clean negative by reading the terminal. Where a site leaks more than existence,
holehe appends it to the same line — a partially
masked recovery email, a masked phone number, sometimes a full name or an account creation date.
Those appended fragments are the most valuable thing the tool produces and the easiest to lose in
the scroll, which is the argument for -C and a look at the CSV rather than the terminal.
How it misleads you: upstream states the tool does not alert the target, and for most of the 120
sites that holds, but the underlying mechanism is the forgotten-password function — treat
“no notification” as a claim about the common case and not a guarantee, and reach for -NP when a
single unexpected reset email would end the engagement. Rate limiting is per egress IP and
accumulates across the run, so the sites checked last are the most likely to report [x]; a
second run from a different address will produce a different-looking result set from the same
truth. Never fold [x] into [-] when you write up.
Zehef
A second opinion with a different module mix, useful because its overlap with holehe is partial. It runs paste-site and breach-index checks alongside account checks, and generates candidate address combinations from a name.
git clone https://github.com/N0rz3/Zehef.git
cd ./Zehef
pip3 install -r requirements.txt
python3 zehef.py target@example.com
python3 zehef.py -h
The combination generator is the part to be careful with. It emits plausible address spellings for
a person, which is a legitimate way to build a candidate list for the oracles above — and nothing
it emits is a confirmed address. Keep generated strings in a separate column from observed ones; a
generated address that later produces a [+] somewhere is a finding, and a generated address in a
report without that step is a fabrication.
Syntax, DNS and SMTP
Before any tool, ask whether the domain can receive mail at all. This is two DNS queries, it is invisible to everybody, and it regularly ends the investigation early.
# the mail exchangers for the domain
dig +noall +answer MX example.com
# example.com. 126 IN MX 0 .
# SPF, DMARC and the rest of the policy record set
dig +short TXT example.com
# "v=spf1 -all"
dig +short TXT _dmarc.example.com
A single MX of 0 . is the null MX of RFC 7505: the domain is declaring that it accepts no mail
at all, so no mailbox on it exists, nothing was ever delivered to it and no account can have been
confirmed through it. Paired with v=spf1 -all — the domain sends no mail either — it means the
address in front of you is decorative. The opposite finding is just as useful: MX records pointing
at *.l.google.com or *.outlook.com tell you a custom-domain address is really a Google or
Microsoft account, which puts GHunt and the Microsoft modules back on the table for an address that
does not look like a free-mail one.
Confirming a specific mailbox, rather than the domain, means an SMTP conversation, and that is an active step with a log entry at the other end.
# RCPT probe: open the transaction, offer the recipient, quit before DATA.
# Nothing is sent. The server's reply to RCPT TO is the entire result.
swaks --to d.whitfield@example.com --from '<>' --quit-after RCPT --timeout 10
# client-side commands only, which is the readable form for a transcript
swaks --to d.whitfield@example.com --from '<>' --quit-after RCPT \
--no-info-hints --hide-receive --hide-informational
# name the server explicitly when you want to probe one specific MX
swaks --to d.whitfield@example.com --from '<>' --quit-after RCPT --server mx1.example.net
The trap is the first line of output. Swaks resolves the recipient domain’s MX itself using Perl’s
Net::DNS, and when that module is missing it says so and falls back to localhost:
*** MX Routing not available: requires Net::DNS. Using localhost as mail server
=== Trying localhost:25...
*** Error connecting to localhost:25:
*** Connection refused
That is not a result about the target. Install Net::DNS or pass --server from the dig output
above, and read the banner before you read the reply. Beyond that: a catch-all domain accepts every
recipient, so a 250 there means nothing; greylisting and tarpitting produce temporary rejections
that look like refusals; and large providers long ago stopped answering this question honestly.
Enumerating mailboxes through an authentication or delivery endpoint is also the step most likely
to be regulated where you are standing, so settle that before you type it, not after. DNS, WHOIS
and certificate-transparency work on the domain itself belongs on
Websites & Infrastructure.
Phone
phonenumbers (libphonenumber)
The Python port of Google’s libphonenumber, and the correct first step for every number you touch. It is offline, instant, free, and it answers the structural questions before you spend money or attention on anything else.
pip install phonenumbers
import phonenumbers
from phonenumbers import carrier, geocoder, timezone, PhoneNumberFormat, PhoneNumberType
# parse() needs a region hint for anything not in +E.164 form, and raises
# NumberParseException rather than guessing:
# phonenumbers.parse("020 7946 0123", None)
# -> NumberParseException: (0) Missing or invalid default region.
n = phonenumbers.parse("+447911123456", None)
print(phonenumbers.format_number(n, PhoneNumberFormat.E164)) # +447911123456
print(phonenumbers.format_number(n, PhoneNumberFormat.INTERNATIONAL)) # +44 7911 123456
print(phonenumbers.format_number(n, PhoneNumberFormat.NATIONAL)) # 07911 123456
print(phonenumbers.is_possible_number(n)) # True
print(phonenumbers.is_valid_number(n)) # True
print(phonenumbers.number_type(n) == PhoneNumberType.MOBILE) # True
print(carrier.name_for_number(n, "en")) # JT
print(geocoder.description_for_number(n, "en")) # Guernsey
print(timezone.time_zones_for_number(n))
# ('Europe/Guernsey', 'Europe/Isle_of_Man', 'Europe/Jersey', 'Europe/London')
print(phonenumbers.region_code_for_number(n)) # GG
print(phonenumbers.is_valid_number_for_region(n, "GB")) # False
print(phonenumbers.is_mobile_number_portable_region("GB")) # True
# why a number failed, rather than just that it did
z = phonenumbers.parse("+44770090012345", None)
print(phonenumbers.is_possible_number_with_reason(z)) # 3 (TOO_LONG)
# a known-good number for a region, to sanity-check your own parsing
print(phonenumbers.format_number(phonenumbers.example_number("GB"),
PhoneNumberFormat.E164)) # +441212345678
That output contains the lesson. A number that looks like an ordinary UK mobile resolves to region
GG, carrier JT and four candidate timezones, because +44 is shared between the United
Kingdom, Guernsey, Jersey and the Isle of Man. If you had written “UK subscriber” from the +44
you would have the wrong jurisdiction, the wrong regulator and the wrong carrier to serve anything
on. number_type returns PhoneNumberType.UNKNOWN, which is 99 and not 0 — 0 is
FIXED_LINE, so a truthiness check on the result is silently wrong.
How it misleads you: the carrier field is the original carrier. Upstream is explicit that for
regions supporting portability “we return the original carrier for the supplied number”, so in any
country with mobile number portability — which is_mobile_number_portable_region will tell you
about — the carrier name is a historical fact about a block, not a current fact about a subscriber.
An empty string means the mapping has no data, not that the number has no carrier. The geocoder
returns a numbering-plan area, which for a fixed line is roughly where the line is and for a mobile
is roughly where the block was issued; neither is a residence. And none of it speaks to whether
the number is in service.
PhoneInfoga
Format normalisation, carrier and line-type identification, and footprint searching, wrapped in a CLI and a local REST API. It is a Go binary: the documented installs are the release archive, Homebrew and Docker, and there is no pip package to reach for.
brew install phoneinfoga
# or: bash <( curl -sSL https://raw.githubusercontent.com/sundowndev/phoneinfoga/master/support/scripts/install )
# sudo install ./phoneinfoga /usr/local/bin/phoneinfoga
# or: docker pull sundowndev/phoneinfoga:latest
phoneinfoga version
phoneinfoga scan -n "+14155550123"
phoneinfoga scan -n "+1 (555) 444-1212" # ( ) - + are escaped for you
# the local web UI and REST API on port 5000, or a port you choose
phoneinfoga serve
phoneinfoga serve -p 8080
phoneinfoga serve --no-client # REST API only, no web client
# a compiled custom scanner
phoneinfoga scan -n "+14155550123" --plugin ./custom_scanner.so
# docker equivalents
docker run --rm -it sundowndev/phoneinfoga version
docker run --rm -it -p 5000:5000 sundowndev/phoneinfoga serve
Five scanners ship with it — Local, Numverify, Googlesearch, Googlecse and OVH — and they are configured by environment variable, not by flag:
export NUMVERIFY_API_KEY=... # Numverify scanner
export GOOGLE_API_KEY=... # Googlecse scanner
export GOOGLECSE_CX=... # Googlecse search engine ID
export GOOGLECSE_MAX_RESULTS=50 # optional, default 10, maximum 100
Local and Googlesearch and OVH need no configuration, which means an unconfigured install silently
runs a subset: a scan with no NUMVERIFY_API_KEY set is not a scan that found no carrier. Read the
country code as mandatory — the docs say it plainly — because a national-format number scanned
without one produces confident output about the wrong country. The Googlesearch scanner’s value is
a list of dork URLs you still have to open and judge yourself; treat it as a worklist, not a
finding. And the local scanner is numbering-plan metadata, so it inherits every portability caveat
from the section above.
Phunter
A number-oriented sweep with a reverse-lookup module and an Amazon account check, which is a different and narrower bet than PhoneInfoga’s.
git clone https://github.com/N0rz3/Phunter.git
cd ./Phunter
pip3 install -r requirements.txt
python3 phunter.py -t "+33644637111" # operator, location, line type, reputation
python3 phunter.py -f numbers.txt # a file of numbers
python3 phunter.py -a "+33644637111" # is the number attached to an Amazon account
python3 phunter.py -p "+33644637111" # reverse lookup: who the number belongs to
python3 phunter.py -p "+33644637111" -o owner.txt
python3 phunter.py -v # version and service status
The -v check is worth running first on a tool of this kind: these modules break when a target
site changes its flow, and a module that is quietly broken returns the same empty result as a
module that found nothing. -a and -p are the two that matter and they are the two that reach
out — the Amazon check exercises an account flow, and the reverse lookup queries third-party
directories whose coverage collapses outside a handful of countries. A “reputation” or spam flag
here describes crowd-sourced complaints about the number, which is evidence about the number’s
behaviour and not about its owner.
ignorant
The phone-side counterpart to holehe, by the same author and with the same flag vocabulary. It checks whether a number is registered on a short list of sites — the README names Snapchat and Instagram — and the positional arguments are the part people get wrong.
pip3 install ignorant
# country code WITHOUT the plus, then the national number WITHOUT the trunk zero
ignorant 33 644637111 # = +33 6 44 63 71 11
ignorant 44 7911123456 # = +44 7911 123456, not "07911 123456"
ignorant 33 644637111 --only-used
ignorant 33 644637111 -T 20 --no-color --no-clear
Same prefixes as holehe: [+] registered, [-] not registered, [x] rate-limited. Same
discipline applies — it works by poking registration flows, so it is active, and a short result
set from a long run usually means you were throttled rather than that the number is unused. Because
the site list is small, a clean sweep of negatives is close to worthless on its own; its use is
confirmation when you already have a candidate and want a second thread tying it to an account.
Telegram Phone Number Checker
Bellingcat’s tool for the single most productive messenger check, because Telegram will confirm registration and often hand over a username, a display name and a user ID.
pip install telegram-phone-number-checker
# with proxy support: pip install telegram-phone-number-checker[proxy]
# credentials come from https://my.telegram.org/ and live in a .env file,
# or on the command line
telegram-phone-number-checker --phone-numbers "+447911123456,+33644637111"
telegram-phone-number-checker --phone-numbers "+447911123456" \
--download-profile-photos --output whitfield.json
telegram-phone-number-checker --usernames "someuser,otheruser"
telegram-phone-number-checker --phone-numbers "+447911123456" \
--api-id 123456 --api-hash 0123456789abcdef0123456789abcdef \
--api-phone-number "+447700900123"
telegram-phone-number-checker --phone-numbers "+447911123456" --proxy socks5://127.0.0.1:9050
The user ID in the output is the durable key — usernames and display names change weekly, the ID does not — and the profile photo is a direct pivot to reverse image search and to Social Media Platforms for the rest of the account.
How it misleads you: the lookup runs as your own Telegram account, and upstream advises plainly
“not to use your personal account for automations as telegram may block it”. A result of
“no username detected” is a privacy setting, not an absence of an account, and an error line means
the account exists but has restricted phone-number discovery — which is itself a finding about how
careful the owner is. Default --output is results.json in the working directory, so name it per
target or you will overwrite the last run.
Caller-ID and directory databases
Crowd-sourced caller-ID apps hold name data for numbers that appear nowhere else, because their users uploaded their own address books.
| Tool | What it does | Cost |
|---|---|---|
| TrueCaller | Large crowd-sourced caller-ID database. Names come from other users’ contact lists, so accuracy varies and the entry may be a nickname. | partly free |
| GetContact | Same model, stronger coverage across Turkey, the Middle East and Central Asia. | partly free |
| ThisNumber | International phone directory listings. | free |
| NigeriaPhonebook | Nigerian number-to-name lookups. | free |
Searching a number in these apps can be visible to other users of the same app, and uploading a contact list to get access hands over everyone in your phone. Use a clean device. Read a hit as “at least one stranger saved this number under this label at some point”, which is a real signal and is not identification — the same number commonly returns a personal name, a trade name and an insult, all from different users, all years apart.
Messaging-app checks
Many messengers confirm whether a number is registered, and often expose a profile photo and status. Adding the number as a contact may make you visible to them — check the platform’s behaviour before you do it on a target who might notice. Do these from a dedicated device and number: the contact list on the account you use for checks is itself a disclosure, and some platforms surface “contacts who joined” to the people you looked up.
Identifier tools at a glance
| Tool | Identifier | What it does | Touches the target |
|---|---|---|---|
| Epieos | email, phone | One server-side query across Google, Microsoft, Gravatar and consumer services. | no |
| GHunt | email, Gaia ID | Google account depth: internal ID, photo, Maps, calendar, Drive. | no, but runs as your Google session |
| Gravatar hash probe | Deterministic SHA-256 lookup for an avatar and a linked profile. | no | |
dig MX/TXT | email domain | Whether the domain can receive mail at all, and who runs it. | no |
| holehe | Registration and reset-flow checks across 120+ sites. | yes — may generate mail | |
| Zehef | Paste and breach checks, account checks, address generation. | partly | |
| swaks | SMTP RCPT probe against the real mail exchanger. | yes — logged at the server | |
| phonenumbers | phone | Offline parse, validity, type, carrier block, timezone. | no |
| PhoneInfoga | phone | Normalisation plus scanner modules and a local REST API. | no for Local, yes for footprint scanners |
| Phunter | phone | Line metadata, reverse lookup, Amazon account check. | yes for -a and -p |
| ignorant | phone | Registration checks on a small set of sites. | yes |
| Telegram Phone Number Checker | phone | Telegram registration, username, display name, user ID. | yes — runs as your account |
Pitfalls
- “Valid” does not mean “assigned”.
+1 415 555 0123validates cleanly and is reserved for fiction. Validity is a statement about the numbering plan, nothing more; service status costs money or a ring. - Possible is weaker than valid, and both are weaker than real. A number can be possible, invalid and still in a dataset as a deliberate placeholder.
+44is not the United Kingdom. It is shared with Guernsey, Jersey and the Isle of Man, andregion_code_for_numberwill tell you which — after which your jurisdiction, regulator and carrier are all different.- Carrier data is pre-portability. In a portable region the lookup returns the carrier the block was issued to, which may be two providers out of date. An empty carrier field means no data, not no carrier.
- Reset-flow probing is active. holehe, ignorant, Phunter’s
-aand any SMTP probe can generate mail or a log entry. Passive-only work means Epieos, GHunt, DNS, hash probes and string searching, nothing that touches a login flow. [x]is not[-]. Rate-limited is not absent. Rate limits are per egress IP and get worse through a run, so the sites checked last are the least trustworthy in the output, and a second run from elsewhere will disagree with the first.- Run a control address. Enumeration-hardened sites return the same friendly page for every input. Without a known-nonexistent control in the same run, you cannot tell that apart from a negative.
- Null MX is a finding, not an error. An MX of
0 .means the domain accepts no mail, so the address never worked. Catch-all domains are the mirror image: they accept everything, so “the address exists” is meaningless on them. - One mailbox, many keys. Dots and
+tagsare the same Gmail inbox and completely different hashes, dataset rows and tool inputs. Normalise before you count, and probe every spelling before you record an absence. - A masked tail is a 1-in-100 coincidence. Two digits of a recovery number matching your candidate is weak evidence. Two independent masks from different services multiplying out to 1 in 100,000 is strong. Say which one you have.
- A recovery phone is not the subject’s phone. It is frequently a partner’s, a parent’s, a former employer’s desk line or a long-dead PAYG handset kept for exactly this purpose.
- Caller-ID names are user-submitted. “John Smith Plumber” may be what one stranger saved the number as years ago, and the lookup itself may be visible to that app’s other users.
- Recycled numbers and recycled mailboxes. Carriers reissue numbers after a few months of disuse and some providers release abandoned usernames, so an old registration may belong to someone unrelated — and account-existence output will not distinguish them.
- Aggregated identifiers age badly. An address that reached someone in 2019 may be dead now; date every lookup and quote the date beside the claim.
- Enumeration may be illegal where you are. Probing authentication endpoints for account existence is regulated in some jurisdictions regardless of intent. Settle that before the first command, not in the write-up.
Worked example
One datum: a tip-off naming Dana Whitfield, with one email address and one phone number and no
other detail — d.whitfield@example.com and +44 7700 900123. The task is to decide whether this
record is worth an investigation before anyone spends a day on it.
- Parse the number offline, first. No network, no cost, no trace.
input : +447700900123
E164 : +447700900123
INTERNATIONAL : +44 7700 900123
NATIONAL : 07700 900123
country_code : 44 national_number: 7700900123
is_possible : True
is_valid : False
number_type : 99 (PhoneNumberType.UNKNOWN; MOBILE would be 1)
carrier : ''
geocoder : ''
timezones : ('Etc/Unknown',)
region : None
- Read the disagreement. Possible but not valid, with no region, no carrier and
Etc/Unknownfor a timezone. Twelve digits in the right shape for+44, matching no block the numbering plan defines. That pattern is a reserved or unallocated range, not a typo. - Name the range. The national number is
7700900123, which sits inside07700 900000to07700 900999— Ofcom’s drama block, 1,000 numbers that Ofcom states “will not be allocated to communications providers in the foreseeable future”. The number is fictional by construction. - Do not take a tool’s word for the conclusion. Running a registration oracle against it
returns negatives, and those negatives are worthless: a rate limit, a privacy setting and an
unassigned number all print the same thing. The load-bearing evidence is the published range,
which anyone can check in a browser, not a
[-]in a terminal. - Switch to the address and ask the cheap question. Two DNS queries, invisible to everyone:
$ dig +noall +answer MX example.com
example.com. 126 IN MX 0 .
$ dig +short TXT example.com | grep spf
"v=spf1 -all"
The grep is not decoration. A bare TXT query on a live domain also returns whatever
domain-verification tokens are parked there that week, and those rotate, so filtering is what makes
the transcript something a reader can reproduce rather than something they have to trust. The MX
TTL will differ from the one above for the same reason.
- Read it. A single MX of
0 .is RFC 7505’s null MX — the domain publishes that it accepts no mail — andv=spf1 -allsays it sends none either. No mailbox on that domain has ever received anything, so no service can have confirmed an account through it. - Confirm with the deterministic probe, on every spelling. SHA-256 of the trimmed, lower-cased
address, against the avatar endpoint with
d=404:
d.whitfield@example.com fc9518a0411a19b071630d4b153e4774e0697053e4015e5eb9e1ab5c934021a3 HTTP 404
dwhitfield@example.com 6a2ae976758ac8100467b0bea363301fcb6fe756c8262b95336be1d03dcbd919 HTTP 404
dwhitfield+shop@example.com bbb5413ad03bc64773f79b7804cd4868f34e7fd7a5fa2e1808617ed45289369f HTTP 404
- Total cost. Four lookups, no packet sent to any mail server, nothing delivered to anybody, about ninety seconds. Both identifiers in the tip-off come from reserved or non-deliverable space, and they come from two different reserved spaces, which is the signature of a synthetic record rather than a transcription error.
- What the same pipeline looks like on a live record, for contrast:
+44 7911 123456parses as valid,PhoneNumberType.MOBILE, carrierJT, geocoderGuernsey, regionGG. That is the point at which the oracles are worth running — and the point at which you must notice that your+44number is not a GB number at all.
What you can assert: that the phone number lies inside a block Ofcom publishes as reserved for drama and unallocated, and that the email domain publishes a null MX and an SPF record refusing all sending. Both claims are independently checkable in under a minute by anyone reading the report, and together they say the record is synthetic. The names, dates and timestamps of the two DNS queries go in the notes, because DNS is mutable and a null MX today is not a null MX last year.
What would falsify it: Ofcom reallocating the drama range, which it says it will not do but which
is a policy rather than a law of nature; a null MX that was added after the record was created,
which would mean the address had worked earlier and is the single most likely way this conclusion
is wrong; or the tip-off being a mangled transcription of a real number one digit away from this
one. The weakest measurement is the Gravatar probe — a 404 speaks only for the exact strings
hashed, and d.whitfield and dwhitfield produce entirely unrelated digests, so three 404s cover
three spellings and not a mailbox. Quote it as “no avatar for these three spellings”, never as
“no account”. And none of this says anything at all about whether a person called Dana Whitfield
exists; it says the two identifiers attached to the name do not. Converting a name into a person is
people search, and converting a handle into one is
Usernames & Accounts.
Broader catalogues
More tools
Further tools for this area from the OSINT Newsletter Tools Library (Email Address OSINT, Phone Number OSINT), excluding those already listed above.
| Tool | What it does |
|---|---|
| Aeroleads | A prospecting and contact discovery platform that helps users find professional email addresses, phone numbers, company… |
| Analyst Research Tools | A browser-based investigative research platform pulling together a collection of free lookup tools to help you gather, organise… |
| BeenVerified | People-search and background research platform that aggregates publicly available records into a single interface. |
| Behind the Email | A browser-based OSINT tool that identifies the online footprint of an email address by checking where it has been used across… |
| LoLArchiver | A niche OSINT tool designed to archive and surface user-related data across gaming and online platforms like League of Legends… |
| OSINT Industries | An all-encompassing OSINT platform that gathers and correlates publicly available digital data such as emails, domains, phone… |
| Osintly | A unified OSINT platform that searches for information across pseudonyms, email addresses, phone numbers, IP addresses, domains… |
| Phone Validator | A phone-number validation & lookup service that can identify a number’s line type, carrier/service provider & approx. location… |
| Phunter | Allows you to find various information via a phone number. |
| Sherlock Eye | AI-powered OSINT assistant that automates investigations, links data, and surfaces insights fast. |
| SynapsInt | A web-based search platform that aggregates publicly available data about people, organisations, domains, IP addresses, email… |
| Telegram Phone Number Checker | Identify a Telegram user by their phone number. |
| Zehef | An email-focused OSINT tool that automates account discovery, breach checks, and paste site lookups |
| Zen | Finds email addresses of GitHub users. |
Sources
Both catalogues below are maintained by other people and are considerably larger than this page. Use them as the canonical index; this sheet is a working route through them.
- Bellingcat’s Online Investigation Toolkit — ~340 tools, each with its own review page covering cost, difficulty, requirements and limitations.
- OSINT Newsletter Tools Library — ~280 tools, organised by investigative goal.
Neither publishes a licence, so nothing here is copied from them: tool names, one-line descriptions, cost flags and links are catalogue facts, and the method and commentary are this site’s own. See credits.