Mobile security
A copy-paste checklist for hardening a native mobile player: what each control is, why it matters, and the concrete Android/Kotlin and iOS/Swift APIs that implement it. None of this is built into vdoX (it's native app code you own), and none of it is bulletproof: treat every item here as raising the cost of casual sharing, not as DRM.
This guide assumes the integration in Mobile: a WebView embed or a native HLS player fed a short-lived, per-viewer playback token that your own backend mints (your vdoX API key never reaches the app). Everything below layers on top of that.
Authentication and secure storage
What: keep the playback token (and any refresh/session credential) out of plain-text storage. On Android that means the AndroidKeyStore provider (directly, or via EncryptedSharedPreferences), never plain SharedPreferences. On iOS that means the Keychain (kSecClassGenericPassword), never UserDefaults, which is an unencrypted plist on disk.
Why: a token in SharedPreferences or UserDefaults is readable by anything with filesystem access to the app sandbox (an adb backup on a rooted device, an unencrypted iTunes/Finder backup, a misconfigured file provider). vdoX tokens are already short-lived (5 minutes), which limits the blast radius, but a leaked token is still a leaked view until it expires.
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import java.security.KeyStore
import javax.crypto.KeyGenerator
// Generate (or reuse) a Keystore-backed AES key. The raw key material never
// leaves the secure element/TEE; you encrypt the playback token with it and
// store only ciphertext (EncryptedSharedPreferences, or your own store).
val keyGenerator = KeyGenerator.getInstance(
KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore"
)
keyGenerator.init(
KeyGenParameterSpec.Builder(
"vdox_token_key",
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
)
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.build()
)
val secretKey = keyGenerator.generateKey()import Security
// Store the playback token in the Keychain, never UserDefaults (which is
// plain-text plist on disk). kSecAttrAccessibleAfterFirstUnlock is a
// reasonable default for a background-playable app.
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: "vdox_playback_token",
kSecValueData as String: tokenData,
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock,
]
SecItemAdd(query as CFDictionary, nil)Device binding
What: tie a stored credential to the device that requested it, so a copied token/keystore blob is useless elsewhere. Derive a stable device identifier (Android: MediaDrm provisioning id or a self-generated id stored in the Keystore-backed prefs; iOS: identifierForVendor) and send it alongside your backend's own token mint call, so your backend can refuse to hand out a fresh token to a different device for the same viewer.
Why: vdoX's own server-side concurrency limits (see Anti-sharing below) already stop the same viewer from streaming on too many devices at once; device binding is a second, app-side layer that stops credential copying between devices even before a request reaches vdoX.
Certificate pinning
What: pin your backend's TLS certificate (or public key) inside the app, so it refuses to trust a MITM proxy even if it presents a certificate signed by an otherwise-trusted CA (a corporate root, a compromised CA, a user-installed root for traffic inspection). Android: okhttp3.CertificatePinner. iOS: URLSession delegate pinning, or App Transport Security (NSPinnedDomains in Info.plist).
Why: pin the call to your own backend (the one holding your vdoX API key), not vdoX's API directly, unless you control that pin's rotation carefully: an expired pin with no update path bricks the app until you ship a fix.
import okhttp3.CertificatePinner
import okhttp3.OkHttpClient
val certificatePinner = CertificatePinner.Builder()
.add("api.yourapp.example.com", "sha256/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=")
.build()
val client = OkHttpClient.Builder()
.certificatePinner(certificatePinner)
.build()// Simplified: real pinning compares the leaf/intermediate public key hash,
// computed from your own cert, inside URLSessionDelegate.
func urlSession(
_ session: URLSession,
didReceive challenge: URLAuthenticationChallenge,
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
) {
guard let trust = challenge.protectionSpace.serverTrust,
pinnedKeyMatches(trust) else {
completionHandler(.cancelAuthenticationChallenge, nil)
return
}
completionHandler(.useCredential, URLCredential(trust: trust))
}App integrity and signature verification
What: verify, server-side, that a token-mint request is coming from your genuine, untampered app binary. Android: Play Integrity API (replaces the older SafetyNet Attestation). iOS: DeviceCheck or App Attest. Both return a signed attestation your backend verifies before minting a playback token.
Why: without this, anyone can decompile your app, extract the flow that calls your token endpoint, and reimplement it in a script, bypassing every on-device protection below.
Root, jailbreak, and emulator detection
What: detect a rooted/jailbroken device or an emulator before playing protected content, and degrade (warn, or refuse playback) on a positive result. Common Android signals: presence of su binaries,Build.TAGS containing test-keys, writable system partitions, known root-manager packages; libraries like RootBeer bundle these heuristics. iOS: sandbox escape checks (writable paths outside the sandbox, Cydia/jailbreak app URL schemes, unusual fork() behavior).
Why: a rooted/jailbroken device can bypass FLAG_SECURE, hook the player process, or dump decrypted frames directly from memory, all the OS-level protections below assume an intact OS sandbox.
Debugger and runtime-hook detection
What, where feasible: detect an attached debugger (Debug.isDebuggerConnected() on Android, ptrace(PT_DENY_ATTACH)/sysctl checks on iOS) and known runtime-hooking frameworks (Frida, Xposed): scan for their default ports/named pipes, injected libraries in /proc/self/maps, or suspicious symbol hooks.
Honest caveat: this is an arms race. Frida in particular can patch around most detection you ship; treat this as raising the bar for casual tampering, not as a guarantee. Combine with server-side integrity attestation above, which is much harder to spoof than a client-side check the same attacker can read and patch.
Screen recording and screenshot protection
Android, FLAG_SECURE: set on the window hosting the player. It blocks screenshots, blocks most screen recording (including the system Screen Recorder and most third-party recording apps), and blanks the Activity's thumbnail in the recent-apps switcher (so a backgrounded app doesn't leak a frame there either).
import android.view.WindowManager
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Blocks screenshots, blocks most screen recording, and blanks this
// Activity's thumbnail in the recent-apps switcher.
window.setFlags(
WindowManager.LayoutParams.FLAG_SECURE,
WindowManager.LayoutParams.FLAG_SECURE
)
}Also blank/hide on background: pause playback and hide the video surface in onPause/ onStop, don't rely on FLAG_SECURE alone for the recents-preview case.
override fun onPause() {
super.onPause()
// Belt-and-braces alongside FLAG_SECURE: stop playback and hide the
// surface the instant the app backgrounds, so nothing is on screen for
// the recents-preview render pass in the first place.
player.pause()
playerView.visibility = View.INVISIBLE
}Honest caveat: FLAG_SECURE is not airtight. Rooted devices and specialized capture tools (some HDMI-out paths, kernel-level screen-grabbers) can bypass it. Treat it as blocking the casual case (the built-in Screen Recorder, most Play Store recording apps), not every case.
iOS capture detection
UIScreen.main.isCaptured is true whenever the screen is being recorded or mirrored (AirPlay, screen recording, external display); observe changes via UIScreen.capturedDidChangeNotification. Screenshots are a separate, after-the-fact signal: UIApplication.userDidTakeScreenshotNotification fires once the screenshot is already taken (iOS gives no way to block or intercept it, only to react).
import UIKit
NotificationCenter.default.addObserver(
forName: UIScreen.capturedDidChangeNotification,
object: nil,
queue: .main
) { _ in
if UIScreen.main.isCaptured {
player.pause()
blurOverlay.isHidden = false
reportSecurityEvent(type: "capture_reported", detail: ["signal": "UIScreen.isCaptured"])
} else {
blurOverlay.isHidden = true
// Deliberately NOT auto-resuming here; see "Custom player policies" below.
}
}
NotificationCenter.default.addObserver(
forName: UIApplication.userDidTakeScreenshotNotification,
object: nil,
queue: .main
) { _ in
reportSecurityEvent(type: "screenshot_reported", detail: nil)
}Honest caveat: iOS detection is more reliable than Android's (there is no equivalent of a rooted bypass for a stock, non-jailbroken device), but it is still reactive, not preventive: a screenshot has already happened by the time the notification fires, and a jailbroken device can suppress both notifications entirely.
Web has neither
Browsers expose no API to detect or block screen recording or screenshots, on any platform. There is no web equivalent of FLAG_SECURE or UIScreen.isCaptured. This is exactly why the vdoX dashboard player and hosted embed instead render an always-on, moving, per-viewer watermark (see Embed): when detection and blocking are impossible, the fallback is deterrence and traceability, not prevention.
Custom player policies
Wire the detections above into a small, consistent policy in your player, regardless of platform:
- Pause on capture. The instant a recording/mirroring signal fires (
UIScreen.isCapturedgoing true; on Android, since you can't detect an in-progress recording as reliably, this reduces to the background/foreground lifecycle above), pause playback immediately. - Hide and warn. Replace the video surface with a blur or a plain message ("Playback paused: screen recording detected") rather than leaving the last frame visible underneath any overlay.
- Resume after recording stops, manually. Don't auto-resume the instant the signal clears; require a tap. An auto-resume can itself be timed by a capture tool that toggles recording on and off around the check.
- Log the event. Report every detection to vdoX via
POST /api/v1/security-events(see API reference), so it shows up next to your other playback and session activity, not only on the device. - Terminate after repeated attempts. If the same session reports multiple capture/screenshot events, treat it as a signal to end the session server-side (your own backend can refuse to mint a further token for that viewer, or you can proactively revoke the vdoX playback session) rather than only relying on the client to keep behaving.
POST /api/v1/security-events
Authorization: Bearer vdx_... // held by YOUR backend, never the app
Content-Type: application/json
{
"type": "capture_reported",
"videoId": "01ARZ3NDEKTSV4RRFFQ69G5FAV",
"sessionId": "01JAVX7C2H8Q9M4N6P0R3T5W7Y",
"detail": { "signal": "UIScreen.isCaptured" }
}Keep the vdx_ API key on your own backend for this call too, exactly as with token minting: never embed it in the app binary. Have the app report the detection to your backend, and have your backend forward it to vdoX.
Download protection
What to avoid: never hand the app a direct, permanent MP4 URL, and never let the app cache a decrypted copy of the video to plain disk storage. If you offer offline playback at all, store the downloaded media in encrypted app-sandbox storage (Android: EncryptedFile/Jetpack Security; iOS: NSFileProtectionComplete), keyed so a copied file is useless outside your app.
What vdoX already does: vdoX serves encrypted HLS only (AES-128, standard HLS-native decryption, never a plain, directly-playable MP4 URL) plus short-lived, presigned MP4 URLs for the explicit download feature (private bucket, expires quickly). The decryption key itself is delivered per-request, keyed to a short-lived playback token, and every manifest/key fetch continuously re-validates that token: once it expires, playback stops, even mid-stream, there is no long-lived credential to protect in the first place on the vdoX side. Disable your own offline-download feature entirely unless you implement encrypted offline storage as above; a plain cached .ts segment or re-muxed file defeats every other control on this page.
Anti-sharing
This part is server-side and already built into vdoX, nothing to implement on your end beyond the two integration points below:
- Concurrent-stream limits. Every plan has a maximum number of simultaneous playback sessions per viewer; vdoX enforces this at token mint time, server-side, regardless of platform (web, WebView embed, or native player).
- Suspicious-IP heuristic. vdoX watches for a single session heartbeating from an abnormal number of distinct IPs in a short window (a token being relayed/shared) and revokes it.
- Revocation. Both of the above (and an admin's manual revoke) mark the session revoked; the next heartbeat tells the player to stop.
Your app's job is to actually send the heartbeat so revocation can take effect: pass &session=<sessionId> (from the playback-token response) on the embed URL, and the hosted embed player heartbeats and stops playback automatically when revoked. A native (non-WebView) player integration should poll POST /api/playback/heartbeat the same way, roughly every 30 seconds, and stop playback ONLY on an HTTP 200 body of exactly { "ok": false, "reason": "revoked" }. Treat any non-200 response (rate-limited, a transient 503, or a network error) as transient and keep polling: it is not evidence the session was revoked, and stopping on it would log a viewer out over a blip unrelated to concurrency. This mirrors what vdoX's own web client does.
Monitoring
Report every capture/screenshot detection from the app to POST /api/v1/security-events (through your own backend, which holds the vdx_ key, never inside the app binary where it could be extracted). vdoX records playback and security events (session starts, denied/revoked concurrency, suspicious-IP revocations, capture/screenshot reports) and surfaces them to workspace admins on the admin security page, alongside a rolling alert count. Treat this as your audit trail: it's the same signal a workspace owner uses to notice a credential being shared before it becomes a bigger problem.
None of the controls above amount to DRM (Widevine/FairPlay-grade hardware key protection is listed as future work, see Embed). They are deterrence and monitoring: each one raises the cost or the traceability of casual sharing, and vdoX's own server-side controls (encrypted HLS, short-lived tokens, concurrency limits, revocation) do the parts that don't depend on trusting the client at all.