Attestation
Proof that a Snug identity controls an account somewhere else. The service issues a nonce-bearing challenge text, the user posts it publicly - a tweet, a gist, a DNS TXT record - and the server fetches that URL back and records a ledger entry binding the handle to the Snug user.
When to reach for it: verified-developer badges from a public gist, creator verification from a social post, domain-ownership proof during org onboarding, impersonation-resistant marketplace listings, a provable cross-platform handle for an AI agent.
When not to: if you control the login flow and the user can simply sign in with the platform, OAuth account linking in Auth is stronger and needs no public post. Contested facts across several parties belong to Consensus; content sealed for conditional later release belongs to Vault.
Concepts
- The challenge is a seed, not a record.
generate-textmints a nonce and stashes a short-lived seed keyed by (Snug user, platform, identity); nothing reaches the ledger until verification succeeds. - Verification is a server-side fetch. You hand over a public URL; the server retrieves it and checks the body carries both the nonce and your Snug user id. No platform API keys, no OAuth - an unreadable page cannot be verified.
platformpicks the retrieval strategy.dnsanddomainresolve TXT records over DNS-over-HTTPS;githubandgistnormalize the URL to its raw form; anything else is fetched as a plain HTTP body.- One verified claim per
platform:identity, globally - not per user. A second user proving the same handle gets409 identity_claimeduntil the first claim is revoked or expires. - Attestations are revocable and optionally time-boxed.
expires_in_secondssets a lifetime; a background sweep flips lapsed proofs toexpiredand releases the handle for re-claim. resolveis the public side; everything else is private. Any signed-in caller can resolve any handle, but reading, listing, auditing, and revoking a proof are owner-or-admin only.
Prove an identity
snug attestation generate-text --platform github --identity docs4-alice
# Proof Text: I am proving my identity on github. I am the user 'docs-wave' on
# Snug, and I am claiming the handle 'docs4-alice'. This is proof ID: c5de7-a12e-8324f.
snug attestation verify --platform github --identity docs4-alice \
--proof-url https://gist.github.com/alice/abc123The same over HTTP, with the envelope:
curl -X POST -H "Authorization: Bearer $SNUG_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{"platform":"twitter","identity":"docs4-bob","proof_url":"https://example.com/proof.txt"}' \
http://localhost:4000/api/v1/proofs/verify{
"status": 200,
"msg": "OK",
"data": {
"proof_id": "proof_c22fff562c434a3f9b531146a24f24b4",
"verified": true,
"platform": "twitter",
"identity": "docs4-bob",
"signature_verified": false,
"attestation_url": "/api/v1/proofs/proof_c22fff562c434a3f9b531146a24f24b4",
"verified_at": "2026-08-28T22:54:39.221066Z",
"expires_at": null
}
}signature_verified: false means this is a possession proof: whoever could
post to that URL controls the account. To upgrade it to a cryptographic proof,
see Signed proofs. The fetched body only has to contain
the nonce and the Snug user id, so a post that wraps the proof text in
surrounding prose still verifies. Content missing the nonce is
400 invalid_proof_text; a URL the server cannot read is 404 proof_not_found.
Read attestations back
snug attestation get --proof-id proof_663ab96eeaa742bdacac8e216ec3a8d6
snug attestation list # the caller's own proofs
snug attestation list --platform github --page-size 2
snug attestation audit --proof-id proof_663ab96eeaa742bdacac8e216ec3a8d6 --limit 10list returns a proofs array plus the standard pagination object (25 per
page by default) and takes only platform, page, and page_size - not the
shared search grammar. audit returns the
per-proof event stream newest-first (verified, revoked, expired),
defaulting to 50 events and capped by the stream's 500-event limit. Listing
another user's proofs, or reading one you do not own, is 403. A platform
admin token bypasses that, and by passing user_id in the body of
generate-text and verify it can also complete a proof on another identity's
behalf. That same user_id from a non-admin token is 403 user_mismatch.
Resolve a handle
The lookup a badge renders from - any authenticated caller may ask, and the
answer names the owning Snug user. proof is trimmed below; it carries the
same fields as get:
snug --output json attestation resolve --platform github --identity docs4-alice{
"platform": "github",
"identity": "docs4-alice",
"verified": true,
"proof": {
"proof_id": "proof_663ab96eeaa742bdacac8e216ec3a8d6",
"user_id": "docs-wave",
"signature_verified": false,
"verified_at": "2026-08-28T22:54:39.180770Z"
}
}An unclaimed handle is still 200, with verified: false and a null proof.
Unauthenticated callers get 401: "public" here means visible to any caller,
not anonymous.
Revoke and expire
snug attestation revoke --proof-id proof_c22fff562c434a3f9b531146a24f24b4Revoking flips the status to revoked, drops the handle out of resolve, and
frees it - verified live, a different user claimed the same twitter:docs4-bob
handle immediately afterwards. Revoking twice is 409 already_revoked.
Time-boxing is the same release on a timer: pass expires_in_seconds (1 second
to 1 year) at verify time and a background sweep transitions the proof to
expired. The sweep is on by default. Its lag depends on where expires_at
falls in the tick - two 1-second-lifetime runs landed the expired audit event
7 and 53 seconds after the deadline - so budget up to one full sweep interval,
60 seconds by default.
Behaviors worth knowing
- Regenerating invalidates the previous text. Each
generate-textfor the same (user, platform, identity) overwrites the seed, so only the newest nonce verifies; posting an older proof text is400 invalid_proof_text. Seeds also lapse on their own after 15 minutes. - Expiry is swept, not evaluated on read. Between
expires_atand the next sweep tick a proof still readsverifiedand still resolves - observed 43 seconds past its deadline. Compareexpires_atyourself if the boundary has to be exact. - Re-verifying is idempotent, not a refresh. Proving a handle you already
hold returns the original
proof_idandverified_at; it neither mints a second record nor extends a lifetime. To move an expiry, revoke and prove again. - Branch on
error, notmsg. Unrelated 400s surface under theinvalid_proof_texterror code behind a "Proof text does not contain a valid nonce" prefix - an out-of-rangeexpires_in_secondsand a disallowed URL scheme both do.
Limits and configuration
Platform names up to 64 characters, identities up to 256, proof URLs up to
2048, a 15-minute seed TTL, a 6-hour resolved-identity cache, a 10-second fetch
timeout, and a 1-year maximum lifetime - all tunable, with the expiry worker's
interval and batch size, via the ATTESTATION_* variables in the
Attestation CONFIG reference.
Reference
- Attestation API - every endpoint, callable
- Signed proofs - upgrading a possession check to ES256
- Related: Auth for OAuth account linking, Consensus for contested facts, Vault for sealed, conditionally released content