@vouchflow/web
v0.2.1
Published
Vouchflow Web SDK — passkey-backed device verification and high-assurance payload signing for browsers.
Readme
@vouchflow/web
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
Install
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>Quick start
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.
High-assurance payload signing
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
}),
})Server-side verification (Node.js)
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,
}
}React
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>
)
}Email fallback
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: '[email protected]',
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.
Capability detection
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').
Error handling
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 support
| 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.
Bundle size
| 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.
Spec
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.
Contributing
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.
License
Apache-2.0.
