What this covers
You have one username. You want every other place that username exists, and then you want to know which of those are the same human. The first part is automated and takes four minutes. The second part is not automated, takes the rest of the day, and is the only part that produces a finding.
Everything on this page serves the second part. The enumerators are a way of generating candidates cheaply; treating their output as a result is the single most common failure in this discipline.
Method
- Enumerate the exact handle across platforms with an automated checker. Fast, noisy, a starting point only.
- Generate variants before concluding anything. People append birth years, swap separators,
and drop vowels.
j.smith,jsmith90,j_smith,jaysmithare all the same person often enough to be worth checking. - Confirm each hit manually. Enumerators check whether a profile URL resolves. They do not check whether it is your target. Open it.
- Pivot on content, not the handle. Same profile photo, same bio phrasing, same follower overlap, same posting hours. A shared handle is weak evidence; a shared photo is strong.
- Reach for a stable identifier. Handles change; the numeric ID behind them usually does not.
Once you have a GAIA ID, a VK id or a GitHub
id, you have something that survives a rename and that you can search for independently. - Work the timeline. Account creation dates that cluster suggest one person registering everywhere at once. A ten-year-old account and a two-week-old one with the same name are probably not related.
- Write down the odds. Say how strong each piece of evidence is before you add it up, so that somebody can argue with a number rather than with your conclusion.
The judgement calls: decide early whether you are chasing one person or one handle, because a handle that turns out to be held by nine strangers needs a different write-up. Decide whether the handle came from the target or from somebody describing the target, since a transcription error upstream wastes the whole day. And decide what you will accept as confirmation before you start looking, because the evidence always looks more convincing once you have spent six hours on it.
The match arithmetic
Two separate calculations sit underneath this work, and conflating them is how people end up certain about the wrong person. The first is about the tool: how often does an enumerator claim a profile that is not there. The second is about the person: given that a handle matches, how likely is it the same human.
What an enumerator actually tests
A checker does not know what a profile looks like. It has four numbers and strings per site, and
it compares a response against them. In the WhatsMyName schema those fields are e_code and
e_string for an account that exists, m_code and m_string for one that does not. Sherlock’s
own database expresses the same idea as a single errorType of status_code, message or
response_url, with an errorMsg or errorCode beside it.
The failure mode falls straight out of the data. Counted from wmn-data.json on 4 October 2026:
WhatsMyName dataset
sites 717 across 20 categories
e_code == m_code (status cannot discriminate) 175 24.4 per cent
m_code == 200 (soft 404 for a missing user) 178
entries carrying a `protection` flag 62 34 of them Cloudflare
POST-based entries 23
Sherlock bundled data.json
sites 481
errorType = status_code 327 68.0 per cent
errorType = message 127
errorType = response_url 27
entries with a regexCheck (handle-shape filter) 95
entries with a separate urlProbe 52
Recompute either of those yourself rather than trusting the numbers above; they move every week:
curl -sL -o wmn-data.json https://raw.githubusercontent.com/WebBreacher/WhatsMyName/main/wmn-data.json
python3 - <<'PY'
import json, collections
d = json.load(open("wmn-data.json"))
s = d["sites"]
same = [x for x in s if x.get("e_code") == x.get("m_code")]
print("sites ", len(s))
print("code-ambiguous ", len(same), f"{100*len(same)/len(s):.1f}%")
print("soft 404 (m=200)", sum(1 for x in s if x.get("m_code") == 200))
print("by category ", collections.Counter(x["cat"] for x in s).most_common(5))
PY
So on roughly a quarter of the dataset the HTTP status is worthless and the body string is the only discriminator. Steam and Keybase are ordinary examples: both return 200 whether or not the account exists. This is why a checker’s detection mode is not a cosmetic setting. Naminter names the two rules explicitly:
--mode all EXISTS only when the status AND the body string both match the "exists" pair.
One of the two matching gives PARTIAL_EXISTS, not EXISTS. (default)
--mode any EXISTS when the status OR the body string matches.
Under any, a status-only rule fires on all 178 soft-404 sites for a handle that exists nowhere
on earth. That is your false-positive budget in one number: switching mode can move the hit count
by up to a quarter of the list without a single new account being involved. Run strict, read the
partials as a separate pile, and never report a count taken from a loose run.
What a handle match is worth
Now the second calculation. Let k be the number of distinct humans who hold the handle across
the platforms you checked. Before any content evidence at all, a candidate account is your target
with probability 1/k:
k = 1 p = 1.00 the handle is yours alone; rare, and worth confirming rather than assuming
k = 2 p = 0.50
k = 3 p = 0.33 odds 1:2 against
k = 5 p = 0.20
k = 40 p = 0.025 a dictionary word or firstname+year; the handle is near-worthless alone
k is not a guess. You estimate it by opening the hits and counting the ones that are plainly
different people — a 2011 account posting in another language, a brand, a bot. Each one you can
positively exclude raises k and lowers what the handle is worth.
Evidence then multiplies the odds:
posterior odds = prior odds x LR1 x LR2 x ...
LR = P(evidence | same person) / P(evidence | different people)
The likelihood ratios you assign are judgements, and the discipline is to write them down:
identical avatar bitmap, image found nowhere else LR ~ 50-100 strong
identical avatar, image is a stock photo or meme LR ~ 1 worthless
registration within the same hour on two platforms LR ~ 10-30 moderate
same rare bio typo or identical punctuation habit LR ~ 10-50 moderate to strong
same stable platform ID reached from both accounts not an LR it is the same account
The trap is independence. Odds only multiply when the pieces of evidence are conditionally independent of each other. A matching handle and a matching display name are not independent: both are derived from the same real name, so counting them twice inflates your confidence by a factor you never earned. Neither are two accounts on platforms that offer “sign in with Google” and copy the avatar across on your behalf. Before you multiply, ask what common cause could have produced both observations, and if there is one, use the stronger of the two and discard the other.
The last row of that table is the point of the whole page. An inference from an avatar is an argument. A stable numeric identifier reached from both ends is not an inference at all, and it is what you should be trying to get to.
Key tools
Sherlock
The usual first pass. It constructs the expected profile URL for each site in its bundled
data.json and reads the response, so it touches only public pages and needs no API keys. 481
sites in the current list, which is a fraction of what Maigret and the WhatsMyName dataset carry —
Sherlock’s value is that it is fast, dependency-light and present in most distro repositories.
pipx install sherlock-project
# or: dnf install sherlock-project
# or: docker run -it --rm sherlock/sherlock user123
# single username; results also land in a text file named for the handle
sherlock user123
# several at once
sherlock alice bob charlie
# the separator sweep: {?} expands to '_', '-' and '.' in turn,
# so this one invocation covers john_doe, john-doe and john.doe
sherlock 'john{?}doe'
# narrow to named sites -- the names are the keys in data.json, case-sensitive
sherlock user123 --site GitHub --site Instagram
# machine-readable output for the triage spreadsheet
sherlock user123 --csv
sherlock user123 --xlsx
sherlock user123 --txt --folderoutput ./runs/kestrel
# show the misses too, which is how you tell "not found" from "never checked"
sherlock user123 --print-all
# route through a proxy, since 481 requests from one address in a few seconds is a pattern
sherlock user123 --proxy socks5://127.0.0.1:1080 --timeout 20
# when a single site's verdict looks wrong, read the response it actually got
sherlock user123 --site Steam --dump-response
# run against an unmerged upstream site list, or a local one you have edited
sherlock user123 --json https://example.org/data.json
sherlock user123 --local
Interpreting the output: a green [+] is a URL that matched the site’s detection rule, nothing
more. Sites excluded upstream for being unreliable are skipped silently unless you pass
--ignore-exclusions, which, as its own help text says, brings back false positives along with
the coverage.
How it misleads you: 327 of the 481 entries decide on the HTTP status code alone, so every
soft-404 site in the list is a coin toss. Ninety-five entries carry a regexCheck that rejects
handles which cannot exist on that platform — useful, but it means a handle containing a dot or a
dash will be reported as absent from sites that simply do not permit the character, which is a
different statement from “nobody registered it”. And the --tor flag that circulates in older
tutorials no longer exists; current releases have --proxy only, and passing --tor will stop
the run with an argparse error rather than quietly running unproxied.
Maigret
Broader than Sherlock and considerably more useful after the first pass, because it extracts profile fields rather than only reporting existence. It also searches by identifiers other than usernames, which is how you re-enter the search from a numeric ID.
pipx install maigret # or: pip install maigret, sudo snap install maigret
docker pull soxoj/maigret
# default run: the 500 highest-traffic sites
maigret user123
# everything in the database, which is where the long-tail regional sites live
maigret user123 -a
# trade coverage for speed on a first sweep
maigret user123 --top-sites 100
# scope by site tag; run --stats first to see which tags the database actually uses
maigret user123 --stats
maigret user123 --tags photo,dating
maigret user123 --exclude-tags "xx NSFW xx"
# reports: -H html, -P pdf, -C csv, -T txt, -M markdown, -G graph, -X xmind
maigret user123 -H -C --folderoutput ./runs/kestrel
# JSON for a pipeline; TYPE is 'simple' or 'ndjson'
maigret user123 -J ndjson
# pivot from a profile page: parse it, extract IDs and usernames, search on those
maigret --parse https://www.deviantart.com/muse1908
# search by a stable identifier instead of a handle
maigret 112233445566778899001 --id-type gaia_id
maigret 12345678 --id-type vk_id
# combine two name fragments into candidate handles
maigret john smith --permute
# highlight sites whose page body also mentions a term you care about
maigret user123 --keywords photography lisbon
# throttle and proxy: 6,000-odd database entries at default concurrency is a very loud burst
maigret user123 -n 20 --timeout 30 --proxy socks5://127.0.0.1:1080
maigret user123 --tor-proxy socks5://127.0.0.1:9050
The valid --id-type values are fixed by the source and worth knowing, because they are the exit
doors from handle-based reasoning: username, yandex_public_id, gaia_id, vk_id, ok_id,
wikimapia_uid, steam_id, uidme_uguid, yelp_userid, orcid, qq_id, bilibili_id.
Before you trust a long run, measure the list:
# tests each site against the known-claimed and known-unclaimed handles in the database
maigret --self-check --diagnose
# and disable the ones that fail, so the next run is quieter
maigret --self-check --auto-disable
How it misleads you: recursive search is on by default, so Maigret will take a username it found
on one profile page and start searching for that, which is excellent until the profile belongs
to someone else and the report quietly becomes a report about two people. Pass --no-recursion
when you want a clean single-handle run, and read the report’s own account-tree rather than the
flat list. The --ai summary is generated text about your results, not a finding, and should
never appear in a write-up. Note also that --print-not-found is off by default, so a short
report can mean a short list of sites rather than a short list of accounts.
WhatsMyName and Naminter
WhatsMyName is the community-maintained detection dataset — a single
wmn-data.json of 717 site entries under CC BY-SA 4.0 — rather than a checker. The project
removed its bundled scripts in 2023 and now maintains only the data, which several other tools
consume. Its detection strings tend to be the most current of any list, because fixing one is a
one-line pull request.
Three inclusion rules shape what it can ever find, and they are the reason an absence here means very little: a site must be publicly accessible without a login, the username must appear in the profile URL, and the site must not swap the username for a numeric ID. Every platform that identifies people by number is therefore structurally invisible to this entire class of tool.
Naminter is the checker built specifically for that dataset, and it is the one to use when you care about the difference between a hit and a maybe.
pip install naminter # or: uv tool install naminter
docker run --rm -it ghcr.io/3xp0rt/naminter --username john_doe
naminter --version
# strict detection is the default: status AND string must both match
naminter -u user123
# show every status, not just the hits -- this is the run you actually read
naminter -u user123 --filter-all
# the partials are the pile that needs eyes; the conflicts are a broken site entry
naminter -u user123 --filter-partial --filter-conflicting
# scope by category; the dataset's 20 values include social, coding, gaming, finance
naminter -u user123 --include-categories social --include-categories coding
naminter -u user123 --exclude-categories "xx NSFW xx"
# keep the evidence: raw response bodies plus a report, before anything is deleted
naminter -u user123 --filter-all --save-response --response-dir ./evidence \
--csv --csv-path hits.csv --html --html-path report.html
# slow it down and proxy it
naminter -u user123 --proxy socks5://127.0.0.1:9050 --timeout 60 --max-tasks 20
# pin the dataset to a local copy so a run is reproducible months later
naminter -u user123 --local-data ./wmn-data.json --local-schema ./wmn-data-schema.json
# measure the list before you trust it: checks every site against its known accounts
naminter --test
naminter --test -s GitHub -s Reddit -vvv
Read the status symbols rather than counting lines. + exists, - missing, ~+ and ~- are
partial matches where only one of the two criteria fired, * is conflicting — the exists and
missing indicators both matched, which means the site entry is stale — ? unknown, X a site
marked invalid in the data, and ! a request error. A partial is not a weak hit; it is a site
where the tool cannot tell, and the fix is to open the URL.
How it misleads you: --impersonate defaults to chrome, so Naminter is deliberately not
truthful about what it is. That is usually what you want, but it means a site serving a different
page to automation may give a verdict you cannot reproduce with curl, and --impersonate none
will sometimes flip a result. --max-tasks defaults to 50, which against 717 sites is a visible
burst from one address. And --filter-exists is the default, so a quiet run is not a negative
result until you have re-run it with --filter-all.
Blackbird
Searches by username and by email from the same binary, which is its reason to exist: a single pass over both identifier types, with permutation built in. Useful as a cross-check when Sherlock and Naminter disagree, because it is a third implementation reading a different list.
git clone https://github.com/p1ngul1n0/blackbird
cd blackbird
pip install -r requirements.txt
python blackbird.py --username user123
python blackbird.py --email target@example.com
# several at once, or from a file
python blackbird.py -u alice bob charlie
python blackbird.py -uf handles.txt
python blackbird.py -ef addresses.txt
# permutation: --permute ignores single elements, --permuteall combines everything
python blackbird.py -u john smith --permute
python blackbird.py -u john michael smith --permuteall
# scope by a list property
python blackbird.py -u user123 --filter "cat=social"
python blackbird.py -u user123 --no-nsfw
# keep the pages, not just the verdicts
python blackbird.py -u user123 --dump --csv --json
# throttle, proxy, and freeze the site list for a reproducible run
python blackbird.py -u user123 --proxy socks5://127.0.0.1:9050 \
--timeout 60 --max-concurrent-requests 10 --no-update
How it misleads you: it updates its site lists on every run unless you pass --no-update, so two
runs a week apart are not comparable and neither is reproducible — pin it with --no-update the
moment a run matters. The --ai profiling requires --setup-ai and an API key, and it produces
characterisation of a person from thin evidence, which is exactly the thing an investigation
should not be doing with a language model. Treat the account list as the output and ignore the
rest.
socid_extractor
This is the step that turns a list of URLs into evidence. It parses a profile page or API response
and returns a flat dictionary of fields, including the stable internal identifiers a platform uses
behind the handle — GAIA ID for Google, Facebook UID, Yandex public ID, Instagram pk. Those
survive renames, which handles do not. It is the extraction engine Maigret uses, and it is worth
running directly on each confirmed hit.
pipx install socid-extractor # or: pip install socid-extractor
# one profile
socid_extractor --url https://www.deviantart.com/muse1908
# structured, for the case file
socid_extractor --url https://github.com/torvalds --json
# a page you already saved, which is the right way to work from archived evidence
socid_extractor --file ./evidence/profile.html --json
# sites that need a session
socid_extractor --url https://example.com/profile --cookies 'sessionid=...; csrftoken=...'
socid_extractor --cookie-jar ./cookies.txt --url https://example.com/profile
# batch runs: skip the HTTP request when the URL matches no known scheme
socid_extractor --url https://example.com/foo --skip-fetch-if-no-url-hint
Output is key: value lines, or JSON with --json. The fields worth extracting first are any
*_id, created_at and the avatar URL; the rest is context.
How it misleads you: it reads what the page serves, so a page behind Cloudflare, a login wall or
an A/B test returns nothing and that is not the same as the account not existing. The field
ontology is normalised across platforms, which means created_at from two sites may have been
derived two different ways — one from an API field, one from a rendered string — and comparing
them to the minute is over-reading. The --ai-fallback extractor guesses at pages with no
matching scheme; anything it produces is unverified and should be marked as such.
GHunt
The Google-specific case, included here because a GAIA ID is the most portable stable identifier
you are likely to recover, and because maigret --id-type gaia_id takes one directly.
pipx install ghunt
ghunt login # authenticates GHunt to Google; required before the modules work
ghunt email target@gmail.com --json user_data.json
ghunt gaia 112233445566778899001 --json gaia.json
The modules are login, email, gaia, drive, geolocate and spiderdal, and --json works
with all but login. Email-side work — validation, breach and registration checks, recovery-hint
pivots — belongs on Email & Phone Number OSINT rather than here.
How it misleads you: it runs as an authenticated Google session, so the account you log in with is visible to Google and the results depend on what that account is permitted to see. Use a research account, never a personal one.
Name and handle variant generation
Enumeration tests the strings you give it, so the strings are the whole game. Two web tools generate them, and both are worth a pass before you conclude a handle is absent.
Bellingcat’s Name Variant Search generates plausible alternative forms of a person’s name — the problem it solves is transliteration and spelling across alphabets, where a registry search fails not because the person is absent but because the name was romanised differently. NAMINT works the other way round, taking first, middle and last names and producing username and email combinations.
Then there are the mechanical variants, which you can generate without a tool:
Given "John Smith", born 1990, known handle jsmith:
separators jsmith j_smith j-smith j.smith
order smithj smith_j johns sjohn
year suffixes jsmith90 jsmith1990 jsmith09
vowel drops jsmth jnsmth
leet j5mith jsm1th
platform caps usernames over the length limit get truncated differently per site
Sherlock’s {?} covers only the three separators. Maigret’s --permute and Blackbird’s
--permute / --permuteall combine name fragments you supply. Nothing generates the year
suffixes or the vowel drops for you, so build that list by hand and feed it as multiple
positional usernames.
How this misleads you: variant generation multiplies your request volume by the number of variants, and it multiplies your false-positive count by the same factor. Ten variants against 717 sites is 7,170 requests and roughly ten times the soft-404 noise. Generate variants, but run them scoped to a category or a site list rather than across everything.
Enumerators at a glance
| Tool | Site list | Detection rule | Extracts profile data | Reports |
|---|---|---|---|---|
| Sherlock | Own data.json, 481 sites | Status code on 327 of 481; message or response URL on the rest | No | csv, xlsx, txt |
| Maigret | Own database, top 500 by default, -a for all | Per-site, plus recursive search on extracted usernames | Yes, via socid_extractor | html, pdf, csv, txt, md, graph, xmind, json, neo4j |
| Naminter | WhatsMyName, 717 sites | Strict all by default; exposes partial and conflicting states | No | csv, json, html, pdf, raw responses |
| Blackbird | Own list, auto-updating | Per-site | Dumps HTML with --dump | csv, json, pdf |
| WhatsMyName web | WhatsMyName, 717 sites | As the dataset defines | No | csv |
| socid_extractor | Not an enumerator; 250+ parse schemes | n/a | Yes, including stable IDs | json |
Pick on the second and third columns, not the first. Sherlock for a fast look, Naminter when the count has to be defensible, Maigret when you want the profile fields in the same pass, Blackbird as the third opinion, socid_extractor on every hit that survives.
Tool reference
| Tool | What it does | Cost |
|---|---|---|
| 192 | Searching for someone’s address in the UK, phone number and who they live with according to electoral rolls. | free |
| Bellingcat Name Variant Search | Quickly search many variants of a person’s name on Google | free |
| Blackbird | Check usernames and email addresses on websites and social networks | free |
| Epieos | Checks where an email has been used. Based on Holehe. | paid |
| Ghunt | A command line tool for obtaining information about Google accounts. | free |
| Maigret | Maigret is a Python script that retrieves user information by searching for usernames across various websites and social media platforms. | free |
| NeutrOSINT | A tool for investigating Proton Mail addresses. | free |
| Sherlock | Allows a user to search for the presence of specific usernames across more than 400 websites and social networks. | free |
| WhatsMyName | Search for usernames on several hundred platforms | free |
Pitfalls
- A 200 is not a profile. 178 of WhatsMyName’s 717 entries record HTTP 200 for an account that does not exist, and 327 of Sherlock’s 481 entries decide on the status code alone. On those sites a hit is a coin toss until you open the URL.
--mode anyis not “more thorough”. A loose detection rule can promote up to a quarter of the list to EXISTS without a single real account being involved. The extra hits are not weak evidence; they are not evidence.- Partials are the interesting pile, not the discard pile. A PARTIAL_EXISTS means the status matched and the string did not, or the reverse, which usually means the site redesigned. Those are the sites where a real account is most likely to be hiding from the tool.
- A CONFLICTING result is a bug report, not a finding. Both the exists and missing indicators matched, so the site entry is stale. Fix it upstream or ignore the site; do not average the two.
- The dataset cannot see numeric-ID platforms at all. WhatsMyName only includes sites where the username appears in the URL and is not transformed into an internal ID. Absence from every enumerator you own is therefore consistent with a large presence on platforms that identify people by number.
- Handles are recycled. A handle released by one account and claimed by another means the 2019 archive and the live profile are different people wearing the same name. Check creation dates before you treat an archived page and a current one as the same account.
- Display name and handle are not independent evidence. Both derive from the real name. Multiplying them together is how a 1:2 prior becomes a confident, wrong answer.
- A matching avatar is only as good as the image’s rarity. An identical bitmap that also appears on 4,000 other profiles is worth nothing. Reverse-search the avatar before you score it — see Reverse Image Search.
- Self-check the list before you trust a long run.
maigret --self-check --diagnoseandnaminter --testboth measure the detection rules against known accounts. A list you have not measured gives you a hit count with no error bar. - Browser impersonation changes the answer. Naminter impersonates Chrome by default and Blackbird can be made to; a verdict you cannot reproduce with a plain client is a verdict that depends on a header. Record which mode produced it.
- Enumerators are loud. 717 sites at Naminter’s default 50 concurrent requests is a burst from
one address in a few seconds. Proxy it, lower
--max-tasksor-n, and assume the target can see the pattern if they operate any of the sites. - Variant sweeps multiply the noise, not just the coverage. Ten variants is ten times the requests and ten times the soft-404 hits. Scope them by category.
- Coverage is skewed. These lists are heavy on English-language and global platforms and thin on regional ones. Absence from a tool’s results is not absence from the internet.
- Paid aggregators recycle stale data. A “verified” result from a people-search service is often a years-old scrape. Check when the underlying data was collected — see People Search & Public Records.
- The AI summary is not a finding. Maigret’s
--aiand Blackbird’s--aiproduce generated prose about your results. It cites nothing, and it will characterise a person from three data points. Keep it out of the write-up. - Archive before you cite. Profiles are deleted while you are looking at them. Capture with
--save-responseor--dumpand preserve properly — see Archiving & Evidence Preservation.
Worked example
One datum: the handle kestrel_ait, seen once in a forum signature. No name, no email, no claim
about who it belongs to. The question asked is narrow — does the person behind the GitHub account
with that handle also hold the Chess.com account with that handle.
- Sweep the separators. Sherlock’s
{?}expands to_,-and., which covers the three mechanical variants in one invocation:
sherlock 'kestrel{?}ait' --csv --folderoutput ./runs/kestrel
kestrel-ait and kestrel.ait return two hits each, both on soft-404 sites, both empty pages
when opened. The underscore form is the live one and the rest of the work uses it.
- Run the strict pass and read every status. Naminter against the full WhatsMyName dataset, with responses preserved:
naminter -u kestrel_ait --filter-all --mode all \
--save-response --response-dir ./evidence \
--csv --csv-path kestrel.csv --max-tasks 20
[+] GitHub https://github.com/kestrel_ait
[+] Chess.com https://api.chess.com/pub/player/kestrel_ait
[+] Last.fm https://www.last.fm/user/kestrel_ait
[+] Mastodon (infosec.exchange)
[+] SoundCloud https://soundcloud.com/kestrel_ait
[+] Patreon https://www.patreon.com/kestrel_ait
[+] Gravatar https://en.gravatar.com/kestrel_ait.json
[+] Steam https://steamcommunity.com/id/kestrel_ait
[+] Keybase https://keybase.io/_/api/1.0/user/lookup.json?usernames=kestrel_ait
exists 9 partial 23 conflicting 4 error 6 missing 675 total 717
- Quantify the ambiguity rather than guessing at it. The same run with the loose rule:
naminter -u kestrel_ait --filter-exists --mode any --max-tasks 20
returns 36 hits. The 27 extra are exactly the 23 partials plus the 4 conflicts, promoted by
a rule that accepts a status match on its own. Nothing was discovered between the two runs. If
the report had been written from the any run it would have claimed 36 accounts, of which at
most 9 were ever candidates.
-
Open all nine. Steam and Keybase are the soft-404 sites from the arithmetic above: both return 200 and both pages are empty. Gravatar resolves to a default identicon with no profile. Patreon is a reserved-but-unused page. That leaves five: GitHub, Chess.com, Last.fm, SoundCloud and the Mastodon account.
-
Estimate
k. Last.fm has scrobbles since 2014, all of one genre, with a listed location in another country. SoundCloud is a label account with a logo avatar and a contact address on a company domain. Both are positively excluded. So at least three distinct humans or entities hold this handle: the GitHub owner, the Last.fm owner, the SoundCloud owner.
k = 3 -> prior that Chess.com is the GitHub person = 1/3 = 0.33 (odds 1:2 against)
- Pull the stable identifiers. Both remaining platforms publish an API:
socid_extractor --url https://github.com/kestrel_ait --json
curl -s https://api.chess.com/pub/player/kestrel_ait
{
"player_id": 196341287,
"@id": "https://api.chess.com/pub/player/kestrel_ait",
"username": "kestrel_ait",
"status": "basic",
"avatar": "https://images.chesscomfiles.com/uploads/v1/user/196341287.9f1c2a7e.200x200o.png",
"joined": 1696153260,
"last_online": 1759512000,
"followers": 4,
"country": "https://api.chess.com/pub/country/GB"
}
GitHub returns "id": 146902331 and "created_at": "2023-10-01T10:12:04Z". Neither ID is the
other, and no identifier is shared, so there is no account-level link. This stays an inference.
- Do the timestamp arithmetic properly.
joinedis a Unix epoch and has to be converted before it means anything:
python3 -c "import datetime as dt; print(dt.datetime.fromtimestamp(1696153260, dt.timezone.utc))"
# 2023-10-01 09:41:00+00:00
Chess.com joined 2023-10-01 09:41:00 UTC
GitHub created_at 2023-10-01 10:12:04 UTC
gap 31 minutes
- Score the avatar, and check its rarity first. Both avatars are a cropped photograph of a bird of prey. Reverse-image searching it returns no other instance, so it is not a stock image:
from PIL import Image
import imagehash
gh = imagehash.phash(Image.open("evidence/github_avatar.png"))
cc = imagehash.phash(Image.open("evidence/chesscom_avatar.png"))
print(gh, cc, gh - cc) # c3a1e4b2d8f09156 c3a1e4b2d8f09156 0
A 64-bit pHash distance of 0 on a non-stock image is the strongest single piece of evidence in the file.
- Multiply, with the numbers written down.
prior odds 1 : 2
avatar, distance 0, image found nowhere else x 60
registration 31 minutes apart x 20
---------
posterior odds 1200 : 2 = 600 : 1 p = 0.998
Those two likelihood ratios are judgements, not measurements, and they are stated so that somebody can halve them. Even halved twice the answer does not change, which is the useful property of writing them down. The display name was deliberately not counted: both accounts read “K. Aitken”, and that is the same evidence as the handle, not a second piece.
What you can assert: two accounts, GitHub id 146902331 and Chess.com player_id 196341287,
registered 31 minutes apart on 1 October 2023 and carrying a byte-identical avatar that appears
nowhere else, are the same person with posterior odds of roughly 600:1 given a prior of 1:2 from
three observed holders of the handle. No shared platform identifier was recovered, so this is an
inference from independent evidence and not an account-level link, and the write-up should say so
in those words.
What would falsify it: a fourth and fifth holder of the handle, which would lower the prior; any
instance of that avatar image elsewhere on the internet, which would collapse its likelihood ratio
from 60 to about 1 and take the conclusion with it; or a Chess.com joined timestamp read in the
wrong zone — the epoch is UTC, and treating it as local time would move the gap from 31 minutes to
an hour or more and remove the clustering argument entirely. The measurement that carries the most
risk is the avatar’s rarity, not its hash distance. The distance is exact and tolerates nothing
above about 2 before it stops meaning “same file”; the rarity is a search result that can be wrong
in one direction only, and it is the one claim here that must be re-checked before publication.
Broader catalogues
More tools
Further tools for this area from the OSINT Newsletter Tools Library (Username OSINT), excluding those already listed above.
| Tool | What it does |
|---|---|
| Analyst Research Tools | A browser-based investigative research platform pulling together a collection of free lookup tools to help you gather, organise… |
| DeepFind.Me Username Search | Username search tool that scans multiple online platforms to identify where a specific username appears across social media… |
| Hippie OSINT Toolkit | An OSINT web toolkit that allows you to reverse search a domain, a TikTok post, an image, or username (and more). |
| LoLArchiver | A niche OSINT tool designed to archive and surface user-related data across gaming and online platforms like League of Legends… |
| NAMINT | A web app that generates multiple username, email, and naming combinations from a person’s first, middle, and last name. |
| 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… |
| PSNProfiles | A PlayStation trophy tracking and player statistics platform that allows users to view public PlayStation Network profiles… |
| Revealer.US username lookup | An exposure-intelligence and OSINT tool that lets you search for information connected to an email address, username, or phone… |
| Sherlock Eye | AI-powered OSINT assistant that automates investigations, links data, and surfaces insights fast. |
| TheBigBrother | All-in-one OSINT toolkit that helps track usernames across the web, investigate emails and domains, extract metadata, and follow… |
| Threat Actor Username Search | A simple OSINT tool that checks whether a username has been observed on known cybercriminal forums and underground platforms. |
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.