Skip to main content
This guide covers the security model of GBGBridge, including transport safety, content policies, and best practices for production deployments.

Security Model Overview

GBGBridge operates within the Android WebView security model. The bridge is a thin messaging layer — it does not bypass any platform security mechanisms. All communication between the web journey and the native host goes through addJavascriptInterface (incoming) and evaluateJavascript (outgoing).

Trust Boundaries

  • Native host code runs with full app permissions. Handlers can access any Android API.
  • Web content in the WebView runs in a sandboxed renderer process. It cannot access native APIs except through the bridge interface.
  • The bridge is the controlled interface between these two zones.

Transport Security

Bridge messages never traverse the network — they ride on the WebView’s JavaScript interface mechanism — but the journey URL itself does, so transport security has two distinct concerns: how messages move between native and web (covered first) and how the journey is loaded over HTTPS (covered second).

Message Channel

Messages travel over the WebView’s built-in JavaScript bridge mechanism:
  • Web → Native: window.GBGBridge.postMessage() — backed by addJavascriptInterface. Only methods annotated with @JavascriptInterface are callable from JavaScript, and the SDK exposes exactly one: a single postMessage entry point that accepts a JSON string. Calls arrive on the WebView’s render thread and are posted to the main looper before any handler or delegate runs.
  • Native → Web: webView.evaluateJavascript() — executes JavaScript in the WebView’s context.
Neither channel traverses the network. Messages are memory-to-memory within the device. Captured images travel through the bridge as base64-encoded strings in message payloads — the SDK holds them in memory only and never writes them to disk.

Network Security (Journey Loading)

The web journey itself is loaded over the network. To ensure network security:
  • Always use HTTPS in production. Android blocks cleartext HTTP by default (API 28+), and the SDK’s library manifest does not relax this.
  • Do not enable cleartext traffic globally. Never ship android:usesCleartextTraffic="true" in a production manifest. If you need local development access, use a network security configuration scoped to development hosts:
<!-- res/xml/network_security_config.xml -->
<network-security-config>
    <domain-config cleartextTrafficPermitted="true">
        <domain includeSubdomains="true">localhost</domain>
        <domain includeSubdomains="true">10.0.2.2</domain>
        <domain includeSubdomains="true">127.0.0.1</domain>
    </domain-config>
</network-security-config>
Reference it from your manifest’s <application> element via android:networkSecurityConfig="@xml/network_security_config". The reference app takes this a step further: its main source set ships the locked-down config above (cleartext only to emulator loopback hosts), and the debug source set overrides the same file with a permissive variant for physical-device LAN testing — so release builds always ship the locked-down version.
  • Pin certificates if your security requirements demand it. Android’s network security configuration supports declarative certificate pinning via <pin-set> entries.
Never call handler.proceed() in WebViewClient.onReceivedSslError. Proceeding past an SSL error disables certificate validation for that connection and is a common cause of app store rejections. The default behavior (cancel the load) is the correct one — if you override onReceivedSslError at all, use it for logging only.

Credential Passthrough

Authentication cookies and headers for the journey URL are entirely host-owned — the SDK does not read, store, or forward credentials. Use CookieManager to manage cookies and webView.loadUrl(url, additionalHeaders) to attach headers to the initial journey request.

Content Security

Bootstrap Script Injection

The bridge bootstrap script is injected in WebViewClient.onPageStarted via evaluateJavascript, on every main-frame page load. This is best-effort early injection: unlike iOS’s WKUserScript at-document-start guarantee, a <head> script that synchronously calls window.GBGBridge.receive could in principle race the injection. Journey pages tolerate this — the bootstrap only installs a no-op placeholder that the journey replaces. The default bootstrap script is minimal:
window.GBGBridge = window.GBGBridge || {};
window.GBGBridge.receive = window.GBGBridge.receive || function(){};

Custom Bootstrap Scripts

If you provide a custom bootstrapScript in BridgeConfiguration, ensure it does not:
  • Expose sensitive native data to the web context.
  • Define functions that bypass the structured message protocol.
  • Include inline secrets, tokens, or API keys.
// Good: Minimal configuration
val config = BridgeConfiguration(
  hostVersion = "1.0.0",
  capabilities = mapOf(/* ... */),
  bootstrapScript = """
    window.GBGBridge = window.GBGBridge || {};
    window.GBGBridge.receive = window.GBGBridge.receive || function(){};
    window.GBGBridge.config = { theme: 'dark' };
  """.trimIndent()
)

// Bad: Exposes secrets
val config = BridgeConfiguration(
  hostVersion = "1.0.0",
  capabilities = mapOf(/* ... */),
  bootstrapScript = """
    window.GBGBridge = window.GBGBridge || {};
    window.GBGBridge.apiKey = '$apiKey';  // DO NOT DO THIS
  """.trimIndent()
)
If you attach with a custom BootstrapInjectingWebViewClient subclass (see below), BridgeConfiguration.bootstrapScript is ignored — the subclass owns its script. Pass the script to the subclass constructor instead.
Deciding which URLs the WebView is allowed to load is your app’s job — the SDK deliberately does not impose a navigation policy. To restrict navigation, subclass BootstrapInjectingWebViewClient, override shouldOverrideUrlLoading, and pass your subclass via attach(webView, client = ...) so bootstrap injection survives:
class JourneyWebViewClient(bootstrapScript: String) :
  BootstrapInjectingWebViewClient(bootstrapScript) {

  override fun shouldOverrideUrlLoading(
    view: WebView,
    request: WebResourceRequest
  ): Boolean {
    val host = request.url.host ?: return true  // block hostless URLs
    val allowed = host == "app.example.com" || host.endsWith(".example.com")
    return !allowed  // returning true cancels the navigation
  }
}

host.attach(webView, client = JourneyWebViewClient(bootstrapScript))
This is the Android equivalent of iOS’s WKNavigationDelegate.webView(_:decidePolicyFor:decisionHandler:) — the primary, navigation-level gate on what content can ever reach the bridge.

Message-Level Origin Allowlist

On iOS, the bridge message handler is installed for the main frame only, so iframes can never reach it. Android’s addJavascriptInterface has no per-frame equivalent — the interface is injected into every frame in the WebView. In its place, the Android SDK offers an opt-in, message-level origin gate: BridgeConfiguration.allowedOrigins.
val config = BridgeConfiguration(
  hostVersion = "1.0.0",
  allowedOrigins = listOf("https://app.example.com")
)
When allowedOrigins is null (the default), no enforcement happens and your navigation policy is the only gate. When set, each inbound postMessage is checked against the normalized origin of the WebView’s current main-frame URL. On rejection the message is dropped, lastError is set, and delegate.onError fires with a SecurityException. Both your allowlist entries and the live URL are normalized to a scheme://host[:port] origin tuple before comparison:
  • Schemes are limited to http and https; anything else (file:, data:, javascript:, …) is rejected.
  • Scheme and host are compared case-insensitively, so HTTPS://APP.example.com/ matches https://app.example.com/journey/123.
  • Default ports are elided (https://app.example.com:443https://app.example.com); ports outside 1..65535 are rejected.
  • A single trailing dot on the host is stripped (app.example.com.app.example.com — the same DNS origin).
  • Hosts containing % are rejected (fail closed on percent-encoded host components and IPv6 zone identifiers).
  • Raw internationalized (non-ASCII) hostnames are rejected — use the punycode A-label form (xn--...). This forces homograph lookalikes to declare their punycode, making them visible in any review of your allowlist.
  • Paths, queries, and fragments are ignored; only the origin tuple is compared.
The configuration is validated at construction: BridgeConfiguration throws IllegalArgumentException if allowedOrigins is an empty list (a kill-switch with no opt-out — pass null to disable enforcement instead) or contains a malformed entry. Misconfigurations surface as a single construction-time failure rather than every message being silently dropped at runtime.
allowedOrigins is not a security boundary. Because addJavascriptInterface injects into every frame and the SDK cannot identify which frame called postMessage, the check compares against the main-frame URL only — a hostile sub-frame inside an allowlisted page can still drive the bridge. There is also a small window between a message being posted and the origin check running on the main thread, during which a same-session navigation is invisible to the gate. Treat allowedOrigins as defense-in-depth against misconfiguration (the wrong URL being loaded into the journey WebView), layered on top of navigation filtering via shouldOverrideUrlLoading — never as a substitute for it.

Message Validation

Incoming Message Decoding

BridgeHost decodes incoming messages using kotlinx.serialization. If a message doesn’t conform to the BridgeMessage structure, it is rejected, lastError is set, and delegate.onError is invoked with the decoding failure. Malformed messages never reach handlers.

Action Validation

Handlers are routed by exact string match on the action field. Register handlers only for actions you expect. Unexpected actions go to pendingRequests where you can inspect and respond to them.

Data Validation in Handlers

Always validate the data payload in your handlers. The web content is a less-trusted zone — treat incoming data the same way you would treat user input.
override fun handle(request: BridgeMessage, responder: BridgeResponder) {
  // Validate required fields
  val side = (request.payload.data?.get("side") as? JsonPrimitive)?.contentOrNull
  if (side !in listOf("front", "back")) {
    responder.respond(
      status = BridgeResponseStatus.ERROR,
      error = BridgeErrorPayload(
        code = "INVALID_PARAMS",
        message = "Missing or invalid 'side' parameter. Expected 'front' or 'back'.",
        recoverable = true
      )
    )
    return
  }

  // Proceed with validated data
  performCapture(side, responder)
}

Permission Management

Native Permissions

GBGBridge itself does not request any Android permissions. Your capability handlers are responsible for checking and requesting permissions as needed.
Check permission status before performing the operation, and return a clear error if permission is denied.
class DocumentCaptureHandler(
  private val context: Context
) : BridgeCapabilityHandler {

  override val action = "camera.document.capture"

  override fun handle(request: BridgeMessage, responder: BridgeResponder) {
    val granted = ContextCompat.checkSelfPermission(
      context, Manifest.permission.CAMERA
    ) == PackageManager.PERMISSION_GRANTED

    if (!granted) {
      responder.respond(
        status = BridgeResponseStatus.ERROR,
        error = BridgeErrorPayload(
          code = "CAMERA_DENIED",
          message = "Camera access is not available. Please enable it in Settings.",
          recoverable = true
        )
      )
      return
    }

    performCapture(responder)
  }
}
Because handle is synchronous, you cannot show the runtime permission dialog inline. To request permission as part of the capture flow, retain the responder, launch the request from your UI (for example with ActivityResultContracts.RequestPermission), and call responder.respond(...) on the main thread once the result arrives. The SDK’s CameraDetector.check(context) gives you a quick snapshot of camera hardware availability and permission state — note that it reports only GRANTED or NOT_DETERMINED, because Android cannot distinguish “never asked” from “permanently denied” without app-side state; set the richer DENIED/RESTRICTED states on your capture capability after running your own permission flow.

Manifest Requirements

Declare every permission your handlers might use. Android’s equivalent of a missing iOS usage description is quieter but just as fatal: requesting a runtime permission that is not declared in the manifest is automatically denied without ever showing the user a dialog.
CapabilityManifest entry
Journey loading<uses-permission android:name="android.permission.INTERNET" />
Camera capture<uses-permission android:name="android.permission.CAMERA" />
If you host camera capture, also declare the hardware feature as optional so devices without a camera can still install your app:
<uses-feature android:name="android.hardware.camera" android:required="false" />
The system photo picker (ActivityResultContracts.PickVisualMedia) requires no permission at all, which makes it a low-friction capture fallback.

Production Checklist

Before shipping your integration:
  • HTTPS only — Journey URL uses HTTPS. No unscoped cleartext exceptions; debug-only network security config overrides live in the debug source set.
  • No secrets in bootstrap scripts — Custom bootstrap scripts contain no API keys or tokens.
  • Input validation — All handlers validate incoming data payloads.
  • Permission handling — Handlers check and request permissions before accessing protected resources.
  • Manifest entries — All permissions handlers might request are declared.
  • Error information — Error responses do not leak internal implementation details.
  • Navigation policyshouldOverrideUrlLoading restricts the WebView to trusted origins; allowedOrigins considered as a defense-in-depth layer.
  • SSL errors fail closed — No handler.proceed() in onReceivedSslError.
  • Debugging disabled in releaseWebView.setWebContentsDebuggingEnabled(true) is gated on FLAG_DEBUGGABLE (or debug builds only).
  • Certificate pinning — Evaluated whether certificate pinning is required for your security posture.

Next Steps