Overview
GBGBridge solves a fundamental problem: web-based identity verification journeys, when embedded as an iframe inside another web page, are subject to browser-level origin isolation that prevents the journey from accessing host-page state, calling host functions, or triggering host UI directly. Rather than building separate host-page logic for every journey variation, GBGBridge provides a structuredpostMessage protocol so a single journey can run anywhere — inside an iOS WebView, an Android WebView, or an iframe on a web host — and request host capabilities through the same interface.
The Web Bridge SDK is the host-page half of that protocol. The other half — the iframe-side SDK that runs inside the journey iframe — is documented separately under the TypeScript Bridge SDK.
Key Terminology
Architecture
The Web SDK has two layered packages: a framework-agnostic core (@gbg/go-bridge-web) and a React adapter (@gbg/go-bridge-web-react) that wraps the core in a Provider and hooks.
Component Diagram
BridgeHostProvider is a thin React adapter that owns the BridgeHost lifecycle. Non-React hosts construct new BridgeHost(...) directly. The transport details live in HostIframeChannel, which the host owns internally.
Layers
- Adapter Layer —
BridgeHostProvider(React) holds the underlyingBridgeHostin component state, snapshots config props on attach, and tears down on unmount. Non-React hosts skip this layer. - Routing Layer —
BridgeHostreceives validated messages from the channel, appends them to observable buffers, fans out to the delegate, and routesrequest-type messages to registered handlers. - Handler Layer —
BridgeCapabilityHandleris an interface with a singlehandle(request, responder)method. The built-inCapabilityQueryHandlerservicescapability.queryautomatically; integrators register additional handlers viauseBridgeCapability(...)(React) orhost.registerHandler(...)(direct). - Transport Layer —
HostIframeChannelowns the singlewindowmessage listener, performs origin and envelope validation, and usesiframe.contentWindow.postMessagefor outbound sends. Origin checks are enforced on both inbound and outbound traffic. - Observation Layer —
BridgeHostDelegateexposes four optional callbacks —onMessage,onUnhandledRequest,onMessageSent,onError— for full visibility into the message flow.
Message Protocol
All communication uses structured JSON envelopes. The protocol version is1.0, and the wire format is identical to the iOS and Android SDKs — a web journey that speaks the bridge protocol works unchanged on all three platforms.
Message Envelope
timestamp is epoch milliseconds. payload.action is required on all three message types — envelopes without a non-empty string action are rejected at the transport layer.
The wire protocol version is exposed as BridgeHost.PROTOCOL_VERSION for telemetry. On version mismatch, the host logs a warning and continues processing rather than failing — this lets minor protocol additions roll out without breaking older integrations.
Message Types
Message Flow
Correlation IDs
Every request has acorrelationId. When the host responds, it includes the same correlationId so the journey can match the response to its original request. Host-emitted events generate their own unique correlation IDs in the format web-event-{uuid} (the SDK uses crypto.randomUUID() where available, with a timestamp+random fallback for older environments).
Response Statuses
BridgeResponder enforces at-most-once delivery on terminal statuses. After a terminal status is sent, subsequent calls on the same responder are silently no-ops. acknowledge() is the one exception: it may be called once before any terminal status, and the responder remains usable for the terminal call.
Error Codes
The protocol defines a small set of well-known error codes, exposed as theBridgeErrorCode enum:
Handlers that throw an uncaught exception trigger an automatic
error response with code HANDLER_FAILURE and recoverable: false, so the journey is never left waiting on a request that the host silently dropped. The error is also routed to delegate.onError.
Request Routing
When a message arrives from the journey iframe:- The
HostIframeChannel’swindowmessagelistener fires. - Source check — Events whose
event.sourceis not the iframe’scontentWindoware dropped silently. This prevents cross-window leakage from other iframes on the page. - Origin check — Events whose
event.originis not inallowedOrigins(after normalization) are dropped silently. - Envelope validation — Events with malformed envelopes (missing
version,correlationId,type,timestamp,payload.action, or invalidtype) are dropped silently. - The validated
BridgeMessageis handed toBridgeHost.handleIncomingMessage. - The message is appended to
receivedMessages(an observable buffer capped at the most recent 200 messages). delegate.onMessage(host, message)fires.- If
message.type === 'request':- If a handler is registered for
payload.action,handler.handle(message, responder)is called. - If no handler is registered, the message is pushed to
pendingRequests(capped at 50; oldest dropped on overflow) anddelegate.onUnhandledRequest(host, message)fires. You can respond later viahost.respond({ correlationId, status, ... }).
- If a handler is registered for
- If
message.type === 'response'or'event', no routing occurs — the message is just observed.
delegate.onError, and if the handler had not yet responded, an error response with code HANDLER_FAILURE is dispatched automatically so the journey is never left waiting.
Declaring vs. Registering Capabilities
A platform divergence worth understanding upfront: on iOS and Android, the Bridge SDK exposes typed capability slots — first-class properties onBridgeHost (e.g. host.documentCapture) where setting a handler simultaneously declares the capability as supported. The web SDK does not have typed slots. Instead, declaration and registration are two separate calls:
In the typical React pattern:
capabilities entry), the journey’s capability.query response will not list camera.document as supported. The journey will fall back to its alternative flow even though your handler is sitting ready. This is the most common integration mistake — there is a dedicated entry in Getting Started → Common Pitfalls.
For dynamic capability additions at runtime (e.g. after a permission grant), use host.registerCustomCapability(id, version, handler) — it declares and registers in one call.
Capability ID vs. Action ID
The protocol uses two related strings:- Capability ID (e.g.
camera.document) — names the feature. Appears in thecapabilitiesmap andcapability.queryresponses. Listed as constants inCAPABILITY_IDS. - Action ID (e.g.
camera.document.capture) — names the specific operation. Appears inuseBridgeCapability(...),registerHandler(...), and inboundpayload.action.
payload.action = "camera.document.capture"; the host has previously registered a handler for that action and declared support for the underlying camera.document capability.
Capability Negotiation
Before invoking a capability, the journey sends acapability.query request. The host’s built-in CapabilityQueryHandler responds automatically with the effective capability map — your declared capabilities plus any added via registerCustomCapability.
Query Response Format
supported and version are always emitted (version is null when unset). permissionState is included only when you set it in the CapabilityInfo entry. The environment field is the literal string "web" for this SDK; iOS reports "ios", Android reports "android".
Why declare?
The journey uses this response to:- Adapt its UI — show or hide steps based on what the host can fulfil.
- Prevent errors — skip requests for unsupported capabilities and fall straight to alternatives.
- Check permissions — surface permission state before triggering a request, avoiding immediate denial.
- Degrade gracefully — use an in-iframe fallback (e.g.
getUserMediain the iframe itself) when the host doesn’t expose a capability.
Transport Mechanism
The Web SDK uses the browser’s nativepostMessage channel in both directions, with origin validation enforced on both sides.
Iframe → Host (Incoming)
The journey iframe sends:window message listener receives the event. HostIframeChannel filters it: event.source must equal the host’s iframe.contentWindow, event.origin must be in allowedOrigins, and the envelope must validate. Surviving events are decoded and routed to BridgeHost.
There is no script injection in the iframe — the host page does not inject any JavaScript into the journey iframe. Cross-origin restrictions would prevent it anyway. The journey loads its own iframe-side SDK independently as part of its bundle.
Host → Iframe (Outgoing)
The host sends:HostIframeChannel resolves targetOrigin from iframe.src at send time (so a late iframe.src assignment is picked up) and validates it against allowedOrigins — outbound messages to a non-allowlisted origin are dropped with a console.warn. This guards against a repointed iframe.src leaking payloads to a non-trusted host.
Why origin validation on both sides?
Same-origin policy doesn’t prevent a page from posting messages to an iframe whosecontentWindow it holds a reference to — it only blocks direct DOM access. Without explicit origin filtering, a malicious script on the host page could redirect the iframe to a different origin (via iframe.src = ...) and start receiving the host’s outbound messages. Two-sided origin validation closes that hole.
Comparison with iOS and Android
React Lifecycle
BridgeHostProvider ties the underlying BridgeHost instance to React component state. Three rules govern this binding:
- Build on iframe attach. The provider holds off until
iframeis non-null. When the iframe element appears (via theref={setIframe}callback), the provider instantiatesBridgeHostand stores it in state, which then propagates throughBridgeHostContextto child hooks. - Config snapshot at attach time.
allowedOrigins,hostVersion, andcapabilitiesare read into a snapshot when the host is built. Subsequent renders that change these props do not rebuild the host. To apply changes, force a Provider remount (key={...}rotation) or call methods on the host instance directly (e.g.registerCustomCapability). - Detach on unmount. When the provider unmounts (or the iframe transitions back to null), the effect cleanup calls
host.detach()and clears the host from state. The internalwindowmessagelistener is removed at this point, so there is no leak.
new BridgeHost(...) directly, you own the lifecycle. Call host.detach() when the iframe is no longer in use to remove the message listener and free any pending request buffers.
Threading model
JavaScript is single-threaded — there is no main-thread / background-thread distinction to manage. Handlers may beasync and the responder’s terminal methods can be called from any continuation. The protocol’s at-most-once enforcement is purely state-based, not thread-based.
Platform divergences from iOS / Android
The Web SDK speaks the same wire protocol as iOS and Android, but a few implementation details differ because the browser model isn’t a native WebView. The table lists the divergences integrators porting host code from iOS or Android are most likely to hit.
The full parity inventory (PARITY.md) lives in the internal SDK source repo.
Design Rationale
The design choices below aren’t obvious from the API surface alone. Each Q&A explains why a specific decision was taken, and what it costs or buys the integrator.Why a message protocol instead of direct method calls?
A message protocol decouples the journey from the host. The journey doesn’t need to know whether it’s running inside an iOS WebView, an Android WebView, or an iframe in a web page — it sends the same messages regardless. This also allows the protocol to evolve without breaking existing integrations.Why declare capabilities upfront?
Capability declaration serves two purposes: it lets the journey adapt before hitting a dead end, and it gives the host explicit control over what features are exposed. A host page might run on a device with a camera but choose not to expose camera capture for a particular journey — for example, because the host’s UX provides a richer capture flow than what the iframe can offer.Why separate declare and register on web (vs. typed slots on iOS / Android)?
iOS and Android can expose typed properties on a strongly-typedBridgeHost because their languages support it ergonomically (Swift’s @StateObject, Kotlin’s properties with backing fields). In a React + TypeScript host, the equivalent would require either generating a typed Provider for each capability or using non-idiomatic prop shapes. The capabilities prop + useBridgeCapability split keeps the React API conventional — declarative props for state, hooks for side effects — at the cost of one extra integration step. registerCustomCapability(id, version, handler) is available on the underlying host for callers who prefer the typed-slot ergonomics.
Why an origin allowlist?
Unlike a native WebView (where the host has full control over what URL loads), an iframe can be repointed by any script in the host page. TheallowedOrigins allowlist makes this safe: an attacker who repoints iframe.src to a hostile origin cannot send messages to or receive messages from the host’s bridge. Origin validation is enforced on both inbound and outbound traffic for this reason.
Why no bootstrap script injection?
iOS and Android inject a script into the native WebView at document start to exposewindow.GBGBridge. On web, the iframe is cross-origin — the host page cannot execute JavaScript inside it. The journey iframe loads its own SDK as part of its bundle. This is a hard browser constraint, not a design choice — but it also keeps the host-side surface simpler: there is no bootstrap configuration to manage.
Why does the React Provider snapshot config?
BridgeHost instances are not free to rebuild — the message listener wiring, capability registration, and pending-request state would all reset. Snapshotting allowedOrigins, hostVersion, and capabilities at attach time means a parent re-render that changes those props (often unintentionally — e.g. a new array literal) does not silently rebuild the host. Integrators who genuinely need to apply runtime config changes use either key-based remount or the registerCustomCapability API.
Why zero runtime dependencies?
GBGBridge sits on a hot path: every iframe message routes through it. Adding dependencies would increase bundle size, create version conflicts with consumer apps, and introduce supply-chain risk. The core uses only standard browser APIs (postMessage, MessageEvent, URL, crypto.randomUUID with fallback). The React package depends only on React itself, as a peer dependency.
Next Steps
- API Reference — Detailed documentation for every public symbol.
- Embedding — React, framework-agnostic, and SSR-aware integration patterns.
- Messaging — Sending events and handling requests in depth.
- Capability Handling — Declaring, registering, and dynamic capability negotiation.