---
title: 'Network Security Configuration'
source: 'https://academia.sh/en/courses/platform-services-and-integrations/network-security-configuration'
course: 'Platform Services and Integrations'
language: en
updated: '2026-10-21T05:02:41+00:00'
license: 'CC BY-SA 4.0'
objectives:
    - 'Distinguishes where cleartext traffic, the trust anchor, and certificate pinning sit in the configuration'
    - 'Shows how a domain-specific rule frees a single address'
    - 'Measures a request blocked as cleartext as lost, because it never reaches the server'
    - 'Distinguishes that when the same request is resent to an HTTPS address the first request becomes superseded, and that a pinning mismatch is lost'
---

# Network Security Configuration

Network security configuration decides on cleartext traffic, trusted certificate sources, and domain-specific rules. The lesson measures how many copies of a request blocked as cleartext reached the server, and the outcome of a connection that does not match a pinned certificate.

In the previous lesson the secret sat on disk, and what made it unreadable was a key. In this lesson the event leaves the device: the app sends a request to a server. Before the request goes out to the network, it passes through the platform's configuration, and that configuration can block it. The question is the course's question: **how many copies of this sent event reached the receiver?** The event is the request, and the receiver is the server.

```kotlin
// Arrival.kt — classifies how many copies of a sent event reached the receiver
class Signal(val key: String, val seq: Int)

class Arrival(val label: String, val sent: Signal, received: List<Signal>) {
    val copies: Int = received.count { it.seq == sent.seq }
    val verdict: String = when {
        copies > 1 -> "duplicated"
        copies == 1 -> "once"
        received.any { it.key == sent.key && it.seq > sent.seq } -> "superseded"
        else -> "lost"
    }

    fun line() {
        println("$label: key=${sent.key} | copies=$copies -> $verdict")
    }
}

fun countArrivals(arrivals: List<Arrival>) {
    for (name in listOf("once", "duplicated", "superseded", "lost")) {
        println("$name: ${arrivals.count { it.verdict == name }}")
    }
}
```

The rule counts the copies that reach the server. It does not know why a request did not arrive, which rule blocked it, or whether the block was correct.

## The Configuration's Three Decisions

Network security configuration is an XML file, and the app's manifest refers to it by name. It decides three things. The first is **cleartext traffic**: whether a request may go to an address that starts with `http://`. The second is the **trust anchor**: which root certificates the server's certificate chain is verified against. The third is **certificate pinning**: for a given domain, only a connection that matches previously declared certificate digests is accepted.

```xml
<!-- Platform code: network-security-config -->
<network-security-config>
    <base-config cleartextTrafficPermitted="false">
        <trust-anchors>
            <certificates src="system" />
        </trust-anchors>
    </base-config>
    <domain-config cleartextTrafficPermitted="true">
        <domain includeSubdomains="false">local.test</domain>
    </domain-config>
    <domain-config>
        <domain includeSubdomains="true">api.example.test</domain>
        <pin-set>
            <pin digest="SHA-256">AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=</pin>
        </pin-set>
    </domain-config>
</network-security-config>
```

This piece is platform configuration; it is not run, and here we make no claim about output. The digest value is an example placeholder. `base-config` writes the default for all traffic: cleartext off, trust anchors set to the system's certificates. The first `domain-config` opens a single address, `local.test`, to cleartext. The second pins the certificate digest for `api.example.test` and its subdomains.

The model builds the platform's rule; the model is not the platform itself. In the model, a policy holds the set of addresses open to cleartext and an optional pinned certificate digest. If the request is not HTTPS, only the open addresses pass. If it is HTTPS and the policy has pinned a digest, the certificate on the request must match it.

```kotlin
// Policy.kt — applies the cleartext and pinning rules to requests
class Request(val signal: Signal, val url: String, val cert: String = "shared")

class NetworkPolicy(
    private val cleartextHosts: Set<String> = emptySet(),
    private val pin: String? = null,
) {
    private fun host(url: String) = url.substringAfter("://").substringBefore("/")

    fun passes(request: Request): Boolean {
        if (!request.url.startsWith("https://")) return host(request.url) in cleartextHosts
        return pin == null || request.cert == pin
    }
}

fun rejectCleartext(policy: NetworkPolicy, requests: List<Request>): List<Signal> =
    requests.filter { policy.passes(it) }.map { it.signal }
```

The list that reaches the server is the return value of `rejectCleartext`. In the measurements, this list gives the copy count.

```kotlin
// Probe.kt — counts the list that reached the server as the receiver for the requests that were sent
fun probe(policy: NetworkPolicy, requests: List<Request>, sent: List<Signal>): List<Arrival> {
    val received = rejectCleartext(policy, requests)
    return sent.mapIndexed { i, s -> Arrival("event-${i + 1}", s, received) }
}
```

## The Two Gates of a Request

The `passes` function sends a request through two gates, and the gates are independent of each other. The first gate looks at the form of the address. If the request does not start with `https://`, the decision depends entirely on the cleartext list: if the address is on the list, the request passes, and if not, it drops. In this branch the certificate is never looked at, because a cleartext request has no certificate.

The second gate works only for HTTPS requests. If the policy has not pinned a digest, the request passes; if it has, the request's certificate must match that digest. Here the cleartext list carries no meaning: if an address on the list is reached over HTTPS, pinning is still checked.

This distinction has two consequences. First, pinning does not protect a cleartext request; an `http://` request going to an open address is not compared with any digest. Second, lifting the cleartext block does not lift pinning; the two decisions are written separately and give results separately. In the measurements, a loss sometimes comes from the first gate and sometimes from the second, and the rule does not tell them apart: it sees only that no copy reached the server.

On the platform, the order in which rules are applied and the details of domain matching depend on how the configuration file is written; what this lesson measures is only the model's two gates.

## When Everything Is HTTPS

All three requests go to HTTPS addresses. The policy has no open address and no pinned digest.

```kotlin
// Main.kt — https requests are not caught by the block
fun main() {
    val sent = listOf(Signal("order.41", 1), Signal("order.42", 2), Signal("stock.7", 3))
    val requests = listOf(
        Request(sent[0], "https://api.example.test/order"),
        Request(sent[1], "https://api.example.test/order"),
        Request(sent[2], "https://api.example.test/stock"),
    )
    val arrivals = probe(NetworkPolicy(), requests, sent)
    arrivals.forEach { it.line() }
    countArrivals(arrivals)
}
```

```text
event-1: key=order.41 | copies=1 -> once
event-2: key=order.42 | copies=1 -> once
event-3: key=stock.7 | copies=1 -> once
once: 3
duplicated: 0
superseded: 0
lost: 0
```

All three requests reached the server as one copy each. When cleartext is off, HTTPS traffic is not caught by this block.

## A Request Blocked as Cleartext

Two of the four requests start with `http://`. One of them goes to `local.test`, which is in the open address set; the other does not.

```kotlin
// Main.kt — a cleartext request is blocked outside the open address
fun main() {
    val sent = listOf(
        Signal("order.41", 1),
        Signal("order.42", 2),
        Signal("stock.7", 3),
        Signal("rate.3", 4),
    )
    val requests = listOf(
        Request(sent[0], "http://api.example.test/order"),
        Request(sent[1], "https://api.example.test/order"),
        Request(sent[2], "http://local.test/stock"),
        Request(sent[3], "http://rate.example.test/rate"),
    )
    val arrivals = probe(NetworkPolicy(setOf("local.test")), requests, sent)
    arrivals.forEach { it.line() }
    countArrivals(arrivals)
}
```

```text
event-1: key=order.41 | copies=0 -> lost
event-2: key=order.42 | copies=1 -> once
event-3: key=stock.7 | copies=1 -> once
event-4: key=rate.3 | copies=0 -> lost
once: 2
duplicated: 0
superseded: 0
lost: 2
```

The two blocked requests never reached the server. This lesson's delivery: lost. The request was written and sent, but before it went out to the network, the platform's configuration stopped it. Because the `local.test` address was opened with a domain-specific rule, that request arrived once. The rule does not see the difference between `http://` and `https://`, only the outcome.

## When a Retry Leaves Copies

A network client can resend a request that timed out. The request goes twice over HTTPS, and both pass through the configuration.

```kotlin
// Main.kt — if a retried request passes twice, the server receives two copies
fun main() {
    val sent = listOf(Signal("order.41", 1), Signal("order.42", 2), Signal("stock.7", 3))
    val requests = listOf(
        Request(sent[0], "https://api.example.test/order"),
        Request(sent[1], "https://api.example.test/order"),
        Request(sent[1], "https://api.example.test/order"),
        Request(sent[2], "http://api.example.test/stock"),
    )
    val arrivals = probe(NetworkPolicy(), requests, sent)
    arrivals.forEach { it.line() }
    countArrivals(arrivals)
}
```

```text
event-1: key=order.41 | copies=1 -> once
event-2: key=order.42 | copies=2 -> duplicated
event-3: key=stock.7 | copies=0 -> lost
once: 1
duplicated: 1
superseded: 0
lost: 1
```

The configuration does not filter out the duplicated request: because both satisfy the same rules, both pass. Preventing duplication is the job of the client or the server. That the third event is lost is a separate matter: that request was cleartext, so it never passed and was not retried. In the same measurement, one event can come out duplicated and another lost.

## The Scope of an Open Address

An address opened to cleartext covers only as much as the name that was written. In the XML example, `includeSubdomains="false"` is written for `local.test`; that is, only this name is freed, not the names beneath it. Its counterpart in the model is an exact match against the `cleartextHosts` set: if the set contains `local.test`, it does not contain `table.local.test`.

There are three requests. The first goes to the open address itself over cleartext. The second goes to a subdomain of the same name over cleartext. The third goes to that subdomain over HTTPS.

```kotlin
// Main.kt — the open address does not cover subdomains, https still passes
fun main() {
    val sent = listOf(Signal("table.1", 1), Signal("table.2", 2), Signal("table.3", 3))
    val requests = listOf(
        Request(sent[0], "http://local.test/table"),
        Request(sent[1], "http://table.local.test/table"),
        Request(sent[2], "https://table.local.test/table"),
    )
    val arrivals = probe(NetworkPolicy(setOf("local.test")), requests, sent)
    arrivals.forEach { it.line() }
    countArrivals(arrivals)
}
```

```text
event-1: key=table.1 | copies=1 -> once
event-2: key=table.2 | copies=0 -> lost
event-3: key=table.3 | copies=1 -> once
once: 2
duplicated: 0
superseded: 0
lost: 1
```

The open address itself passed, its subdomain did not, and HTTPS was not caught by the cleartext rule whatever the address. Opening an address in a development environment and assuming its subdomains are open too is therefore a common mistake. The measurement shows it in three lines: the loss is only in the second request.

**SE1.** The model builds a retry as the same request appearing twice in the list. On the platform, a retry happens in the client's intermediate layer, and the configuration does not see it separately.

**SE2.** The model builds pinning with a single digest and for all HTTPS addresses. On the platform, pinning is written per domain and with more than one digest.

## The Bounding Measurement

The policy has pinned a digest. There are four sends. The first request is blocked as cleartext. The app resends the same request, with the same key, to an HTTPS address. The third request is HTTPS, but the server's certificate does not match the pinned digest.

```kotlin
// Main.kt — a resent request supersedes the first, a pinning mismatch is lost
fun main() {
    val sent = listOf(
        Signal("order.41", 1),
        Signal("order.42", 2),
        Signal("stock.7", 3),
        Signal("order.41", 4),
    )
    val requests = listOf(
        Request(sent[0], "http://api.example.test/order"),
        Request(sent[1], "https://api.example.test/order", "pinned-a"),
        Request(sent[2], "https://api.example.test/stock", "other"),
        Request(sent[3], "https://api.example.test/order", "pinned-a"),
    )
    val arrivals = probe(NetworkPolicy(pin = "pinned-a"), requests, sent)
    arrivals.forEach { it.line() }
    countArrivals(arrivals)
}
```

```text
event-1: key=order.41 | copies=0 -> superseded
event-2: key=order.42 | copies=1 -> once
event-3: key=stock.7 | copies=0 -> lost
event-4: key=order.41 | copies=1 -> once
once: 2
duplicated: 0
superseded: 1
lost: 1
```

The first request did not go out to the network; but a greater `seq` under the same key, that is, the resent request, reached the server. For this reason the rule counts the first request as superseded, not lost. `stock.7`, on the other hand, is lost because it does not match the pinned certificate, and since no other request reached the server under the same key, it is not superseded either. The cause of the first request is cleartext, and the cause of the third is a pinning mismatch. The rule does not tell these two causes apart: both mean no copy. The cause is learned only by looking at the configuration and the log.

**SE3.** The model builds the certificate mismatch with a label on the request. On the platform, a mismatch appears as the chain failing to verify during the handshake, and the connection fails with an error before the request is ever sent.

## What the Client Has to Do

Because the block is at the configuration level, responsibility is spread over two places. The code that sends the request must know that the request may have dropped before it went out to the network. The person who writes the configuration must document which addresses are open.

- **Separate the block from a network error.** A cleartext block and a dropped connection appear to the client as different errors. If the code puts both into the same retry loop, it repeats a blocked request for nothing; the model shows this, because however many times a blocked request is tried, its copies stay zero.
- **Deduplicate the retry.** Because the configuration does not filter out copies, adding a repeat identifier to the request is the client's job. If the server sees the same identifier twice, it ignores the second. In this case the delivery is still duplicated, but the result is not repeated; the identifier limits how many times the result is processed, not how many times it is delivered.
- **Prepare pinning for renewal.** The model builds a single digest. If the server's certificate changes, until the digest is updated all HTTPS requests are lost. In a real configuration more than one digest can be written, but which digests will be written and when they will change is not what this lesson measures.
- **Do not show the error message to the user as is.** The text shown to the user for a request that dropped because of the configuration must not say the network is down. The network is up; the request was turned back by the configuration.

## The Cost of Pinning

Pinning is a decision that raises security, but its cost is visible in the measurement. A pinned digest is tied to the server's current certificate, and when the certificate changes, the client and the server no longer expect the same thing. In the bounding measurement this appeared as the loss of a single request; in a real app, every HTTPS request going to the same domain is caught at the same gate, and the loss is not limited to a single request.

The cost is managed in two ways. First, the digest of the new certificate is added to the configuration before the certificate changes; the old and new certificates are then accepted together for a while. Second, the time it takes for the app to be updated is taken into account: the configuration ships with the app, while the certificate on the server can change immediately. If the user stays on an old version, the moment the new certificate arrives, all pinned requests in the old version are lost. Because the model in this lesson builds a single digest, it measures only this second case. The result is this: pinning gains security, but it ties the certificate renewal schedule to the configuration schedule. This tie requires, in the release plan, that a certificate change be prepared before the app version; otherwise, on the day the new certificate arrives, pinned requests in old versions silently drop.

## Common Misconceptions

**Every HTTPS request reaches the server.** If pinning exists, it does not. In the bounding measurement, `stock.7` went over HTTPS, but because its certificate did not match the pinned digest, it was lost.

**The configuration prevents repeats.** It does not. When two copies of the same request satisfy the same rules, both pass. The duplicated measurement showed this: the configuration only allows or does not allow; it does not count how many times something passed.

**Opening an address opens its subdomains too.** The measurement above disproved this: an open address is an exact match. Covering subdomains is a separate setting, and this lesson's model does not build it.

**Pinning also protects a cleartext request.** It does not. A cleartext request has no certificate; in this branch the model never looks at the digest, and the request passes or drops only according to the address list.

**A blocked request is at least recorded somewhere.** There is no record on the server side, because the request never went to the server. A lost event leaves a trace only in the client's own log; this is why the client log is the only source for this kind of loss.

## Summary

- This lesson's delivery: lost. A request blocked as cleartext never reaches the server.
- The configuration decides on cleartext traffic, the trust anchor, and certificate pinning; a domain-specific rule can free a single address.
- When two of four requests are cleartext, two requests arrive once and two are lost: once 2, lost 2.
- If a retried request passes twice, the server receives two copies; the configuration does not filter out duplication.
- When a request with the same key is resent over HTTPS, the first request becomes superseded. A pinning mismatch is lost; two causes are one category.

## Next Step

The request can now go out to the network. But the server asks for an access token to accept the request, and when the token expires it has to be refreshed. In the next lesson, two concurrent requests get an unauthorized response at the same time and both start a refresh; the same refresh event reaches the server as two copies.
