Exploring mTLS
Like most apps, we have some resources that need to be protected more carefully than others. In our case, this included sensitive data that should only be available through a trusted app flow.
For a while, our protection model was mostly based around access tokens. They were required for every part of the app, and without them the app would not work. But eventually we started seeing bad actors scrape sensitive data from us. That forced us to look at the problem a little differently.
Access tokens are good for proving that a user has signed in and is allowed to access something tied to their account. They are not a great way to prove that a request is coming from a trusted app install. If a token is extracted or replayed, the backend can still see a valid token, but it does not really know much about the client making the request.
So we started moving towards mTLS.
Where we started
Before doing any of this, access tokens were the main thing we relied on for protected APIs. Every part of the app required an access token, whether the user had a full account or was using the app in a more lightweight state.
This is still true today.
We did not remove access tokens and we did not replace user authentication with certificates. The app still needs access tokens everywhere. In some flows, the token represents a signed-in user and what that user is allowed to do. In other flows, it is still required for the app to talk to the backend at all.
The issue was that an access token alone did not tell us enough about whether the client itself should be trusted. Sensitive data made this gap more obvious. We needed a stronger signal than "this request has a token".
Adding mTLS with a bootstrap certificate
The first step was to add mTLS using a bootstrap certificate.
In normal TLS, the app verifies the server. With mTLS, the server also verifies the app by asking it to present a client certificate during the TLS handshake. That gave us a way to reject requests earlier and make scraping harder, because having an access token was no longer enough.
At this stage, the bootstrap certificate was used across the app. It gave us a better baseline than bearer tokens alone because requests now needed to come through a client that could complete the mTLS handshake.
This was a good first step, but it had an obvious limitation: the certificate represented the app baseline, not a specific device or install.
If the same trust material is shared across installs, then the backend can tell that a request came from something with that certificate, but it still cannot distinguish one device from another in a meaningful way. That makes abuse harder to handle because you do not have a clean device-level identity to renew, expire, or revoke. Another thing is bad actors could dig into our binary and extract that bootstrap certificate and use it as they please.
So the next step was to move from a shared bootstrap certificate to per-device certificates.
Moving to per-device certificates
The idea was simple: each app install should have its own certificate.
On Android, we generated a key pair on the device and kept the private key in Android Keystore. The private key never needed to leave the device. The app would create a CSR from the public key, send that CSR to our backend through the bootstrap-protected path, and the backend would return a signed device certificate.
After that, the app could use the device certificate for mTLS requests across the app.
At a high level, the flow looked something like this:
class DeviceCertificateManager(
private val keystore: AndroidKeystore,
private val certificateApi: CertificateApi,
private val networkClient: NetworkClient,
) {
suspend fun prepareDeviceCertificate() {
val keyPair = keystore.getOrCreateKeyPair(alias = "app_device_key")
if (keystore.hasValidDeviceCertificate(alias = "app_device_key")) {
networkClient.useDeviceCertificate(alias = "app_device_key")
return
}
val csr = CertificateSigningRequest.create(
publicKey = keyPair.public,
privateKeyAlias = "app_device_key",
)
val signedCertificate = certificateApi.issueDeviceCertificate(
csr = csr,
// This request still goes through the bootstrap mTLS path.
)
keystore.storeCertificate(
alias = "app_device_key",
certificate = signedCertificate,
)
networkClient.useDeviceCertificate(alias = "app_device_key")
}
suspend fun requestAccountResource(accessToken: String) {
networkClient.get(
path = "/account/resource",
headers = mapOf("Authorization" to "Bearer $accessToken"),
// The TLS connection still uses the device certificate.
)
}
}
The snippet is simplified, but the important parts are:
- The private key is generated on the device.
- The private key stays in Android Keystore.
- The backend signs a CSR instead of receiving the private key.
- The app uses the signed certificate for mTLS.
- Access tokens are still attached to every request.
This gave us two separate layers that solve two separate problems.
The certificate tells us that the request came from a device/install that went through our certificate issuance flow. The access token is still required for the app to talk to the backend, and for signed-in flows it also carries the user context the backend needs.
Adding Play Integrity
Once we had per-device certificates, the next question was: when should we issue one?
Generating a key pair on the device and sending a CSR is useful, but by itself it does not tell us enough about the environment asking for the certificate. A modified app, an emulator, or a compromised device could still try to go through the same flow.
So we started adding Google's Play Integrity API into the certificate issuance and renewal path.
The rough flow became:
suspend fun requestDeviceCertificate() {
val nonce = certificateApi.createIntegrityChallenge()
val integrityToken = playIntegrity.requestToken(
nonce = nonce,
)
val keyPair = keystore.getOrCreateKeyPair(alias = "app_device_key")
val csr = CertificateSigningRequest.create(
publicKey = keyPair.public,
privateKeyAlias = "app_device_key",
)
val signedCertificate = certificateApi.issueDeviceCertificate(
csr = csr,
integrityToken = integrityToken,
)
keystore.storeCertificate(
alias = "app_device_key",
certificate = signedCertificate,
)
}
On the backend, the integrity token is verified before the certificate is issued. The checks we care about are things like whether the request came from our app binary, whether Google Play recognizes the app, and whether the device looks like a genuine Android device.
This is a nice extra layer because certificate issuance becomes more intentional. We are no longer just saying "this device generated a key pair". We are asking for a fresh integrity signal before giving that device a certificate it can use across the app.
There are some clear benefits:
- It raises the bar for modified apps and suspicious environments.
- The nonce makes replay harder because the integrity response is tied to a backend challenge.
- The backend gets a signal before issuing or renewing a device certificate.
- It keeps the decision server-side, which means the app does not get to decide whether it is trusted.
But it is not magic, and it comes with tradeoffs:
- It depends on Google Play services and the Google Play ecosystem.
- It does not help much on devices without GMS.
- Integrity verdicts are still signals, not absolute proof.
- You need to handle latency, quota, retries, and temporary failures carefully.
- It can affect legitimate users on unusual devices, test devices, or devices with unsupported configurations.
The way I think about it is that Play Integrity is not replacing mTLS. It helps us decide when to issue or renew the certificate that mTLS will later use. The certificate still protects the connection. The access token is still required for the app to work. Play Integrity just gives the backend another signal before it extends trust to a device.
Certificate lifecycle
Issuing certificates is only one part of the work. You also need to think about what happens after a certificate exists.
We did not want a device certificate to be valid forever, so certificates were short-lived and had to be renewed. This helps limit the damage if something goes wrong because old certificates naturally stop being useful after some time.
We also needed targeted revocation. If we saw suspicious behavior from a specific device certificate, we could revoke that certificate without affecting every other install. This is one of the big benefits of moving away from a shared bootstrap certificate for normal app traffic.
With the bootstrap certificate, the blast radius is much bigger because the trust is shared. With per-device certificates, the backend has a device-level handle it can reason about.
Where access tokens still fit
One thing worth calling out again is that certificates did not replace access tokens.
They are different tools.
Access tokens are still required everywhere in the app. Without them, the app does not work. If a signed-in user is accessing something tied to their account, that same access token also carries the user context the backend needs. The certificate does not answer that question.
Certificates are also required throughout the app because they help prove client/device trust. So every request needs an access token, and every request also needs to come through a client that can complete mTLS.
So a request can end up looking like this:
- mTLS proves the app install/device is trusted enough to talk to the backend.
- The access token is required for app access, and for signed-in flows it proves the user is allowed to access the account-specific resource.
That separation made the model much cleaner.
Final thoughts
The main lesson for us was that access tokens and certificates solve different problems.
Access tokens are useful, and we still use them. But for our threat model, especially after seeing sensitive data being scraped, access tokens alone were not enough. We needed a way to make client trust part of the connection itself, not just another header on the request.
The bootstrap certificate got us to mTLS quickly and raised the baseline across the app. Per-device certificates made the model much better because every install could have its own identity, lifecycle, and revocation path.
Play Integrity then gave us another useful signal at the point where trust is created or renewed. Instead of issuing certificates only because a device asked for one, we could ask: does this look like our real app, running in an environment we are comfortable trusting?
The next thing we want is similar support for Huawei devices using Huawei's Safety Detect, especially SysIntegrity. The idea is the same at a high level: get an integrity signal from the platform, verify it on the backend, and use it as part of the decision to issue or renew a device certificate.
The open question is what to do for phones that do not have GMS and also do not have a platform integrity API we can rely on. We could treat those devices differently, fall back to a weaker risk model, or decide that some features require a supported integrity provider. None of those options are perfect.
That is probably the most important part of this work: there is no single switch that makes a mobile app trusted. Access tokens, mTLS, per-device certificates, revocation, Play Integrity, and eventually Huawei Safety Detect all cover different parts of the problem.
It was not about replacing one mechanism with another. It was about separating user identity from device trust and making both explicit.