Passkey-backed device verification and high-assurance payload signing for browsers. Mirrors the Vouchflow iOS and Android SDKs and adds web-specific extensions for arbitrary payload signing — a primitive the mobile SDKs do not expose.
- Zero runtime dependencies for the core
- TypeScript-first; full types ship in the package
- 6.8 KB gzipped UMD, 9 KB ESM
- React entry point at
@vouchflow/web/react(opt-in) - Compatible with strict CSP — no
eval, no inline scripts
npm install @vouchflow/webOr via UMD from jsDelivr — Vouchflow attaches to window:
<script src="https://cdn.jsdelivr.net/npm/@vouchflow/web/dist/umd/vouchflow.min.js"></script>import { Vouchflow } from '@vouchflow/web'
Vouchflow.configure({
apiKey: 'vsk_sandbox_…',
environment: 'sandbox',
rpId: 'app.example.com', // must match current origin's registrable domain
rpName: 'Example',
})
const result = await Vouchflow.shared.verify({
context: 'login',
userHandle: 'user_abc',
minConfidence: 'medium',
})
// result.verified — true
// result.confidence — 'high' | 'medium' | 'low'
// result.deviceToken — pass to your server for reputation queries
// result.sessionId — matches webhook session_idEnrollment is automatic on first verify(). Call enroll({ userHandle, forceNew: true }) only when you need to add a backup credential.
signPayload() produces a Vouchflow-attested JWS over any JSON-serializable payload — useful for mandate signing, approval signing, or any flow where you need a server-verifiable signed envelope.
import { Vouchflow, type SignResult } from '@vouchflow/web'
const signed: SignResult = await Vouchflow.shared.signPayload({
context: 'mandate_signing',
payload: { v: 1, id: 'mand_abc', scope: 'send', amount: 500 },
userHandle: 'user_abc',
minConfidence: 'high', // default for signPayload
})
await fetch('/api/mandates', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
mandate: signed.payload, // canonicalized JSON the user signed
assertion: signed.assertion, // Vouchflow-signed JWS
}),
})Verify the JWS against Vouchflow's published JWKS — no WebAuthn knowledge required.
import { jwtVerify, createRemoteJWKSet } from 'jose'
import crypto from 'node:crypto'
const JWKS = createRemoteJWKSet(
new URL('https://api.vouchflow.dev/v1/.well-known/jwks.json'),
)
export async function verifyVouchflowAssertion(
assertion: string,
canonicalizedPayload: string,
customerId: string,
) {
const { payload } = await jwtVerify(assertion, JWKS, {
issuer: 'https://vouchflow.dev',
audience: customerId,
})
const expected = crypto
.createHash('sha256')
.update(canonicalizedPayload, 'utf8')
.digest('hex')
if (payload.payload_sha256 !== expected) {
throw new Error('Payload hash mismatch — client sent different bytes than were signed')
}
return {
confidence: payload.confidence as 'high' | 'medium' | 'low',
deviceToken: payload.device_token as string,
signingDeviceId: payload.signing_device_id as string,
sessionId: payload.session_id as string,
}
}VouchflowProvider configures the SDK once on mount. useVerify and useSign are thin wrappers around the core that expose loading state and the last result.
import { VouchflowProvider, useVerify, useSign } from '@vouchflow/web/react'
export function App() {
return (
<VouchflowProvider config={{
apiKey: 'vsk_sandbox_…',
environment: 'sandbox',
rpId: 'app.example.com',
rpName: 'Example',
}}>
<SignInButton />
</VouchflowProvider>
)
}
function SignInButton() {
const { verify, isVerifying } = useVerify()
return (
<button
disabled={isVerifying}
onClick={() => verify({ context: 'login', userHandle: 'user_abc' })}
>
{isVerifying ? 'Authenticating…' : 'Sign in with passkey'}
</button>
)
}When WebAuthn is unavailable, cancelled, or fails, request an email OTP. The session ID flows through the thrown error.
import { Vouchflow, VouchflowError } from '@vouchflow/web'
try {
await Vouchflow.shared.verify({ context: 'signup', userHandle: 'user_abc' })
} catch (err) {
if (
err instanceof VouchflowError &&
(err.code === 'biometric_cancelled' ||
err.code === 'biometric_failed' ||
err.code === 'webauthn_unavailable')
) {
const fb = await Vouchflow.shared.requestFallback({
sessionId: err.sessionId!,
email: 'user@example.com',
reason: 'biometric_failed',
})
const code = await promptUserForCode()
const completed = await Vouchflow.shared.completeFallback({
sessionId: fb.fallbackSessionId,
code,
})
// completed.confidence === 'low' ← always 'low' for email fallback
}
}Email OTP fallback always returns confidence: 'low'. It proves the user controls an inbox, not that a hardware-backed device is present.
const support = await Vouchflow.shared.checkSupport()
if (!support.webauthn) showEmailOTPOnly()
else if (!support.platformAuthenticator) showSecurityKeyAndEmailOptions()
else showPasskeyButton()support.webauthn, support.platformAuthenticator, support.userVerifyingAuthenticator, support.conditionalUI, support.attestation ('available' | 'unsupported'), support.recommendedFallback ('email' | 'none').
All errors are instances of VouchflowError with a discriminated code field. Switch on err.code, not on subclass.
| Code | Recommended action |
|---|---|
invalid_config |
Fix at init (most often an rpId mismatch with the current origin) |
not_configured |
Call Vouchflow.configure() before Vouchflow.shared |
not_in_browser |
Don't call verify()/signPayload() from Node / SSR |
webauthn_unavailable |
Offer email fallback |
platform_authenticator_unavailable |
Offer security-key or email fallback |
biometric_cancelled |
Offer retry; err.sessionId is set |
biometric_failed |
Offer fallback using err.sessionId |
concurrent_ceremony |
Another tab is mid-verify; WebAuthn is exclusive per origin |
enrollment_failed |
Usually transient — retry |
invalid_signature |
Clear state and re-enroll |
challenge_expired |
Retry; SDK normally fires within ms |
challenge_already_used |
Atomic guard tripped — retry from scratch |
device_not_found |
Local state stale — forget() and re-enroll |
minimum_confidence_unmet |
Block or degrade; err.actualConfidence is set |
rate_limit_exceeded |
Back off and retry |
unauthorized |
Wrong API-key prefix or scope |
invalid_otp |
Re-prompt up to the lockout |
fallback_locked |
Surface a generic auth failure |
network_error |
err.retryable === true — back off |
aborted |
Caller asked for this via AbortSignal |
unknown_error |
Log err.cause |
| Browser | Minimum | Notes |
|---|---|---|
| Chrome | 109+ | Full WebAuthn + conditional UI |
| Edge | 109+ | Chromium engine — same support |
| Safari | 16.4+ | iOS 16.4+ and macOS 13.3+ for full passkey support |
| Firefox | 119+ | WebAuthn mature; conditional UI lags Chromium |
WebAuthn refuses to run over http:// outside localhost. Make sure staging and preview environments have valid TLS.
In Node.js / SSR, verify() and signPayload() throw not_in_browser. The package is import-safe in SSR — only the active call sites need a real browser.
| Build | Size (gzipped) |
|---|---|
UMD (dist/umd/vouchflow.min.js) |
6.8 KB |
ESM (dist/index.js) |
9 KB (tree-shakeable) |
React entry (dist/react/index.js) |
+1.4 KB |
CI fails if either core bundle exceeds the configured budget.
The full specification — including architectural rationale, IndexedDB schema, error mapping rules, and the WebAuthn ceremony details — lives at docs/spec.md. Customer-facing docs are at vouchflow.dev/docs/web-sdk.
Issues and PRs welcome at github.com/vouchflow/web-sdk. Unit tests use Vitest with fake-indexeddb; integration tests use Playwright's virtual authenticator API. See test/ for examples.
Apache-2.0.