spoken-token

July 7, 2026 · View on GitHub

Nostr: npub1mgvlrnf5hm9yf0n5mf9nqmvarhvxkc6remu5ec3vf8r0txqkuk7su0e7q2

CI npm GitHub Sponsors

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.

ParamTypeDescription
secretUint8Array | stringShared secret (hex string or bytes, min 16 bytes)
contextstringDomain separation string
counternumberTime-based or usage counter (uint32)
encodingTokenEncodingOutput format (default: single word)
identitystring?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.

LibraryWhat it does
nsec-treeDeterministic sub-identity derivation
ring-sigSAG/LSAG ring signatures on secp256k1
range-proofPedersen commitment range proofs
canary-kitCoercion-resistant spoken verification
spoken-tokenHuman-speakable verification tokens
toll-boothL402 payment middleware
geohash-kitGeohash toolkit with polygon coverage
nostr-attestationsNIP-VA verifiable attestations
dominionEpoch-based encrypted access control
nostr-veilPrivacy-preserving Web of Trust

Licence

MIT