Skip to main content

Display sensitive card details in native WebViews

Beta

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
  • Access-token endpoint provided by BVNK. See Request an access token for the instructions on how to get one.
  • Minimum OS versions:
    • iOS 11+ (WKWebView with script message handlers)
    • Android API 24+ recommended (WebMessageListener); addJavascriptInterface works on older versions but lacks the allowedOriginRules allow-list
  • 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.
React Native

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.

  1. Load the vault URL into the WebView. Use HTTPS only. Optionally append ?host=webview-ios or ?host=webview-android to force the transport. This is useful for testing; in production the page auto-detects the bridge.
  2. Install the JS bridge so the page can post messages out and your shell can inject messages in:
    • iOS: add a WKScriptMessageHandler named sensitiveCardDetails to the WKUserContentController, and call evaluateJavaScript to invoke window.__sensitiveCardDetailsInbox(...).
    • Android: add a @JavascriptInterface object named SensitiveCardDetailsBridge with a postMessage(String) method, and call evaluateJavascript to invoke window.__sensitiveCardDetailsInbox(...).
  3. Wait for HOST_READY from 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.
  4. Mint a token from your backend, then send INIT_WITH_TOKEN with the token and a fresh requestId. Tokens are short-lived and single-use. Fetch a new one for every WebView load. Do not cache or reuse tokens.
  5. Handle RENDER_COMPLETE or ERROR from the page:
    • RENDER_COMPLETE — card details are visible. You may dismiss any loading UI in your shell.
    • ERROR — terminal failure. Read payload.code (see Error codes) and surface an appropriate message; do not retry by re-sending INIT_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.

POST /card/v1/card-details-token
{
"cardId": "123e4567-e89b-12d3-a456-426614174000",
"customerId": "4f2a76a4-0954-4999-b555-f9f2bec78c50"
}

BVNK validates card ownership and returns a one-time, short-lived token:

Response
{
"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.

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 via evaluateJavaScript to 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

typePayloadWhen to send
INIT_WITH_TOKEN{ accessToken: string, requestId?: string }After receiving HOST_READY and obtaining a fresh access token.
INIT_WITH_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

typePayloadMeaning
HOST_READYnoneWeb 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

CodeMeaning
HANDSHAKE_TIMEOUTWeb app didn't receive INIT_WITH_TOKEN in time.
INVALID_MESSAGEA message didn't match the expected protocol schema.
TOKEN_REJECTEDAPI rejected the token (expired, revoked, scope mismatch).
RENDER_FAILEDVault failed to fetch or decrypt card details.
NO_PARENT_FRAMEWeb 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:

RequirementDetails
Origin pinningLoad the web app over HTTPS from a fixed vault origin only. Never from file://. On Android: allowFileAccess = false, allowContentAccess = false.
Bridge surfaceExpose only the single postMessage method. Prefer WebMessageListener with allowedOriginRules on Android API 24+.
Token in transitDeliver the token only via the inbox function after HOST_READY. Never via URL parameter, header, or cookie.
Token at restClear cookies, localStorage, and HTTP cache on teardown.
requestId correlationSend a fresh requestId with every INIT_WITH_TOKEN. Reject any response whose requestId doesn't match.
Schema validationValidate type and protocolVersion on every incoming envelope.
Screen captureAndroid: FLAG_SECURE. iOS: observe UIScreen.capturedDidChangeNotification and hide content while captured.
Autofill / form dataDisable on the WebView. Android: saveFormData = false, savePassword = false.
Navigation lock-downReject any navigation away from the vault origin so the bridge can never be reached from arbitrary pages.
Security requirements
  • 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.
Was this page helpful?