Provides instructions on how to effectively protect 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
It also keeps browser cookies aligned between WKWebView and the native URLSession used to execute protected requests, which is critical for login, session, and CSRF flows.
WKWebView does not provide a supported API for mutating headers on arbitrary built-in https:// page requests before they leave WebKit's networking process.
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 Swift with
WKScriptMessageHandlerWithReply. - Sync WebKit cookies into native networking.
- Ask Approov for a JWT in native code.
- Add the
approov-tokenheader in Swift. - Inject any native-only secrets, such as API keys, in Swift.
- Execute the request natively.
- Return the response back to JavaScript or, for form navigations, load the native response into the WebView with
loadSimulatedRequest(...).
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.
flowchart LR
subgraph WebView["WebView / Page Layer"]
User["User"]
Page["Web app code (js)<br/>fetch / XHR / form submit"]
Bridge["Injected JS bridge (js)<br/>serialize request + postMessage(...)"]
User -->|"Taps a button or submits a form"| Page
Page -->|"Uses normal browser APIs for data access"| Bridge
end
subgraph Native["Native iOS Layer"]
Coordinator["WKScriptMessageHandlerWithReply (swift)"]
Executor["ApproovWebViewRequestExecutor actor (swift)"]
Cookies["WKHTTPCookieStore + HTTPCookieStorage (swift/WebKit)"]
Mutate["mutateRequest(...) (swift)<br/>native-only headers / API keys"]
Token["ApproovService.fetchToken(...) (swift)"]
Pinning["Approov dynamic pinning (swift)<br/>enabled per request inside ApproovURLSession"]
Session["ApproovURLSession.dataTask(...) (swift)"]
Coordinator -->|"Decodes the JS payload and calls execute(proxyRequest)"| Executor
Executor -->|"Reads WebView cookies before the native call"| Cookies
Cookies -->|"Returns browser cookies for session continuity"| Executor
Executor -->|"Applies headers and secrets"| Mutate
Mutate -->|"Returns the updated URLRequest"| Executor
Executor -->|"If protected, asks Approov for a token"| Token
Token -->|"Returns an Approov token; if present, the request is marked for pinning"| Executor
Executor -->|"Passes the final request into the protected transport layer"| Pinning
Pinning -->|"ApproovURLSession applies TLS pinning during the HTTPS request"| Session
Session -->|"Returns status/headers/body/response cookies"| Executor
Executor -->|"Writes response cookies back into WebKit"| Cookies
Executor -->|"Builds either a proxy response or a navigation load"| Coordinator
end
subgraph Backend["Backend Layer"]
API["Protected API (server)<br/>validates token and returns response"]
end
Bridge -->|"Forwards URL, method, headers, body, and page URL to native"| Coordinator
Session -->|"HTTPS request to the protected backend"| API
API -->|"HTTP response and any Set-Cookie headers"| Session
Coordinator --> Reply["JS Response object / XHR events (js)<br/>or loadSimulatedRequest(...) (swift)"]
Reply -->|"Delivers data back to the page or loads a new document"| Page
sequenceDiagram
autonumber
actor User
participant Page as Web Page (js)
participant JSBridge as Injected JS Bridge (js)
participant Coordinator as WKScriptMessageHandlerWithReply (swift)
participant Executor as ApproovWebViewRequestExecutor actor (swift)
participant Cookies as WKHTTPCookieStore (swift/WebKit)
participant Approov as ApproovService SDK (swift)
participant Session as ApproovURLSession (swift)
participant API as Protected API (server)
User->>Page: Trigger fetch(...) (js), XHR.send(...) (js), or form submit (js)
Page->>JSBridge: serializeRequest(...) (js) and body -> base64
alt Request is not HTTP(S)
JSBridge->>Page: originalFetch(...) / native XHR fallback (js)
else Request is HTTP(S)
JSBridge->>Coordinator: postMessage(payload) (js)
Coordinator->>Executor: execute(proxyRequest) (swift)
Executor->>Cookies: allCookies() (swift)
Cookies-->>Executor: Cookies
Executor->>Executor: Copy cookies into native HTTPCookieStorage
Executor->>Executor: Build URLRequest + apply browser context headers
Executor->>Executor: mutateRequest(...) (swift) for native-only headers
alt shouldAttemptApproovProtection(url) == true
Executor->>Approov: initialize(config:) (swift) if needed
Executor->>Approov: fetchToken(url:) (swift)
alt Token returned
Approov-->>Executor: JWT
Executor->>Executor: Set approov-token header
Executor->>Executor: Enable per-request dynamic pinning
else Token missing or fetch failed
alt allowRequestsWithoutApproovToken == false
Executor-->>Coordinator: Throw error
Coordinator-->>JSBridge: reply(error)
JSBridge-->>Page: Promise/XHR/form error path
else allowRequestsWithoutApproovToken == true
Executor->>Executor: Continue without JWT (no pinning)
end
end
else shouldAttemptApproovProtection(url) == false
Executor->>Executor: Skip token and pinning
end
Executor->>Session: dataTask(with:) (swift)
Session->>API: HTTPS request (optional Approov JWT)
API-->>Session: Response + Set-Cookie
Session-->>Executor: Data + HTTPURLResponse
Executor->>Cookies: setCookies(...) (swift)
alt responseHandling == "response" (fetch/XHR or form response mode)
Executor-->>Coordinator: status + headers + bodyBase64
Coordinator-->>JSBridge: reply(proxyResponse)
JSBridge-->>Page: new Response(...) (js) / XHR state updates (js) / dispatch approov:form-response (js)
else responseHandling == "navigation" (same-frame form navigation)
Executor-->>Coordinator: navigation load payload
Coordinator->>Page: webView.loadSimulatedRequest(...) (swift)
Coordinator-->>JSBridge: reply({navigationStarted: true})
end
end
The reusable bridge in WebViewShapes/ApproovWebViewBridge.swift covers:
fetchXMLHttpRequestform.submit()- user-driven HTML form submission
requestSubmit()flows that eventually produce a normal submit event- cookie synchronization between
WKHTTPCookieStoreand nativeURLSession - simulated current-frame navigations for form responses using
WKWebView.loadSimulatedRequest(...) - dynamic pinning
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
This quickstart intentionally does not claim impossible coverage.
Even with a strong bridge, public WebKit 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
- 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 WebKit directly
- or route protected browser traffic through a same-origin backend/BFF that your WebView can call normally
WebViewShapes/ApproovWebViewBridge.swift- Reusable bridge code.
- Includes both the SwiftUI
ApproovWebViewwrapper and the UIKitApproovWebViewController. - This is the file to copy into another app.
WebViewShapes/QuickstartConfiguration.swift- Demo-specific configuration.
- This is the file most adopters should edit first.
WebViewShapes/ShapesQuickstartPage.swift- Local demo HTML loaded into the WebView.
- Demonstrates both
fetch()and real HTML form submission.
WebViewShapes/ContentView.swift- Demo chooser that lets this one sample app present both the SwiftUI and UIKit host variants.
JS_BRIDGE_DESIGN.md- Detailed design walkthrough of bridge injection, interception behavior, and JS/native forwarding.
This sample now demonstrates both host styles in one app:
ApproovWebView- SwiftUI wrapper around the protected
WKWebView
- SwiftUI wrapper around the protected
ApproovWebViewController- UIKit
UIViewControllerthat hosts the same protectedWKWebView
- UIKit
If you only want one style in your own app:
- SwiftUI-only app
- Keep
ApproovWebView - Delete
ApproovWebViewController - In
ContentView.swift, comment out the UIKit demo case
- Keep
- UIKit-only app
- Keep
ApproovWebViewController - Delete
ApproovWebViewandimport SwiftUIfromApproovWebViewBridge.swift - In
ContentView.swift, comment out the SwiftUI demo case
- Keep
The native proxy now mirrors cookies between:
WKWebsiteDataStore.httpCookieStore- native
URLSession
Without that, many login and session flows break as soon as a request moves out of WebKit and into native networking.
The bridge now intercepts:
- user form submission
form.submit()requestSubmit()
For standard same-frame form navigations, native Swift fetches the response and loads it back into the WebView using loadSimulatedRequest(...).
For forms that should behave more like AJAX and stay on the current page, add:
<form data-approov-submit-mode="response">That makes the form dispatch:
approov:form-responseapproov:form-error
instead of navigating away.
This sample currently keeps the earlier requested fail-open behavior:
- if Approov cannot produce a JWT, the request can still proceed without
approov-token
That is controlled by:
allowRequestsWithoutApproovTokeninQuickstartConfiguration.swift
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 iOS app using Approov CLI
-
Test on a real device or configure a simulator/development path in your Approov account.
-
Replace the demo values in
QuickstartConfiguration.swift:
approovConfigshapesEndpointshouldAttemptApproovProtectionmutateRequest
This project uses:
https://github.com/approov/approov-service-urlsession.git
That package brings in the Approov iOS SDK used by the bridge.
Copy:
WebViewShapes/ApproovWebViewBridge.swift
Example:
let config = ApproovWebViewConfiguration(
approovConfig: "<your-approov-config>",
approovTokenHeaderName: "approov-token",
allowRequestsWithoutApproovToken: false,
shouldAttemptApproovProtection: { url in
url.host?.lowercased() == "api.example.com"
},
mutateRequest: { request in
var request = request
if request.url?.host?.lowercased() == "api.example.com" {
request.setValue("native-only-secret", forHTTPHeaderField: "Api-Key")
}
return request
}
)ApproovWebView(
content: .request(URLRequest(url: URL(string: "https://your-web-app.example")!)),
configuration: config
)Or load local HTML:
ApproovWebView(
content: .htmlString(htmlString, baseURL: nil),
configuration: config
)- Prefer a strict allowlist in
shouldAttemptApproovProtection. - Keep native-only secrets in
mutateRequest, never in page JavaScript. - Keep protected endpoints on Fetch, XHR, or current-frame form submission.
- Keep the WebView on the default website data store unless you have a strong reason to isolate cookies.
- Set
allowRequestsWithoutApproovTokentofalsefor production unless you intentionally need fail-open behavior. - If you fully own the web app, consider App-Bound Domains as an additional hardening layer for navigation scope.