The RealHandles protocol

An open format for proving online accounts belong to one person. Signed by their own key, hosted anywhere, verifiable without trusting us.

Want to read an identity from your own site rather than implement the format? See Build with RealHandles.

The signed manifest

One file, conventionally realhandles.json. The signed part is a compact JWS (Ed25519 / EdDSA) carrying the manifest; the readable mirror beside it is not signed.

{
  "$schema": "https://realhandles.com/schema/realhandles-v1.json",
  "jws": "eyJhbGciOiJFZERTQSIsInR5cCI6InJoLW1hbmlmZXN0Iiwia2lkIjoi..."
  "manifest": {
    "version": "1",
    "subject": {
      "username": "davidvkimball",
      "displayName": "David V. Kimball",
      "publicKey": { "kty": "OKP", "crv": "Ed25519", "x": "..." },
      "keyId": "base64url(SHA-256(publicKey))"
    },
    "accounts": [
      { "platform": "github", "handle": "davidvkimball",
        "profileUrl": "https://github.com/davidvkimball",
        "method": "oauth", "verifiedAt": "2026-07-13T...",
        "accountId": "1234567" }
    ],
    "anchor": { "url": "https://davidvkimball.com/.well-known/realhandles.json", "kind": "domain" },
    "issued": "2026-07-13T...",
    "statement": "These accounts belong to ..."
  },
  "publicKeyJwk": { "kty": "OKP", "crv": "Ed25519", "x": "..." },
  "keyId": "..."
}

accountId is optional and additive: the platform's own immutable id (an OAuth account id, a Bluesky DID, a nostr pubkey) where the verification had one to record. Absent means the method had no id, never that the account is suspect. Carry it through when you re-serialize a manifest, because it is what lets a verifier tell an owner who renamed from a handle that changed owner. Methods with no id (a bio token, a posted token, a mutual rel="me" page, a domain proof) stay bound to the handle and stay exposed to exactly that.

What method records, and what it does not

These are the values RealHandles writes when a first party confirmed the account. Anything else, self-attested included, is a signed claim and nothing more.

atproto The AT Protocol directory resolved the handle to the DID that proved it.

description-proof The account bio, About section or channel description carried our token.

domain-anchor The domain hosts the signed manifest itself, at /.well-known/realhandles.json.

domain-control A DNS record or a file under /.well-known on the domain carried our token.

lnurl-proof The Lightning Address resolved as LNURL-pay on the domain that hosts it.

nostr-signature The nostr key signed our challenge directly. No platform was asked.

oauth The platform itself confirmed the account, through its own sign-in.

post-proof The same, on a platform whose posts we read through its embed API.

rel-me A mutual rel="me" link: the owner listed the page and the page links back to this exact profile.

tweet-proof A post from the account carried our token, read back through the platform.

wallet-signature The wallet signed our challenge, so the key behind the address answered.

Do not decide "verified" by reading method

The list above says what each value MEANS. It does not make the value true. The signer wrote that string, so a valid signature over { "platform": "x", "handle": "jack", "method": "tweet-proof" } is a valid signature over a lie, and anyone who can claim a free handle can publish one.

A method check is a necessary condition and never a sufficient one. It may only ever subtract from a verdict something else already granted. Get that verdict from the accounts.verified and accounts.claimed split at GET /api/identity/<handle>, and fail closed to claimed when you cannot reach us or cannot tell.

The same holds for verifiedAt, which is the same field wearing a date. Full reasoning on Build with RealHandles.

Verifying it (independently)

1. Base64url-decode the jws payload to read the manifest.

2. Verify the JWS signature against manifest.subject.publicKey (EdDSA). Any off-the-shelf JOSE library does this.

3. Recompute keyId = base64url(SHA-256(canonical publicKey)) and confirm it matches.

4. Trust decision: pin that keyId the first time you see it (trust on first use). A later change without a signed rotation is suspect.

This holds even if RealHandles is offline. Try it on the independent verifier.

History and key rotation

Every signed manifest carries its position in an append-only chain, and a key change carries proof that the previous key authorized it.

seq is a 0-based counter, one per signature, and prev is base64url(SHA-256(previous manifest JWS)).

Rewriting any past entry breaks every hash link after it. A manifest without them is genesis, so older proofs stay valid.

A rotation entry is signed by the NEW key and adds rotation: { prevKeyId, prevKeySig }, where prevKeySig is the OLD key signing the exact string rh-rotate:v1:<newKeyId>:<seq>:<prev>. A verifier pinned to the old key accepts the new one only because the old key vouched for it.

The full history is public at /<handle>/realhandles-chain.json, oldest first. Pass the entries to verifyChain to check it all at once.

Each version is also timestamped with OpenTimestamps, which commits the hash of the signed file to Bitcoin. The signature proves who, the chain proves in what order, the timestamp proves by when.

Download that proof at /<handle>/realhandles.ots and check it with any OpenTimestamps tool.

ots verify realhandles.ots -f realhandles.json
import { verifyChain } from '@realhandles/verify';

const { versions } = await fetch('https://realhandles.com/david/realhandles-chain.json').then((r) => r.json());
const { valid, keyId } = await verifyChain(versions.map((v) => v.file), pinnedGenesisKeyId);
// keyId is the CURRENT key, proven from the one you originally pinned

Anyone can audit a whole chain, including ours. See reading the history.

Open source

The wire format is public with conformance vectors, and the reference verifier on npm is the same code RealHandles runs.

npm install @realhandles/verify

import { verifySignedManifest } from '@realhandles/verify';

const file = await fetch('https://realhandles.com/david/realhandles.json').then((r) => r.json());
const { valid, keyId, manifest } = await verifySignedManifest(file);

Discovery

Hosted: realhandles.com/<handle>/realhandles.json is always current.

Self-hosted: /.well-known/realhandles.json on your own domain, or a public Gist. Host the full manifest, or a signed pointer file naming your canonical URL.

Linked: <link rel="me" href="https://realhandles.com/<handle>"> from your homepage makes you discoverable and, because rel="me" is bidirectional, corroborated.

Importing profiles

RealHandles reads profiles from sources people already publish:

rel="me" links (the IndieWeb standard, also how Mastodon verifies).

schema.org sameAs in JSON-LD ({"@type":"Person","sameAs":[...]}).

A JSON list of links, e.g. a /api/social.json that returns [{ "name": "...", "url": "..." }].

Imported accounts start as Claimed and can be upgraded to Verified per platform.

For AI agents

Everything here is machine-readable, so an agent can resolve a person's identity reliably.

Structured read API: GET /api/identity/<handle> returns a machine-readable summary (keyId, did:key, trust score, accounts split verified vs claimed) plus the signed jws.

MCP server: add @realhandles/mcp to any MCP client (npx @realhandles/mcp) for verify_identity and check_link tools. Each proof is verified locally with @realhandles/verify.

To read someone's real accounts: fetch GET /api/identity/<handle> and read accounts.verified. Verify the jws it carries to confirm the person published it, then fail closed to accounts.claimed for anything else. Reading method out of manifest.accounts and trusting whatever is not self-attested is the trap above: the signer wrote that field.

To structure a messy web presence: parse the person's rel="me" links and sameAs data, or read their /api/social.json. Map each URL to a platform by its host.

For DID interop: the subject's public key is also a standard did:key, usable with the wider verifiable-credentials ecosystem.

The /social convention

In the spirit of /now and /uses pages: publish a /social page for people and a /api/social.json for machines, then get them verified and listed here.