Nostr: npub1mgvlrnf5hm9yf0n5mf9nqmvarhvxkc6remu5ec3vf8r0txqkuk7su0e7q2
TOTP but you say it out loud — derive time-rotating, human-speakable verification tokens from a shared secret.
npm install spoken-token
Zero runtime dependencies. ESM-only. Works in Node.js and the browser.
TOTP gives you a 6-digit code on a screen — great for typing into a website, useless for saying over a phone call. Digits are hard to speak, easy to mishear, and carry no meaning.
spoken-token replaces digits with words from a curated 2048-word English wordlist — no homophones, no phonetic near-collisions, 3–8 characters each. The derivation is the same (HMAC over a counter), but the encoding is optimised for the human voice.
The app derives the same word on both sides. Rider reads it aloud; driver confirms.
import { deriveToken, getCounter } from 'spoken-token'
const counter = getCounter(Date.now() / 1000) // rotates every 7 days by default
const word = deriveToken(sharedSecret, 'rideshare:pickup', counter)
// → 'carbon'Two roles, two different words — neither party can parrot the other.
import { deriveDirectionalPair, getCounter } from 'spoken-token'
const counter = getCounter(Date.now() / 1000, 30) // 30-second rotation
const { caller, agent } = deriveDirectionalPair(sharedSecret, 'support-call', ['caller', 'agent'], counter)
// caller hears: 'timber'
// agent says: 'canyon'Verify a spoken word against a secret without transmitting the secret.
import { verifyToken, getCounter } from 'spoken-token'
const counter = getCounter(Date.now() / 1000)
const result = verifyToken(sharedSecret, 'courier:handoff', counter, spokenWord, undefined, { tolerance: 1 })
if (result.status === 'valid') {
console.log('Package accepted')
}Derive an encoded token string.
| Param | Type | Description |
|---|---|---|
secret |
Uint8Array | string |
Shared secret (hex string or bytes, min 16 bytes) |
context |
string |
Domain separation string |
counter |
number |
Time-based or usage counter (uint32) |
encoding |
TokenEncoding |
Output format (default: single word) |
identity |
string? |
Optional per-member identifier |
When using nsec-tree for deterministic sub-identity derivation, a persona's npub makes a natural identity parameter — different personas produce different tokens from the same group secret:
import { deriveToken } from 'spoken-token'
import { fromMnemonic } from 'nsec-tree/mnemonic'
import { derivePersona } from 'nsec-tree/persona'
const root = fromMnemonic(mnemonic)
const personal = derivePersona(root, 'personal', 0)
const bitcoiner = derivePersona(root, 'bitcoiner', 0)
// Same group secret, same counter — different persona = different token
const tokenA = deriveToken(groupSecret, 'canary:verify', counter, 'words', personal.identity.npub)
const tokenB = deriveToken(groupSecret, 'canary:verify', counter, 'words', bitcoiner.identity.npub)
// tokenA !== tokenB — persona isolationVerify a spoken or entered token. Returns { status: 'valid' | 'invalid', identity?: string }.
Options: { encoding?, tolerance? } — tolerance accepts tokens within ±N counter steps (max 10).
Derive two distinct tokens from the same secret, one per role. Roles are [string, string] — e.g. ['caller', 'agent']. Returns { [role]: word }.
Compute floor(timestamp / interval). Default interval: 604800 (7 days). Pass 30 for 30-second TOTP-style rotation.
{ format: 'words', count?: number, wordlist?: string[] } // default: 1 word
{ format: 'pin', digits?: number } // default: 4 digits
{ format: 'hex', length?: number } // default: 8 charsShips en-v1: 2048 English words curated for spoken-word clarity — no homophones, no phonetic near-collisions, 3–8 characters each.
Supply your own via the wordlist option (must be exactly 2048 entries):
deriveToken(secret, context, counter, { format: 'words', wordlist: myWordlist })Each token is HMAC-SHA256(secret, utf8(context) || counter_be32), truncated and mapped onto a wordlist or numeric range. The counter is derived from wall-clock time divided by the rotation interval, giving both parties the same value without coordination. A tolerance window (default: 0) accepts tokens from adjacent counter steps to absorb clock skew. Directional pairs use context = namespace + '\0' + role so each role's token is cryptographically independent.
canary-kit — deepfake-proof identity verification
npx tsx examples/rideshare.ts
npx tsx examples/phone-auth.ts
npx tsx examples/identity-verify.tsMIT