writtenbyhuman.ai

How it works

The token

A post ends with ✓writtenbyhuman.ai/abc123. The link opens a page stating when the text was signed and on what kind of device. Readers paste the text to confirm it matches.

What is stored

Only: SHA-256 hash of the normalized text, the signature, the authenticator's public key and metadata, and a timestamp. The text itself never leaves the writer's device. No IP addresses or user agents are kept.

Normalization

Both the writer's and the reader's browser run the same function before hashing (normalize.js): remove any existing token, Unicode NFC, unify smart quotes/dashes/ellipsis to ASCII, strip zero-width characters, collapse whitespace, trim. The hash is sha256(utf8(normalize(text))).

Signing (web)

WebAuthn / passkey with userVerification: required and authenticatorAttachment: platform. The challenge is 16 random bytes followed by the 32-byte text hash, so the device signature covers the hash directly. The server verifies the signature, the UV flag, origin binding and the one-time challenge. AAGUID 00000000-… (virtual/software authenticators) is rejected.

Tier ✓ any accepted passkey · Tier ✓✓ known platform authenticator (Apple, Google, Windows Hello, Samsung) or attested key.

Signing (app) — Tier ✓✓✓

iOS: a Secure Enclave key with biometryCurrentSet access control, attested with App Attest. Android: a hardware Keystore key with BIOMETRIC_STRONG, key attestation and Play Integrity. These keys have no PIN or password fallback — only a biometric can unlock them — and the device + app are attested as genuine. The server verifies the attestation chains, nonces, key policies and counters.

What it proves — and doesn't

It proves a human verified themselves on a real device and signed this exact text at that time. It does not prove the human typed the text, and it cannot force anyone else to use it. Rate limits: 10 signatures/minute, 200/day per key.

Verify independently

GET /<id>.json returns public key, signature, authenticator data and client data. For passkeys: the signed message is authenticatorData ‖ sha256(clientDataJSON); clientDataJSON.challenge ends with the 64-hex text hash (base64url of 48 bytes).