Skip to content

Attestation

Try the interactive example ↓

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-text mints 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.
  • platform picks the retrieval strategy. dns and domain resolve TXT records over DNS-over-HTTPS; github and gist normalize 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 gets 409 identity_claimed until the first claim is revoked or expires.
  • Attestations are revocable and optionally time-boxed. expires_in_seconds sets a lifetime; a background sweep flips lapsed proofs to expired and releases the handle for re-claim.
  • resolve is 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/abc123

The 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": "0199e100-0000-7000-8000-000000000005",
    "verified": true,
    "platform": "twitter",
    "identity": "docs4-bob",
    "signature_verified": false,
    "attestation_url": "/api/v1/proofs/0199e100-0000-7000-8000-000000000005",
    "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 0199e100-0000-7000-8000-000000000006
snug attestation list                                # the caller's own proofs
snug attestation list --filter 'platform[eq]=github' --limit 2
snug attestation audit --proof-id 0199e100-0000-7000-8000-000000000006 --limit 10

list returns a proofs array plus the shared page object (25 per page by default) and takes the shared list parameters: filter on platform, identity, status, signature_verified and the timestamps, and sort by created_at, verified_at or expires_at. audit returns the per-proof audit events newest-first (verified, revoked, expired) as events plus page, paged by cursor (--limit up to 100, --all); the audit history keeps the most recent 500 events. 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": "0199e100-0000-7000-8000-000000000006",
    "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 0199e100-0000-7000-8000-000000000005

Revoking flips the status to revoked, drops the handle out of resolve, and frees it, 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-text for the same (user, platform, identity) overwrites the seed, so only the newest nonce verifies; posting an older proof text is 400 invalid_proof_text. Seeds also lapse on their own after 15 minutes.
  • Expiry is swept, not evaluated on read. Between expires_at and the next sweep tick a proof still reads verified and still resolves - observed 43 seconds past its deadline. Compare expires_at yourself if the boundary has to be exact.
  • Re-verifying is idempotent, not a refresh. Proving a handle you already hold returns the original proof_id and verified_at; it neither mints a second record nor extends a lifetime. To move an expiry, revoke and prove again.
  • Branch on error, not msg. Unrelated 400s surface under the invalid_proof_text error code behind a "Proof text does not contain a valid nonce" prefix - an out-of-range expires_in_seconds and 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 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.

Durable storage

Proofs and audit events live in PostgreSQL. A database constraint allows only one active proof of each platform/identity across the deployment. Verification and its first audit event commit together; revocation or expiry releases that claim and appends its audit event in the same transaction. The proof and audit rows remain scoped to their owning tenant.

Redis retains the short-lived verification challenge. Resolution reads the active SQL claim directly, so a committed revocation cannot be revived by a stale result cache. Cross-tenant resolution remains a deliberate authenticated public lookup; private proof details and audit routes retain owner/admin checks.

Reference

Identifiers

Proof and audit-event IDs are opaque UUIDv7 strings. External platform identities and disposable verification challenges keep their separate formats.

Interactive example

Verify control of an external profile through a challenge and public proof. Keep the resulting verification attached to the identity it confirms.

An illustrative example that runs in your browser. No account or API calls required.