Verify a Handle proof yourself
Check whether an external account publishes evidence of control for a handle. Once you have found the claim, you can check public evidence without a Handle account, session, token or verification service.
The pieces
A handle is a username on handle.pub. Its profile lists the linked identities its owner adds: accounts, domains, addresses or keys. Linking alone does not verify them.
A proof binds a handle to one identity. Its publication location is somewhere controlled by that identity: a Gist, a bio, a post, a file or DNS. Discovery finds the claim and its evidence; verification checks that evidence and who published it.
Ordinary v1 proofs use a hash of public inputs. Anyone can compute it. The hash is not a secret, and Handle does not cryptographically sign it.
Find the claim and check both directions
You must check both sides: the Handle profile must list the external identity, and that external account must publish the matching proof. Neither side alone establishes the relationship. A matching hash copied by another account does not count.
Open the public Handle profile, find its Identities card, and open the identity's details for its evidence and verification method. Alternatively, use GET /handles/{handle} through the API guide, with a token that has profile:read. Private profiles do not expose their linked identities.
Discovery depends on Handle. Checking the discovered public proof does not require Handle's verifier or an API token. Keep the handle, provider, external account and evidence location together; the proof line alone cannot tell you all of them.
Compute a GitHub proof
This illustrative example uses the Handle username openactor and GitHub login openactor. It does not describe a live verification. For a real check, first confirm that the Handle profile lists the GitHub account.
The canonical input is handle:<handle>:<type>:<account>. Lowercase the handle and GitHub login, omit any leading @, and use github as the type. Encode it as UTF-8 without a trailing newline. Compute SHA-256, take the first 16 bytes, and write 32 lowercase hexadecimal characters. On macOS or a system with shasum:
printf '%s' 'handle:openactor:github:openactor' | shasum -a 256 | cut -c1-32
The result is 80e4fd494daa04eb1b2bb211261cb02b. A GitHub proof needs a public Gist owned by openactor with a file named exactly _handle.txt, containing this full proof:
Verifying proof of control on Handle.
https://handle.pub/openactor
handle-proof=80e4fd494daa04eb1b2bb211261cb02b
The Gist description is not evidence. Check GitHub's reported owner, its public visibility, the exact filename, the profile URL and the full proof.
Read and check the evidence
After confirming the profile lists that GitHub account, replace YOUR_PUBLIC_GIST_ID with the ID of its public proof Gist and run this with Node.js 24. It reads the Gist without authentication. For another claim, replace the handle, account and Gist ID together, after checking the Handle side.
node --input-type=module <<'JS'
import { createHash } from 'node:crypto';
const handle = 'openactor';
const account = 'openactor';
const gistId = 'YOUR_PUBLIC_GIST_ID';
const input = 'handle:' + handle.toLowerCase()
+ ':github:' + account.toLowerCase();
const digest = createHash('sha256').update(input, 'utf8')
.digest('hex').slice(0, 32);
console.log('Expected: handle-proof=' + digest);
const response = await fetch('https://api.github.com/gists/' + gistId, {
headers: { Accept: 'application/vnd.github+json' },
redirect: 'error',
});
if (!response.ok) throw new Error('GitHub HTTP ' + response.status);
const gist = await response.json();
if (gist.public !== true
|| gist.owner?.login?.toLowerCase() !== account.toLowerCase()) {
throw new Error('Public Gist owner does not match the claimed account');
}
const file = gist.files?.['_handle.txt'];
if (!file || file.truncated || typeof file.content !== 'string') {
throw new Error('Need the complete _handle.txt file; inspect its raw_url');
}
const text = file.content.replace(/^\uFEFF/, '')
.replace(/\r\n?/g, '\n').trim();
const lines = text.split('\n').map(line => line.trim());
const proof = lines[4];
if (lines.length !== 5
|| lines[0] !== 'Verifying proof of control on Handle.'
|| lines[1] !== '' || lines[3] !== ''
|| lines[2].replace(/\/$/, '').toLowerCase()
!== 'https://handle.pub/' + handle.toLowerCase()
|| (proof !== 'handle-proof=' + digest
&& proof !== 'handle-proof=' + digest + ':v1')) {
throw new Error('Evidence does not match the full v1 proof');
}
console.log('Public GitHub evidence matches this Handle claim.');
JS
The first output is Expected: handle-proof=80e4fd494daa04eb1b2bb211261cb02b. Matching evidence ends with Public GitHub evidence matches this Handle claim.
A legacy random code is not the full v1 proof. The command rejects it with Evidence does not match the full v1 proof, even if a profile shows Verified. The expected full proof above and the checker are tested together using synthetic provider responses. They are not evidence of a live verification. Public evidence can change after a successful check.
This small example refuses truncated or missing file content. For those responses, inspect GitHub's raw_url, require HTTPS on gist.githubusercontent.com, and read the complete file before comparing it. A network error, rate limit or missing file is not a successful verification.
Other methods have different rules
No version suffix means v1; an explicit :v1 is also accepted. Reject malformed proofs and unsupported versions such as :v2; never silently interpret them as v1. This is an introduction, not a normative v1 specification or a complete set of conformance vectors.
- GitLab uses a public personal Snippet with a full proof file.
- X and Substack use public posts or Notes. Mastodon uses a post, bio or profile field. Bandcamp, Genius and Discogs use profile text. Text proofs need the matching proof line and provider-confirmed authorship.
- LinkedIn profiles can be linked but not verified yet. A linked LinkedIn profile always shows Unverified, and there is no proof to check.
- Domains use DNS TXT,
/.well-known/handle.txtor a meta tag. DNS holds the proof line; the file holds the full proof. Use an ASCII IDNA hostname with no trailing dot; each hostname is a separate identity. Mastodon accounts include both the username and ASCII server name. - SSH and OpenPGP keys sign a one-time challenge, rather than this hash. Their public proof records contain the message, signature and public key. Verify the exact signed bytes with
ssh-keygenorgpg, check the handle in the challenge, and compare the fingerprint through a channel you trust. Check current key expiry and revocation. The record'schecked_atis not a trusted timestamp. - Email verification has no independently checkable public proof. Handle privately checks receipt of a one-time link. It does not prove sending ability or control of the email domain.
What the result means
A successful two-way check is evidence of control at a point in time. It does not establish legal identity, endorsement, legal or permanent ownership, exclusive control, or continuous monitoring. Handle does not periodically recheck proofs: published evidence can be removed while its historical Verified status remains.
After a successful handle rename, identities stay linked, but every Handle-bound verification resets to Unverified, including SSH and OpenPGP. Publish proofs for the new handle and verify again. Email verification is not Handle-bound and is not reset.
You trust the provider to report authorship and account control honestly, including a Mastodon server's operator or DNS infrastructure. Provider outages and unauthenticated rate limits can prevent a check. Some providers depend on undocumented endpoints that can change. Account names can change hands. Record what you checked and when, and repeat the check when freshness matters.
For programmatic discovery, read the API guide.