Beta
Claim Handle
/docs

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.

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.