Server-Driven UI Security Best Practices
With SDUI, your server controls what your app displays. That's powerful — and potentially dangerous if not secured properly. Here's how to build SDUI systems that are both flexible and secure.
The Security Surface of SDUI
Traditional mobile apps have their UI compiled into the binary. SDUI changes that: UI becomes data. This introduces new attack vectors:
- UI Injection: Malicious UI definitions rendered by your app
- Action Hijacking: Unauthorized actions triggered by server responses
- Data Exfiltration: UI that captures and sends sensitive data
- Phishing: Fake UI that mimics legitimate screens
- Denial of Service: Malformed UI that crashes the app
Let's address each one.
1. Schema Validation
Never render arbitrary JSON. Every UI definition must pass schema validation before rendering.
🚨 Bad: No Validation
val component = json.parseAsComponent()
renderer.render(component) // Anything goes!
✅ Good: Strict Validation
val result = schemaValidator.validate(json, registeredSchemas)
if (result.valid) renderer.render(result.component)
else logAndShowFallback(result.errors)
Your schema should enforce:
- Allowed component types: Whitelist only known components
- Required props: Ensure critical props are always present
- Prop types: Validate strings, numbers, URLs, enums
- Prop constraints: Max length, allowed values, format patterns
- Nesting depth: Prevent deeply nested structures that crash renderers
data class ComponentSchema(
val type: String,
val props: Map,
val allowedChildren: List? = null,
val maxChildren: Int = 100
)
data class PropSchema(
val type: PropType,
val required: Boolean = false,
val maxLength: Int? = null,
val pattern: Regex? = null,
val allowedValues: List? = null
)
// Example: Button schema
val buttonSchema = ComponentSchema(
type = "Button",
props = mapOf(
"text" to PropSchema(
type = PropType.STRING,
required = true,
maxLength = 100
),
"style" to PropSchema(
type = PropType.ENUM,
allowedValues = listOf("primary", "secondary", "danger")
),
"action" to PropSchema(
type = PropType.ACTION,
required = true
)
),
allowedChildren = null // No children allowed
)
2. URL and Link Sanitization
Any URL in your SDUI payload is a potential attack vector. Always validate and sanitize.
object URLSanitizer {
private val allowedSchemes = setOf("https", "http")
private val allowedDomains = setOf(
"cdn.yourapp.com",
"images.yourapp.com",
"api.yourapp.com"
)
fun sanitize(url: String): SanitizedURL? {
val parsed = try {
URI.create(url)
} catch (e: Exception) {
return null // Invalid URL
}
// Scheme check
if (parsed.scheme?.lowercase() !in allowedSchemes) {
return null
}
// Domain check for external resources
if (parsed.host !in allowedDomains) {
// Log for monitoring
SecurityLogger.log("Blocked URL: $url")
return null
}
return SanitizedURL(parsed.toString())
}
fun sanitizeForNavigation(deeplink: String): SanitizedDeeplink? {
// Only allow internal app schemes
if (!deeplink.startsWith("yourapp://")) {
return null
}
// Validate against known routes
val route = deeplink.removePrefix("yourapp://")
if (route !in registeredRoutes) {
SecurityLogger.log("Unknown route: $route")
return null
}
return SanitizedDeeplink(deeplink)
}
}
⚠️ Watch Out For
javascript:URLs in WebViewsfile://URLs exposing local files- URL redirects to external phishing sites
- Data URLs with embedded payloads
3. Action Security
SDUI actions (tap handlers, form submissions) are the most sensitive part of your system. A compromised server could tell your app to:
- Navigate to a phishing screen
- Submit data to a malicious endpoint
- Execute arbitrary code (if you allow
eval-style actions)
Action Allowlisting
object ActionRegistry {
private val allowedActions = mapOf(
"navigate" to NavigateActionHandler::class,
"toast" to ToastActionHandler::class,
"submitForm" to SubmitFormActionHandler::class,
"haptic" to HapticActionHandler::class,
"analytics" to AnalyticsActionHandler::class
)
// NEVER allow arbitrary code execution
// No "eval", "execute", "runScript" actions
fun handle(action: SDUIAction): ActionResult {
val handler = allowedActions[action.type]
?: return ActionResult.Rejected("Unknown action: ${action.type}")
// Additional per-action validation
return handler.newInstance().handle(action)
}
}
class NavigateActionHandler : ActionHandler {
private val allowedRoutes = setOf(
"home", "profile", "settings", "product/*", "checkout"
)
override fun handle(action: SDUIAction): ActionResult {
val destination = action.params["destination"] as? String
?: return ActionResult.Rejected("Missing destination")
// Route validation
if (!isAllowedRoute(destination)) {
SecurityLogger.log("Blocked navigation: $destination")
return ActionResult.Rejected("Route not allowed")
}
router.navigate(destination)
return ActionResult.Success
}
private fun isAllowedRoute(route: String): Boolean {
return allowedRoutes.any { pattern ->
if (pattern.endsWith("/*")) {
route.startsWith(pattern.dropLast(2))
} else {
route == pattern
}
}
}
}
Network Action Security
For actions that make network requests (form submissions, API calls), enforce strict controls:
class SubmitFormActionHandler : ActionHandler {
// Only allow requests to your own APIs
private val allowedEndpoints = listOf(
"https://api.yourapp.com/*",
"https://forms.yourapp.com/*"
)
override fun handle(action: SDUIAction): ActionResult {
val endpoint = action.params["endpoint"] as? String
?: return ActionResult.Rejected("Missing endpoint")
// Strict endpoint validation
if (!isAllowedEndpoint(endpoint)) {
SecurityLogger.log("Blocked form submission to: $endpoint")
return ActionResult.Rejected("Endpoint not allowed")
}
// Data sanitization
val formData = action.params["data"] as? Map
?: emptyMap()
val sanitizedData = sanitizeFormData(formData)
// Always use HTTPS
if (!endpoint.startsWith("https://")) {
return ActionResult.Rejected("HTTPS required")
}
return submitForm(endpoint, sanitizedData)
}
private fun sanitizeFormData(data: Map): Map {
return data.mapValues { (key, value) ->
when (value) {
is String -> value.take(10_000) // Limit string length
is Number -> value
is Boolean -> value
else -> value.toString().take(10_000)
}
}
}
}
4. Content Security
HTML/Rich Text Sanitization
If your SDUI supports HTML or rich text components, you must sanitize the content:
object HTMLSanitizer {
private val allowedTags = setOf(
"p", "br", "b", "i", "strong", "em",
"ul", "ol", "li", "a", "span"
)
private val allowedAttributes = mapOf(
"a" to setOf("href"),
"span" to setOf("class")
)
// Blocks: script, iframe, object, embed, form, input, style
// Blocks: onclick, onerror, onload, javascript:
fun sanitize(html: String): String {
val doc = Jsoup.parse(html)
doc.select("*").forEach { element ->
// Remove disallowed tags
if (element.tagName() !in allowedTags) {
element.remove()
return@forEach
}
// Remove disallowed attributes
val allowed = allowedAttributes[element.tagName()] ?: emptySet()
element.attributes().forEach { attr ->
if (attr.key !in allowed) {
element.removeAttr(attr.key)
}
// Block javascript: URLs
if (attr.key == "href" && attr.value.startsWith("javascript:")) {
element.removeAttr(attr.key)
}
}
}
return doc.body().html()
}
}
Image and Media Security
- Validate image URLs come from allowed CDN domains
- Set maximum dimensions to prevent memory exhaustion
- Don't allow arbitrary
file://paths - Consider Content Security Policy for WebViews
5. Transport Security
SDUI payloads must be transmitted securely:
| Requirement | Implementation |
|---|---|
| HTTPS Only | Reject HTTP responses at the network layer |
| Certificate Pinning | Pin your API server certificates |
| Response Signing | Sign SDUI responses, verify on client |
| Integrity Check | Hash/checksum to detect tampering |
class SecureSDUIClient(
private val httpClient: OkHttpClient,
private val publicKey: PublicKey
) {
fun fetchScreen(path: String): SDUIScreen? {
val response = httpClient.newCall(
Request.Builder().url("https://api.yourapp.com$path").build()
).execute()
val body = response.body?.string() ?: return null
val signature = response.header("X-SDUI-Signature")
// Verify signature
if (!verifySignature(body, signature)) {
SecurityLogger.log("Invalid SDUI signature for: $path")
return null
}
return parseAndValidate(body)
}
private fun verifySignature(body: String, signature: String?): Boolean {
if (signature == null) return false
val sig = Signature.getInstance("SHA256withRSA")
sig.initVerify(publicKey)
sig.update(body.toByteArray())
return try {
sig.verify(Base64.decode(signature, Base64.DEFAULT))
} catch (e: Exception) {
false
}
}
}
6. Rate Limiting and Abuse Prevention
Protect against:
- Rapid screen requests: Rate limit SDUI API calls
- Large payloads: Set max response size (e.g., 1MB)
- Deep nesting: Limit component tree depth (e.g., 20 levels)
- Many children: Limit children per container (e.g., 1000)
object SafeParser {
const val MAX_DEPTH = 20
const val MAX_CHILDREN = 1000
const val MAX_PAYLOAD_SIZE = 1_000_000 // 1MB
fun parse(json: String): ParseResult {
if (json.length > MAX_PAYLOAD_SIZE) {
return ParseResult.Error("Payload too large")
}
val component = parseComponent(json, depth = 0)
?: return ParseResult.Error("Parse failed")
return ParseResult.Success(component)
}
private fun parseComponent(json: JsonObject, depth: Int): Component? {
if (depth > MAX_DEPTH) {
SecurityLogger.log("Max depth exceeded")
return null
}
val children = json.getAsJsonArray("children")
if (children != null && children.size() > MAX_CHILDREN) {
SecurityLogger.log("Max children exceeded")
return null
}
// Continue parsing...
}
}
7. Monitoring and Alerting
Security isn't just prevention — it's detection. Monitor for:
- Schema validation failures (spike = possible attack)
- Blocked URLs or actions
- Signature verification failures
- Unusual request patterns
object SecurityMonitor {
fun recordEvent(event: SecurityEvent) {
analytics.track("sdui_security", mapOf(
"event_type" to event.type,
"blocked_value" to event.blockedValue,
"screen" to event.screenPath,
"timestamp" to System.currentTimeMillis()
))
// Alert on threshold
if (recentBlockedCount() > ALERT_THRESHOLD) {
alerting.send(
severity = Severity.HIGH,
message = "SDUI security events elevated: ${recentBlockedCount()} in last 5 minutes"
)
}
}
}
Security Checklist
| Category | Check |
|---|---|
| Schema | ✅ All components validated against schema |
| Schema | ✅ Unknown component types rejected |
| URLs | ✅ Image URLs validated against CDN allowlist |
| URLs | ✅ Navigation URLs validated against route allowlist |
| Actions | ✅ Only allowlisted action types accepted |
| Actions | ✅ Network actions restricted to approved endpoints |
| Content | ✅ HTML/rich text sanitized |
| Transport | ✅ HTTPS enforced |
| Transport | ✅ Certificate pinning enabled |
| Transport | ✅ Response signing implemented |
| Limits | ✅ Max payload size enforced |
| Limits | ✅ Max nesting depth enforced |
| Monitoring | ✅ Security events logged and alerted |
The Bottom Line
Server-Driven UI is powerful because the server controls the UI. That same power requires careful security consideration. Treat SDUI payloads as untrusted input — validate, sanitize, and constrain at every layer.
The investment in security upfront prevents catastrophic issues later. Build it right from day one.
Related Articles
Building Secure Server-Driven UI?
Pyramid includes built-in schema validation, action allowlisting, and security monitoring. We've done the security work so you don't have to reinvent it.
Join the Waitlist