• 10 min read

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:

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:

ComponentSchema.kt
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.

URLSanitizer.kt
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

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:

Action Allowlisting

ActionRegistry.kt
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:

NetworkActionHandler.kt
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:

HTMLSanitizer.kt
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

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
ResponseVerification.kt
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:

SafeParser.kt
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:

SecurityMonitoring.kt
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