Provides instructions on how to effectively protect Android WebView using the Approov SDK, including Approov Dynamic Pinning.
The quickstart is designed for WebView apps that need Approov protection on:
fetch(...)XMLHttpRequest- current-frame HTML form submission
The demo page calls https://shapes.approov.io/v2/shapes, which also requires the API key yXClypapWNHIifHUWmBIyPFAm. The API key is injected natively so the page never needs to know it.
Android WebView does not provide a supported API for mutating headers on arbitrary built-in https:// page requests before they leave the WebView networking stack.
Because of that, the safe approach is:
- Inject a JavaScript bridge at document start.
- Intercept Fetch, XHR, and current-frame form submission inside the page.
- Forward those requests into native Java with
WebViewCompat.addWebMessageListener(...). - Sync WebView cookies into native OkHttp before the request is sent.
- Ask Approov for a JWT in native code through the Approov OkHttp wrapper.
- Add the
approov-tokenheader in native code. - Inject any native-only secrets, such as API keys, in native code.
- Execute the request natively with an Approov-protected
OkHttpClient. - Let Approov dynamic pinning validate the TLS connection.
- Sync response cookies back into the WebView cookie store.
- Return the response back to JavaScript or, for form navigations, render the response back into the current document.
flowchart LR
subgraph Web["Trusted WebView Page"]
Page["HTML / app JavaScript"]
Bridge["approov-webview-bridge.js"]
Render["Current WebView document"]
end
subgraph Native["Android Native Layer"]
Listener["WebViewCompat.addWebMessageListener(...)"]
Support["ApproovWebViewSupport"]
CookieSync["Cookie sync with CookieManager"]
Client["Approov-protected OkHttpClient"]
end
subgraph Remote["Remote Services"]
Approov["Approov SDK integration"]
API["Protected API"]
end
Page -->|"fetch / XHR / form submit"| Bridge
Bridge -->|"JSON request envelope"| Listener
Listener --> Support
Support -->|"copy cookies into native request"| CookieSync
CookieSync --> Client
Support -->|"inject secret headers"| Client
Client -->|"request Approov token"| Approov
Approov -->|"approov-token + dynamic pinning"| Client
Client -->|"HTTPS request"| API
API -->|"HTTP response + Set-Cookie"| Client
Client --> CookieSync
Support -->|"JSON response envelope"| Bridge
Bridge -->|"Response object for fetch / XHR"| Page
Support -->|"HTML body for same-frame form submit"| Bridge
Bridge -->|"document.open/write/close"| Render
Render -->|"updated content visible in WebView"| Page
| Approach | What it does well | Main downside | Best fit |
|---|---|---|---|
Current approach: document-start JS interception + scoped WebMessage bridge + native OkHttp |
Works with public Android APIs, keeps secrets native, supports fetch, XHR, and same-frame forms |
Does not transparently cover every browser network primitive | Hybrid apps where page code owns protected API traffic |
WebViewClient.shouldInterceptRequest(...) proxying |
Useful for asset serving, custom responses, and some inspection | Not a reliable way to mutate arbitrary outgoing headers before the WebView stack sends them (see more below) | Static assets, offline content, or narrow resource interception |
addJavascriptInterface(...) bridge |
Simpler to wire up on older examples | Larger attack surface and weaker trust scoping than origin-scoped WebMessage listeners | Legacy compatibility only |
| Same-origin backend/BFF or reverse proxy | Preserves normal browser behavior and covers subresources, redirects, Service Workers, and WebSockets better | Requires server-side infrastructure and operational ownership | Apps that need full browser semantics for protected traffic |
| Native UI + native networking only | Strongest control over networking and security policy | Gives up most WebView reuse and increases rewrite cost | Products that are primarily native rather than hybrid |
WebViewClient.shouldInterceptRequest(...) is a response replacement hook, not a request mutation hook.
- the callback gives us request metadata such as URL, method, and headers, but not a general way to edit the request and let the WebView continue sending it with extra headers
- if we need to add
approov-tokenor an API key, you usually end up re-executing the request yourself in native code and returning a syntheticWebResourceResponse WebResourceRequestdoes not expose a general request body, so faithfully replaying arbitrary POST-style traffic is already incomplete..shouldInterceptRequest(...)is only called for the initial URL in a redirect chain, whileWebResourceResponsedoes not support causing a redirect with a3xxstatus code- Service Worker traffic uses a separate API surface (
ServiceWorkerController/ServiceWorkerClient), so one hook does not cover all browser-managed networking
If we proxy the request ourself, we are no longer "adding a header to the browser request". We are implementing a parallel browser transport and trying to map its result back into WebView. Browser semantics such as redirects, streaming, progress, cancellation, cookie behavior, and some scheme-specific behavior stop being native WebView behavior unless we would re-create them ourselfes but this seem to be a complex and prone to errors.
The reusable bridge in app/src/main/java/approov/io/webviewjava/approovwebview/ApproovWebViewSupport.java and app/src/main/assets/approov-webview-bridge.js covers:
fetchXMLHttpRequestform.submit()- user-driven HTML form submission
- cookie synchronization between
CookieManagerand native OkHttp - dynamic pinning
- native-only secret header injection
- local HTTPS content through
WebViewAssetLoader
That makes it suitable for the common WebView app pattern where:
- the UI lives in web content
- protected business APIs are called with Fetch/XHR
- some flows still rely on standard HTML forms
Even with a strong bridge, public Android WebView APIs still do not let an app transparently mutate headers on:
- arbitrary
<img>requests - arbitrary
<script>requests - arbitrary
<iframe>resource requests - arbitrary CSS subresource requests
- WebSockets
- Service Worker networking
- forms targeting another window or named frame
- multipart file-upload form submissions
- every browser semantic detail such as Fetch abort signals and XHR progress streaming
The safest production patterns are:
- keep protected API traffic on Fetch, XHR, or current-frame form submission
- keep static assets and page documents unprotected by Approov if they must be loaded by WebView directly
- or route protected browser traffic through a same-origin backend/BFF that your WebView can call normally
app/src/main/java/approov/io/webviewjava/approovwebview/ApproovWebViewSupport.java- Reusable bridge code.
- This is the main file to copy into another app.
app/src/main/java/approov/io/webviewjava/approovwebview/ApproovWebViewConfig.java- Reusable bridge configuration.
app/src/main/java/approov/io/webviewjava/approovwebview/ApproovWebViewSecretHeader.java- Reusable native-only secret header rules.
app/src/main/java/approov/io/webviewjava/WebViewJavaApplication.java- Demo-specific configuration.
- This is the file most adopters should edit first.
app/src/main/java/approov/io/webviewjava/MainActivity.java- Minimal Android host activity.
app/src/main/assets/index.html- Local demo page loaded into the WebView.
- Demonstrates both
fetch()and real HTML form submission.
app/src/main/assets/approov-webview-bridge.js- Document-start JavaScript bridge injected into trusted pages.
The bridge now intercepts:
- user form submission
form.submit()
For standard same-frame form navigations, the native layer executes the request and then writes the response HTML back into the current document.
Forms targeting another frame or window are intentionally left on the normal WebView path.
Multipart file uploads are also intentionally left on the normal WebView path.
This sample uses:
ApproovService.getOkHttpClient()
That client includes:
- Approov token injection
- Approov dynamic pinning
The relevant request execution path is in:
ApproovWebViewSupport.executeRequest(...)
The native proxy mirrors cookies between:
- WebView's cookie store
- native OkHttp
That is important for login, session, and CSRF-sensitive flows once requests leave WebView and run through native networking.
This sample defaults to fail-open behavior:
- if Approov cannot initialize, the request can still proceed without
approov-token - if Approov networking fails after initialization,
ApproovService.setProceedOnNetworkFail(true)allows the request to proceed
That behavior is controlled by:
setAllowRequestsWithoutApproov(...)inApproovWebViewConfig.Builder
For a stricter production deployment, set it to false.
The project builds as-is, but Approov will only issue valid JWTs when the account and app are configured correctly.
- Add the protected API domain to Approov.
approov api -add shapes.approov.io- Ensure the backend validates Approov tokens.
If you adapt this quickstart to your own backend, the server must validate the JWT presented on the approov-token header.
-
Register the Android app in Approov and obtain the initial configuration string.
-
If you are testing on an emulator or other development device, create and configure an Approov development key if required by your account setup.
-
Replace the demo values in:
app/build.gradle.ktsapp/src/main/java/approov/io/webviewjava/WebViewJavaApplication.java
In particular:
approov.configapproov.devKeyshapes.apiKeyBuildConfig.SHAPES_API_URL- secret-header matching rules
- allowed origin rules
This project uses:
io.approov:service.okhttpcom.squareup.okhttp3:okhttpandroidx.webkit:webkit
Copy:
app/src/main/java/approov/io/webviewjava/approovwebview/ApproovWebViewSupport.javaapp/src/main/java/approov/io/webviewjava/approovwebview/ApproovWebViewConfig.javaapp/src/main/java/approov/io/webviewjava/approovwebview/ApproovWebViewSecretHeader.javaapp/src/main/assets/approov-webview-bridge.js
Example:
ApproovWebViewConfig config = new ApproovWebViewConfig.Builder(BuildConfig.APPROOV_CONFIG)
.setApproovDevKey(BuildConfig.APPROOV_DEV_KEY)
.setApproovTokenHeaderName("approov-token")
.setAllowRequestsWithoutApproov(true)
.addAllowedOriginRule(ApproovWebViewSupport.LOCAL_ASSET_ORIGIN)
.addAllowedOriginRule("https://your-web-app.example.com")
.addSecretHeader(new ApproovWebViewSecretHeader(
"api.example.com",
"/v1/",
"x-api-key",
BuildConfig.EXAMPLE_API_KEY
))
.build();
ApproovWebViewSupport.initialize(this, config);ApproovWebViewSupport approovWebViewSupport = ApproovWebViewSupport.getInstance();
approovWebViewSupport.configureWebView(webView);
webView.setWebViewClient(approovWebViewSupport.buildWebViewClient(null));
webView.loadUrl(approovWebViewSupport.getAssetUrl("index.html"));Or load a trusted remote page:
ApproovWebViewSupport approovWebViewSupport = ApproovWebViewSupport.getInstance();
approovWebViewSupport.configureWebView(webView);
webView.setWebViewClient(approovWebViewSupport.buildWebViewClient(null));
webView.loadUrl("https://your-web-app.example.com");- Prefer a strict allowlist in
addAllowedOriginRule(...). - Keep native-only secrets in
ApproovWebViewSecretHeader, never in page JavaScript. - Keep protected endpoints on Fetch, XHR, or current-frame form submission.
- Keep cookie synchronization enabled if your app depends on authenticated browser state.
- Keep local demo pages on
https://appassets.androidplatform.netinstead offile:///android_asset/.... - Set
setAllowRequestsWithoutApproov(false)for production unless you intentionally need fail-open behavior.
The project builds with Java 21:
export JAVA_HOME=$(/usr/libexec/java_home -v 21)
export PATH="$JAVA_HOME/bin:$PATH"
./gradlew assembleDebugThe output APK is:
app/build/outputs/apk/debug/app-debug.apk