spoken-token
July 7, 2026 · View on GitHub
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.
Why spoken-token?
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.
Quick start
Rideshare pickup
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'
Phone call auth
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'
Courier handoff
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')
}
API
deriveToken(secret, context, counter, encoding?, identity?)
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 |
Identity binding with nsec-tree
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, undefined, personal.identity.npub)
const tokenB = deriveToken(groupSecret, 'canary:verify', counter, undefined, bitcoiner.identity.npub)
// tokenA !== tokenB — persona isolation
Identity binding is label/domain separation, not proof of identity by itself. If multiple parties know the same group secret, each party can derive tokens for any public identity label. Use per-member secrets or signatures when attribution must be cryptographically enforced.
verifyToken(secret, context, counter, input, identities?, options?)
Verify a spoken or entered token. Returns { status: 'valid' | 'invalid', identity?: string }.
Options: { encoding?, tolerance?, identityMode? } — tolerance accepts tokens within ±N counter steps (max 10).
identityMode: 'with-group-fallback' is the default for backwards compatibility:
identity-bound tokens are checked first, then group-wide tokens are also accepted.
Use identityMode: 'identity-only' to accept only tokens bound to the supplied
identity list.
estimateVerificationRisk(options?)
Estimate how many token values a verification call accepts and the single-attempt online guessing probability. Use this when combining identities, tolerance, and short encodings:
import { estimateVerificationRisk } from 'spoken-token'
estimateVerificationRisk({
identities: 100,
tolerance: 1,
encoding: { format: 'words', count: 1 },
})
// → candidates: 303, singleAttemptSuccessProbability: ~13.8%
deriveDirectionalPair(secret, namespace, roles, counter, encoding?)
Derive two distinct tokens from the same secret, one per role. Roles are [string, string] — e.g. ['caller', 'agent']. Returns { [role]: word }.
getCounter(timestampSec, rotationIntervalSec?)
Compute floor(timestamp / interval). Default interval: 604800 (7 days). Pass 30 for 30-second TOTP-style rotation.
Encoding options
{ format: 'words', count?: number, wordlist?: string[] } // default: 1 word
{ format: 'pin', digits?: number } // default: 4 digits
{ format: 'hex', length?: number } // default: 8 chars
Wordlist
Ships 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 })
How it works
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 = "pair\0" + namespace + "\0" + role so each role's token is cryptographically independent.
Used by
canary-kit — deepfake-proof identity verification
Try it
npx tsx examples/rideshare.ts
npx tsx examples/phone-auth.ts
npx tsx examples/identity-verify.ts
Part of the ForgeSworn Toolkit
ForgeSworn builds open-source cryptographic identity, payments, and coordination tools for Nostr.
| Library | What it does |
|---|---|
| nsec-tree | Deterministic sub-identity derivation |
| ring-sig | SAG/LSAG ring signatures on secp256k1 |
| range-proof | Pedersen commitment range proofs |
| canary-kit | Coercion-resistant spoken verification |
| spoken-token | Human-speakable verification tokens |
| toll-booth | L402 payment middleware |
| geohash-kit | Geohash toolkit with polygon coverage |
| nostr-attestations | NIP-VA verifiable attestations |
| dominion | Epoch-based encrypted access control |
| nostr-veil | Privacy-preserving Web of Trust |
Licence
MIT