Updated: 2026-09-06
A page that speaks the kyc-bridge protocol is embedded in a hardened WebView (React Native) or an origin-pinned iframe (web). This repo's packages/kyc-service serves such a page at GET /hosted/:sessionId, but any page of yours that posts the same envelopes works identically - the packages have no knowledge of what renders inside.
# React Native - the Apollo-free entry
npm i @blinkbitcoin/kyc-react-native react-native-webview @react-native-community/netinfo
# React web - single entry
npm i @blinkbitcoin/kyc-reactimport { createHostedSource, IdentityVerification } from '@blinkbitcoin/kyc-react-native/hosted';
// web: import { createHostedSource, IdentityVerification } from '@blinkbitcoin/kyc-react';
const source = createHostedSource({
getSession: async () => yourApi.startVerification(), // must return { url }
refreshToken: async (session) => yourApi.refreshToken(session.sessionId),
// omit refreshToken and an expired token becomes a Restart prompt instead
});| Option | Type | Notes |
|---|---|---|
url |
string |
A static page url - the simplest possible source |
getSession |
() => VerificationSession | Promise<VerificationSession> |
Preferred: mints a url per attempt |
allowedOrigin |
string |
Overrides the derived origin |
provider |
string |
Labels the session, default 'hosted' |
refreshToken |
(previous: VerificationSession) => Promise<string> |
Supplying it makes the returned source a TokenRefreshableSource |
Exactly one of url or getSession is required; without either, start() rejects with VALIDATION_ERROR.
allowedOrigin is derived when you do not supply it, from the session url by regex (scheme + host + port), rather than through the DOM URL global - React Native has no built-in URL on Hermes without a polyfill, and polyfilled origins differ subtly from the browser's. Anything that is not a well-formed http(s) url yields undefined, and the component then fails closed: every message from the page is ignored, token refresh is disabled, and a console warning explains why.
Page → app. One envelope shape:
{ source: 'kyc-bridge', v: 1, type: <event type>, payload?: Record<string, unknown> }posted through window.ReactNativeWebView.postMessage(JSON.stringify(msg)) when that global exists, otherwise window.parent.postMessage(msg, '*'). interpretBridgeMessage accepts an object or a JSON string, and returns null for anything whose source is not kyc-bridge or whose v is not the supported version - which is precisely what filters the provider SDK's own postMessage traffic out of your event stream.
type |
Payload | Meaning |
|---|---|---|
applicantLoaded |
{ applicantId: string } |
The provider identified the applicant |
submitted |
- | Documents submitted; the flow moves to pending |
statusChanged |
{ status: IdentityVerificationStatus } |
Intermediate status |
complete |
{ status: IdentityVerificationStatus, applicantId?: string } |
Terminal outcome |
cancel |
- | The user aborted inside the page |
tokenExpired |
- | The access token expired and the page wants a new one |
sessionExpired |
- | The session is over and cannot be refreshed |
error |
{ code?: string, message?: string } |
Missing code becomes BRIDGE_PROTOCOL |
App → page. Only for token refresh, and the two platforms use different transports on purpose:
- React Native injects
createSetTokenScript(token), which callswindow.__kycBridge.setToken("<bare token>"). There is no envelope to validate because an injected script is already trusted; the trailingtrue;is whatinjectJavaScriptexpects back. - Web posts
createSetTokenMessage(token)- the full{ source: 'kyc-bridge', v: 1, type: 'setToken', token }envelope - atallowedOrigin, never at'*'. The page must checksourceandvbefore trusting it.
A page that accepts both is trivial: packages/kyc-service's readToken takes either a bare string or an object with a .token. This repo's own mock page acknowledges a refreshed token entirely in its own DOM (document.body.dataset.tokenRefreshed = 'true', an element with id mock-token-refreshed) rather than by emitting a bridge event - a statusChanged there would move your state machine (pending hides and mutes the page) and end the flow instead of resuming it, so a page that wants to signal a successful refresh should do the same: acknowledge it locally, not over the bridge.
When the page emits tokenExpired:
- If
isTokenRefreshable(source)and a session and (on the web) anallowedOriginexist, the hook awaitsrefreshToken(session)and pushes the token back into the page. Two guards keep this honest: an unmount flag drops anything that resolves too late, and a monotonic sequence number means only the newest refresh may inject - latest wins. - Otherwise, or if the refresh rejects, the flow enters
errorwithTOKEN_EXPIRED/TOKEN_REFRESH_FAILED, keeping the session, and the error screen offers Restart rather than Try again.
sessionExpired always takes the Restart path - it is not refreshable by definition. The hosted page you write should emit sessionExpired for a session that can no longer progress (unknown, wrong provider, or already terminal) rather than staying silent; packages/kyc-service's own not-found page (served for those cases, and for a 502 when token minting fails) does exactly this.
The iframe is rendered with allow="camera; microphone; fullscreen", sandbox="allow-scripts allow-same-origin allow-forms" and referrerPolicy="strict-origin-when-cross-origin". Three things must line up or the provider's liveness step cannot open the camera:
- Your page must be permitted to use the camera itself. If your app sends a
Permissions-Policyheader, it must not dropcamera/microphonefor its own origin - a frame can only be delegated a capability its embedder holds. - Your page must be served over HTTPS (or
localhost). Browsers do not grantgetUserMediaon plain HTTP. - The verification page must allow being framed by you. This repo's
packages/kyc-serviceserves it withframe-ancestors *and noX-Frame-Options, and withPermissions-Policy: camera=(self "https://api.sumsub.com"), microphone=(self "https://api.sumsub.com"). If your own page sets a CSP, add the verification origin toframe-src.
allow-same-origin is required, not an oversight: the provider SDK uses its own storage, and a sandbox without it gives the page an opaque origin where every storage access throws. Because the page is served from a different origin than your app, that flag restores the page's own origin and grants the frame nothing over your document. fullscreen is delegated because a provider's document-capture step can ask for the whole viewport. Popups, top-level navigation and downloads stay denied.
mediaCapturePermissionGrantType: 'grant' (iOS/macOS only - grants WKWebView capture without a second in-page prompt, the single most common cause of a false "Allow camera access" loop; Android has no equivalent property, and relies on react-native-webview's own onPermissionRequest granting whatever the app already holds OS permission for), allowsInlineMediaPlayback, mediaPlaybackRequiresUserAction: false, originWhitelist pinned to [sessionOrigin, ...allowedNavigationOrigins] (checked first, for every navigation - react-native-webview opens a miss in the system browser rather than consulting the guard below), a navigation guard re-checking the same list for every navigation action including subframes (and, on Android, allowing an unanswered check through after 250 ms - defense in depth, not a sandbox), setSupportMultipleWindows: false, cacheEnabled: false, allowFileAccess: false, and a pre-content script that installs window.__kycBridge so a token arriving before the page's own handler is queued rather than lost. The full table is in ../architecture/mobile.md.
- The hosted URL has no max-age of its own:
GET /hosted/:sessionIdserves the page until the session reaches a terminal status (then it renders the expired page). Session ids are unguessable, and the provider access token inside the page still expires on the provider's schedule. - The
IdentityVerificationcomponents' built-in strings (buttons, status copy, permission screens) are English only and not overridable yet; useuseIdentityVerificationand render your own UI if you need localized copy.
Everything the packages require of a page:
- Post
kyc-bridgeenvelopes for the events above, preferringwindow.ReactNativeWebView.postMessagewhen it exists. - Accept a
setTokenin either transport and hand the token to the provider SDK. - Serve it over HTTPS, allow being framed, and delegate
camera/microphoneto the provider's origin.
packages/kyc-node/src/providers/sumsub/page.ts (and the mock's providers/mock/page.ts) is a complete worked example, including the CSP.