Display sensitive card details in native WebViews
Cards is in beta and may change as we continue to improve it. Please review the documentation and endpoints carefully, test everything in the sandbox, and reach out to the Solutions team if you need help.
You can display sensitive card details (PAN, CVC, expiry date, cardholder name) inside a native mobile app by loading the BVNK card-details web app in an Android WebView or iOS WKWebView. The web app renders the data inside the WebView. Your native code never accesses the sensitive values directly.
Prerequisites
Before you start, ensure the following:
- Vault URL for your environment provided by BVNK. One URL per environment:
- Sandbox
https://card-details.sandbox.bvnk.com - Production
https://card-details.bvnk.com
- Sandbox
- Access-token endpoint provided by BVNK. See Request an access token for the instructions on how to get one.
- Minimum OS versions:
- iOS 11+ (
WKWebViewwith script message handlers) - Android API 24+ recommended (
WebMessageListener);addJavascriptInterfaceworks on older versions but lacks theallowedOriginRulesallow-list
- iOS 11+ (
- Fresh token per load. Call the access-token endpoint every time the user opens the card-details screen. Tokens are short-lived and single-use. Do not cache or reuse them.
- Step-up authentication. Your app must require strong authentication (2FA or step-up auth) before it can request the token.
If you're integrating from React Native, the same protocol applies. Wire the bridge through react-native-webview's onMessage (for outgoing messages) and injectJavaScript (for incoming messages). The native code in this guide is what react-native-webview runs under the hood.
Integration overview
Your native shell communicates with the BVNK vault web app through a JavaScript bridge using a simple request-response protocol:
The same five steps apply on both iOS and Android; only the platform API differs.
- Load the vault URL into the WebView. Use HTTPS only. Optionally append
?host=webview-iosor?host=webview-androidto force the transport. This is useful for testing; in production the page auto-detects the bridge. - Install the JS bridge so the page can post messages out and your shell can inject messages in:
- iOS: add a
WKScriptMessageHandlernamedsensitiveCardDetailsto theWKUserContentController, and callevaluateJavaScriptto invokewindow.__sensitiveCardDetailsInbox(...). - Android: add a
@JavascriptInterfaceobject namedSensitiveCardDetailsBridgewith apostMessage(String)method, and callevaluateJavascriptto invokewindow.__sensitiveCardDetailsInbox(...).
- iOS: add a
- Wait for
HOST_READYfrom the page. This is the page's signal that it has booted and is ready to accept a token. Do not send the token before this. - Mint a token from your backend, then send
INIT_WITH_TOKENwith the token and a freshrequestId. Tokens are short-lived and single-use. Fetch a new one for every WebView load. Do not cache or reuse tokens. - Handle
RENDER_COMPLETEorERRORfrom the page:RENDER_COMPLETE— card details are visible. You may dismiss any loading UI in your shell.ERROR— terminal failure. Readpayload.code(see Error codes) and surface an appropriate message; do not retry by re-sendingINIT_WITH_TOKEN— close the WebView, mint a fresh token, and reopen.
Always reject any RENDER_COMPLETE or ERROR message whose requestId does not match the one you sent.
On teardown—when the user dismisses the screen, navigates away, or the app backgrounds for long enough—clear cookies, localStorage, and HTTP cache from the WebView.
Request an access token
From your backend, send the POST /card/v1/card-details-token request with the card ID and customer ID.
{
"cardId": "123e4567-e89b-12d3-a456-426614174000",
"customerId": "4f2a76a4-0954-4999-b555-f9f2bec78c50"
}
BVNK validates card ownership and returns a one-time, short-lived token:
{
"token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
"tokenType": "Bearer",
"expiresIn": 60
}
The token expires after the number of seconds specified in expiresIn. Pass this token to your native shell before sending INIT_WITH_TOKEN.
Set up the WebView and handle messages
Choose your platform below. Each example implements all five integration steps: loading the vault URL, installing the JS bridge, handling HOST_READY, sending the token, and processing the response.
- iOS (WKWebView)
- Android (WebView)
The web app expects:
- An outgoing message handler named
sensitiveCardDetails(window.webkit.messageHandlers.sensitiveCardDetails.postMessage(jsonString)). - An inbox function
window.__sensitiveCardDetailsInbox(rawMessage)that your native shell calls viaevaluateJavaScriptto deliver messages.
import WebKit
import UIKit
final class CardDetailsWebViewController: UIViewController,
WKScriptMessageHandler, WKNavigationDelegate {
private var webView: WKWebView!
private let vaultURL = URL(string: "https://card-details.bvnk.com/?host=webview-ios")!
private var requestId: String?
private var captureObserver: NSObjectProtocol?
override func viewDidLoad() {
super.viewDidLoad()
let config = WKWebViewConfiguration()
config.userContentController.add(self, name: "sensitiveCardDetails")
config.preferences.javaScriptCanOpenWindowsAutomatically = false
config.websiteDataStore = .nonPersistent()
webView = WKWebView(frame: view.bounds, configuration: config)
webView.navigationDelegate = self
view.addSubview(webView)
observeScreenCapture()
webView.load(URLRequest(url: vaultURL))
}
// MARK: - Outgoing: web → native
func userContentController(_ ucc: WKUserContentController,
didReceive msg: WKScriptMessage) {
guard let raw = msg.body as? String,
let data = raw.data(using: .utf8) else { return }
guard let envelope = try? JSONSerialization.jsonObject(with: data)
as? [String: Any],
let type = envelope["type"] as? String,
(envelope["protocolVersion"] as? Int) == 1 else { return }
let payload = envelope["payload"] as? [String: Any]
let echoedId = payload?["requestId"] as? String
switch type {
case "HOST_READY":
Task { await sendToken() }
case "RENDER_COMPLETE":
guard echoedId == requestId else { return }
handleSuccess()
case "ERROR":
guard echoedId == requestId else { return }
handleError(payload)
default: break
}
}
// MARK: - Incoming: native → web
private func sendToken() async {
do {
let token = try await fetchAccessTokenFromBackend()
let id = UUID().uuidString
self.requestId = id
let envelope: [String: Any] = [
"type": "INIT_WITH_TOKEN",
"protocolVersion": 1,
"payload": ["accessToken": token, "requestId": id],
]
let json = String(
data: try JSONSerialization.data(withJSONObject: envelope),
encoding: .utf8
)!
let escaped = json
.replacingOccurrences(of: "\\", with: "\\\\")
.replacingOccurrences(of: "'", with: "\\'")
webView.evaluateJavaScript(
"window.__sensitiveCardDetailsInbox && " +
"window.__sensitiveCardDetailsInbox('\(escaped)')"
)
} catch {
// Surface to the user and close the WebView.
}
}
// MARK: - Navigation lock-down
func webView(_ webView: WKWebView,
decidePolicyFor navigationAction: WKNavigationAction,
decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
guard let url = navigationAction.request.url,
url.host == vaultURL.host, url.scheme == "https" else {
decisionHandler(.cancel)
return
}
decisionHandler(.allow)
}
// MARK: - Screen-capture protection
private func observeScreenCapture() {
captureObserver = NotificationCenter.default.addObserver(
forName: UIScreen.capturedDidChangeNotification,
object: nil, queue: .main
) { [weak self] _ in
self?.webView.isHidden = UIScreen.main.isCaptured
}
}
// MARK: - Teardown
deinit {
if let obs = captureObserver {
NotificationCenter.default.removeObserver(obs)
}
WKWebsiteDataStore.default().removeData(
ofTypes: WKWebsiteDataStore.allWebsiteDataTypes(),
modifiedSince: .distantPast,
completionHandler: {}
)
}
}
Message protocol reference
Every message is a JSON object with the following envelope:
{
type: 'HOST_READY' | 'INIT_WITH_TOKEN' | 'RENDER_COMPLETE' | 'ERROR',
protocolVersion: 1,
payload?: { ... }
}
Reject any message whose protocolVersion doesn't match 1.
Messages your native shell sends
type | Payload | When to send |
|---|---|---|
INIT_WITH_TOKEN | { accessToken: string, requestId?: string } | After receiving HOST_READY and obtaining a fresh access token. |
{
"type": "INIT_WITH_TOKEN",
"protocolVersion": 1,
"payload": {
"accessToken": "eyJhbGciOi...",
"requestId": "01JC8E3..."
}
}
Send exactly once per WebView load. The requestId is optional but recommended — it correlates the response with the request and lets you ignore stale replies.
Messages the web app sends
type | Payload | Meaning |
|---|---|---|
HOST_READY | none | Web app has booted and is ready to accept a token. Always the first message. |
RENDER_COMPLETE | { requestId?: string } | Card details fetched, decrypted, and rendered. |
ERROR | { code: string, message: string, requestId?: string } | Terminal failure. |
Error codes
| Code | Meaning |
|---|---|
HANDSHAKE_TIMEOUT | Web app didn't receive INIT_WITH_TOKEN in time. |
INVALID_MESSAGE | A message didn't match the expected protocol schema. |
TOKEN_REJECTED | API rejected the token (expired, revoked, scope mismatch). |
RENDER_FAILED | Vault failed to fetch or decrypt card details. |
NO_PARENT_FRAME | Web app couldn't detect any host (no bridge or iframe). |
Treat any unrecognised code as a generic terminal error.
Security checklist
The browser same-origin policy that protects the iframe transport does not apply in a WebView. The native shell becomes the trust boundary. You must enforce:
| Requirement | Details |
|---|---|
| Origin pinning | Load the web app over HTTPS from a fixed vault origin only. Never from file://. On Android: allowFileAccess = false, allowContentAccess = false. |
| Bridge surface | Expose only the single postMessage method. Prefer WebMessageListener with allowedOriginRules on Android API 24+. |
| Token in transit | Deliver the token only via the inbox function after HOST_READY. Never via URL parameter, header, or cookie. |
| Token at rest | Clear cookies, localStorage, and HTTP cache on teardown. |
| requestId correlation | Send a fresh requestId with every INIT_WITH_TOKEN. Reject any response whose requestId doesn't match. |
| Schema validation | Validate type and protocolVersion on every incoming envelope. |
| Screen capture | Android: FLAG_SECURE. iOS: observe UIScreen.capturedDidChangeNotification and hide content while captured. |
| Autofill / form data | Disable on the WebView. Android: saveFormData = false, savePassword = false. |
| Navigation lock-down | Reject any navigation away from the vault origin so the bridge can never be reached from arbitrary pages. |
- Your application must require strong authentication (step-up auth or 2FA) before requesting the access token.
- Sensitive card values never leave the vault origin — they are decrypted and rendered inside the WebView, never exposed to your native code.
- You must confirm that you operate in a PCI-compliant manner.