App attestation (Android)
For Android developers adding attestation to an app that already uses TrustPin pinning. At the end of this page, your requests carry a token your backend can verify.
How it works
kotlin-attest creates a device key and sends evidence about the app and the device to the attestation verifier you run: Android Key Attestation, Google Play Integrity, or both, as your policy asks. The verifier answers with a short-lived signed token, which the SDK renews cheaply with the same key. The SDK adds the token to requests for the hosts you choose as the TrustPin-Attestation header, and your backend verifies it and decides what an unattested request may do.
The SDK never blocks or fails your requests. When no token is available, the request goes out without the header.
Requirements
| Item | Requirement |
|---|---|
| Android | API 25 or later |
| Kotlin | 2.3.0 or later |
kotlin-sdk | Set up in your app, at the same version as kotlin-attest |
| Verifier | Deployed, reachable over HTTPS, with a published policy |
| Key Attestation | A device with hardware-backed key attestation. Emulators offer only software-level attestation, which is never accepted |
| Play Integrity | Google Play services and the Play Store on the device, only if your policy asks for Play Integrity |
SDK and verifier compatibility
| SDK | Verifier |
|---|---|
kotlin-attest 7.1.0 | Contract version 2 or newer: verifier 0.2.2 or later |
With an older verifier the state is Failed(VerifierIncompatible, …) and no header is sent until the verifier is upgraded. The app recovers on its own at the next attempt, with no update or reinstall.
Before you write code: the policy
Your verifier checks every attestation against a signed policy, which you manage on the project’s Attestation page in the TrustPin console . These settings matter for an Android app:
| Setting | What to set | What it does |
|---|---|---|
| Attest Android apps | Package name and every Signing certificate SHA-256 | An unlisted app or certificate is refused with app_not_allowed, in every mode |
| Evidence | Either, Both, Play Integrity only or Key Attestation only | Which evidence a device must provide |
| Minimum security level | TRUSTED_ENVIRONMENT or STRONG_BOX | Software keys are never accepted |
| Require verified boot, Require a locked bootloader | On for consumer production | Devices that do not meet them are refused |
| Require an app recognized by Google Play | On for production builds distributed through Play | Keep it off while testing builds installed from Android Studio or sideloaded |
| Require token binding | On to refuse tokens that are not bound to a session | See Bind tokens to the user’s session |
| Allow debug tokens | Off in production | See Emulators and development |
| Mode | Monitor (recommended to start), then Enforce | Monitor issues a token even when a policy check fails and records the result in it |
Evidence decides which devices can attest:
| Option | The device must provide | Fails on |
|---|---|---|
| Key Attestation only | Key Attestation | Devices without hardware-backed key attestation, and emulators |
| Play Integrity only | Play Integrity | Devices without Google Play, and apps with no project number |
| Either | Every evidence it can provide: Key Attestation, plus Play Integrity when the app has a project number. Passes when at least one passes the policy | Devices without hardware attestation when the app has no project number, and devices that can provide neither |
| Both | Key Attestation and Play Integrity | Devices missing either one |
Key Attestation only is the simplest start, because it needs no Google Cloud setup. Under Either, an app configured with a project number spends one Play Integrity request on every attestation.
Signing certificates. List the SHA-256 of each certificate that signs a build you accept:
- your debug key, for builds run from Android Studio;
- your upload key, if you install builds signed with it;
- the Play app signing key, for installs from Play (Play Console, Test and release, App integrity).
You can drop a certificate file (.pem, .der, .crt, .cer) onto the form to fill in the digest, or read it with keytool and convert it to the unpadded base64url the policy stores:
keytool -list -v -keystore ~/.android/debug.keystore -alias androiddebugkey \
-storepass android -keypass android | grep SHA256
echo "AB:CD:…" | tr -d ':' | xxd -r -p | base64 | tr '+/' '-_' | tr -d '='Register your verifier’s domain in your TrustPin project. Calls to the verifier are pinned by the TrustPin instance with the same identifier, and in strict mode an unregistered host is never reached.
Install
Add both artifacts at the same version, plus the adapter for your HTTP client:
dependencies {
implementation("cloud.trustpin:kotlin-sdk:7.1.0")
implementation("cloud.trustpin:kotlin-attest:7.1.0")
// The adapter for your HTTP client:
implementation("cloud.trustpin:trustpin-okhttp:7.1.0") // OkHttp, Retrofit
// or
implementation("cloud.trustpin:trustpin-ktor:7.1.0") // Ktor with the OkHttp engine; includes trustpin-okhttp
}The adapters do not bring OkHttp or Ktor with them, so your app supplies its own. kotlin-attest always brings the Play Integrity library. Both AARs ship their R8 and ProGuard consumer rules, so you add no keep rules, and kotlin-attest declares no permissions.
Configure
Add the attestation keys to the assets/trustpin.json that already holds your pinning configuration:
| Key | Required | Type | Notes |
|---|---|---|---|
attest_verifier_url | Yes | String | HTTPS base URL of your verifier, without a version segment, for example https://api.example.com/attest. A deployment path is allowed. No credentials, query, fragment or IP address |
attested_hosts | Yes | Array of string | One to fifty hosts that receive the header. *.example.com covers exactly one label. IP addresses are not accepted |
google_cloud_project_number | Only for Play Integrity | String or number | Your Google Cloud project number |
{
"organization_id": "<organization id>",
"project_id": "<project id>",
"public_key": "<project public key>",
"mode": "strict",
"attest_verifier_url": "https://api.example.com/attest",
"attested_hosts": ["api.example.com"],
"google_cloud_project_number": "123456789012"
}Other keys in the file are ignored. You can also build the configuration in code:
val config = TrustPinAttestConfiguration(
verifierUrl = "https://api.example.com/attest",
attestedHosts = listOf("api.example.com"),
googleCloudProjectNumber = 123456789012L, // null without Play Integrity
)An invalid value throws TrustPinAttestError.InvalidConfiguration, and the reason is written to logcat.
Play Integrity setup, only if your policy uses it:
- Enable the Play Integrity API in a Google Cloud project.
- Link the project to your app in Play Console (Test and release, App integrity, Play Integrity API).
- Put the project’s number, not its ID, in
google_cloud_project_numberand in the policy’s Google Cloud project number. - Ask your verifier’s operator to enable Play Integrity for that project on the verifier.
If Play Integrity is needed and the app has no project number, the result is EvidenceUnavailable. Configure the number only when the policy uses Play Integrity: with a number configured, the app contacts Google Play services from setup on, whatever the evidence mode.
Set up
Set up pinning first, then attestation, once per process:
import cloud.trustpin.kotlin.attest.TrustPinAttest
import cloud.trustpin.kotlin.attest.TrustPinAttestConfiguration
import cloud.trustpin.kotlin.attest.TrustPinAttestError
import cloud.trustpin.kotlin.attest.fromAssets
import cloud.trustpin.kotlin.sdk.TrustPin
import cloud.trustpin.kotlin.sdk.TrustPinConfiguration
import cloud.trustpin.kotlin.sdk.TrustPinError
import cloud.trustpin.kotlin.sdk.fromAssets
import cloud.trustpin.kotlin.sdk.withAndroidStorage
class MyApp : Application() {
private val appScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
override fun onCreate() {
super.onCreate()
appScope.launch {
try {
TrustPin.setup(TrustPinConfiguration.fromAssets(this@MyApp).withAndroidStorage(this@MyApp))
TrustPinAttest.setup(this@MyApp, TrustPinAttestConfiguration.fromAssets(this@MyApp))
TrustPinAttest.bindToHeader("Authorization")
TrustPinAttest.prefetch()
} catch (e: TrustPinError) {
// The pinning configuration is invalid.
} catch (e: TrustPinAttestError) {
// The attestation configuration is invalid.
}
}
}
}setupmakes no call to your verifier. It loads any key attested in an earlier launch.prefetch()starts attestation or a renewal in the background, so the first request does not wait. It never throws.- A second
setupof one instance throwsAlreadyInitialized; one made while the first is still running throwsInvalidConfiguration. - Set up the pinning instance with the same identifier first. Without it, verifier calls fail with
VerifierUnavailable. - One process. Set attestation up in the process that does your networking. Two processes attesting at once are harmless but spend evidence quota twice.
- Named instances.
TrustPinAttest.instance("payments")pairs withTrustPin.instance("payments"). The static calls use the default instance. - Java. Use
TrustPinAttest.getDefault().setupBlocking(context, TrustPinAttestConfigurationAssets.fromAssets(context))and the other blocking forms (getTokenBlocking,attestationHeadersBlocking,assertionHeadersBlocking,resetBlocking), from a background thread.
Bind tokens to the user’s session
A bound token is valid only with the session it was issued for. Only a SHA-256 hash travels: the value never leaves the device and is never logged.
| Mode | Call | Use it when |
|---|---|---|
| Header binding | TrustPinAttest.bindToHeader("Authorization") | Your requests carry the session in a header. Each request’s token is bound to that header’s value on that request |
| Fixed binding | TrustPinAttest.setTokenBinding(sessionValue) | You have one session value, or a client that sends no such header (gRPC, WebSockets) |
- A binding change renews the token with the existing key. It is not a new attestation.
setTokenBinding(null)removes the fixed binding only.bindToHeader(null)stops header binding and also clears a fixed binding, andbindToHeader(name)replaces a fixed binding.- With Require token binding on, no token exists until a binding is known, and the state is
Failed(BindingRequired, …). That is expected before sign-in. With header binding, the first request carrying the header gets the token. With fixed binding, callsetTokenBindingbeforeprefetch(). prefetch()withbindToHeaderattests early but cannot bind, because there is no request yet. The first bound request renews the token once more, cheaply.- With header binding,
getToken()returns a token bound to the most recent request the instance saw.
Send the header
A. OkHttp and Retrofit. The adapter that pins your client also adds the header:
val client = OkHttpClient.Builder()
.trustPin()
.build()
val retrofit = Retrofit.Builder()
.baseUrl("https://api.example.com/")
.client(client)
.build()Build the client after TrustPin.setup. For a named instance, use .trustPin(TrustPin.instance("payments")).
B. Ktor with the OkHttp engine.
val ktor = HttpClient(OkHttp) {
engine { trustPin() }
}With a preconfigured OkHttpClient, call trustPin() in the same engine block, and do not also pin that client with path A.
C. Your own client. Ask for the header and add it yourself. Turn off automatic redirects, so the token cannot follow a redirect to another host:
val url = "https://api.example.com/v1/items"
val auth = "Bearer $sessionValue"
val headers = TrustPinAttest.attestationHeaders(url, mapOf("Authorization" to listOf(auth)))
val connection = (URL(url).openConnection() as HttpsURLConnection).apply {
sslSocketFactory = TrustPin.makeTlsPair().sslSocketFactory
instanceFollowRedirects = false
setRequestProperty("Authorization", auth)
headers.forEach { (name, value) -> setRequestProperty(name, value) }
}If your client must follow redirects, rebuild each redirect’s headers with TrustPin.redirectedRequestHeaders(TrustPinAttestationRequest(url, method, headers)).
D. Anything else (gRPC, WebSockets). Read the token and send it yourself:
val token = TrustPinAttest.getToken()
// Send it in the TrustPinAttest.HEADER_NAME header.These rules apply to every path:
- HTTPS only. The token is never added to a plain
http://request. - Attested hosts only. Other hosts never receive it.
- Redirects. On paths A and B, a redirect gets the header only if it goes to an HTTPS URL on an attested host.
- Bounded wait. If no token is ready, a request waits a short, bounded time and is then sent without the header. During a wait after a failure it goes out at once without the header.
- A header your app sets. On OkHttp, it is kept: the SDK adds its own only when the request has none, and removes either on a redirect to another URL. With Ktor, the SDK replaces it on every request.
- Adding the header yourself everywhere. Call
TrustPin.setAttestationProvider(null)afterTrustPinAttest.setup. On OkHttp the header is still removed on a redirect to another URL, or on every request if one of your interceptors rewrites the request’s URL.
Sensitive operations
For an operation your backend must not accept on a reused token alone (a payment, a change of beneficiary), ask for an assertion bound to that operation and send both headers:
val digest = MessageDigest.getInstance("SHA-256").digest(body)
val headers = TrustPinAttest.assertionHeaders(digest) // assertionHeadersBlocking for Java
val request = Request.Builder()
.url("https://api.example.com/v1/transfer")
.header("Authorization", "Bearer $sessionValue")
.apply { headers.forEach { (name, value) -> header(name, value) } }
.post(body.toRequestBody("application/json".toMediaType()))
.build()The result holds the TrustPin-Attestation token and a TrustPin-Assertion header (TrustPinAttest.ASSERTION_HEADER_NAME). Your backend passes both, with its own digest of the operation, to your verifier (see Assertions for sensitive operations).
- The token comes first. A device that is waiting, refused or unsupported fails with the same error
getToken()would throw, and nothing is sent. - Binding. The token is bound as for
getToken(). With header binding, callattestationHeaders(url, headers)for this request first, thenassertionHeaders. - Cost. Each call produces a new assertion and costs one round trip to your verifier besides your request.
- Digest. Anything but 32 bytes throws
InvalidDigestbefore any network activity. - Who requires it. It is available with every policy. Your verifier and your API decide which endpoints require it. For endpoints that reuse the token, a short token lifetime and a required binding are the recommended configuration.
Verify the token in your API
The header carries a signed JWT issued by your verifier. Your API checks its signature against the verifier’s published keys, its type, issuer, audience, expiry and project, and its binding, and refuses debug tokens in production. What to do with a request that has no valid token is your decision, per endpoint. See Verifying tokens in your API for the steps.
sub identifies the device key. It changes when the device attests a new key, after a reset, a reinstall or a data wipe, so do not use it as a stable device identifier.
Emulators and development
Emulators cannot provide device evidence your policy accepts. For development, your verifier’s operator can issue a debug token. The policy (Allow debug tokens) and the verifier must both allow it. Pass it from a debug-only build field, never from trustpin.json, which the SDK does not read it from:
// build.gradle.kts
android {
buildFeatures { buildConfig = true }
buildTypes {
debug {
buildConfigField("String", "TRUSTPIN_DEBUG_TOKEN",
"\"${providers.gradleProperty("trustpinDebugToken").getOrElse("")}\"")
}
release {
buildConfigField("String", "TRUSTPIN_DEBUG_TOKEN", "\"\"")
}
}
}val fromAssets = TrustPinAttestConfiguration.fromAssets(context)
val config = TrustPinAttestConfiguration(
verifierUrl = fromAssets.verifierUrl,
attestedHosts = fromAssets.attestedHosts,
googleCloudProjectNumber = fromAssets.googleCloudProjectNumber,
debugToken = BuildConfig.TRUSTPIN_DEBUG_TOKEN.ifEmpty { null },
)
TrustPinAttest.setup(context, config)Keep the token itself in ~/.gradle/gradle.properties (trustpinDebugToken=…), not in the repository.
- The debug token is used only when the app is debuggable. Every other build ignores it.
- In a debuggable build with a debug token and a policy that allows debug tokens, it is used on real devices too. To test real device evidence, build without it or turn Allow debug tokens off.
- Debug tokens carry
tp.envdebug, which production APIs refuse. - Builds run from Android Studio are signed with your debug key: list its SHA-256 in the policy and keep Require an app recognized by Google Play off while you test them.
State, errors and logging
TrustPinAttest.state is one of the following. setStateListener { state -> … } observes changes on a background thread and does not replay the current state, so read state first.
| State | Meaning |
|---|---|
Unavailable | No token yet: before setup, before the first attestation, or after reset() |
Attesting | An attestation or a renewal is in progress |
Ready(expiresAtMillis) | A token is available until that time, and it is renewed before then |
Failed(error, retryAtMillis) | The last attempt failed |
Retry time. No timer runs after a failure. Nothing is tried before retryAtMillis, and the next request, getToken() or prefetch() after it starts a new attempt. Long.MAX_VALUE means no new attempt in this process until reset() or a restart. The exception is BindingRequired, which resumes as soon as a binding is set.
Errors are subclasses of TrustPinAttestError, thrown by setup, getToken and assertionHeaders, and carried by Failed:
| Error | Meaning | What to do |
|---|---|---|
InvalidConfiguration | A trustpin.json key is missing or invalid, or another setup is in progress | Fix the configuration and set up once |
AlreadyInitialized | setup was called twice on one instance | Set up once per instance |
NotSetUp | getToken before setup | Call setup first |
UnsupportedDevice | This device cannot attest the app, or new keys keep failing on it | See Support flow |
EvidenceUnavailable | The policy asks for evidence this device cannot provide, or for Play Integrity without a project number | Review Evidence, or configure the project number |
BindingRequired | The policy requires a binding and none is known yet | Expected before sign-in |
AttestationFailed(reasons) | The verifier refused the attestation. reasons carries its codes, or one the SDK supplies (http_<status>, invalid_key, invalid_input) | Read reasons. The SDK waits before attesting again, for as long as the verifier asks when it gives a time. The wait survives a relaunch, and an app update or reset() ends it |
VerifierUnavailable | The verifier could not be reached securely | Check connectivity and the verifier’s health. In strict mode, register the verifier’s domain |
VerifierIncompatible | The verifier is older than contract version 2 | Upgrade the verifier. No app change is needed |
InvalidDigest | assertionHeaders was given something other than a 32-byte SHA-256 digest | Hash the payload with SHA-256 first |
TrustPinAttestState, TrustPinAttestError and TrustPinAttestEvent may gain subclasses in a minor release, so give when expressions over them an else branch.
Logging
Logging is off by default. Whether a request got the header is logged at debug level.
TrustPinAttest.setLogLevel(TrustPinAttestLogLevel.DEBUG) // NONE, ERROR, WARNING, INFO, DEBUG; per instance
TrustPinAttest.setLogSink { level, instanceId, message -> // process-wide; null restores logcat
myLogger.log("[attest/$instanceId] $level: $message")
}Without a sink, messages go to logcat under the TrustPinAttest tag (adb logcat -s TrustPinAttest). Log lines never contain tokens, key certificates, bound values or debug tokens.
Events
TrustPinAttest.setEventListener { event -> … } or TrustPinAttest.events.collect { … } deliver TrustPinAttestEvent values for your metrics, in order and on a background thread. A slow or throwing listener never delays or fails attestation.
TrustPinAttest.setEventListener { event ->
when (event) {
is TrustPinAttestEvent.AttestationSucceeded -> metrics.timing("attest", event.durationMillis)
is TrustPinAttestEvent.Refused -> metrics.count("attest.refused")
is TrustPinAttestEvent.WaitEntered -> metrics.gauge("attest.wait_until", event.untilMillis)
else -> Unit
}
}| Event | Carries |
|---|---|
KeyGenerated | Nothing |
KeyReplaced | count |
AttestationSucceeded, RefreshSucceeded, AssertionSucceeded | durationMillis |
AttestationFailed | reasons, retryAtMillis |
RefreshFailed, AssertionFailed | error |
Refused | retryAtMillis |
WaitEntered | untilMillis |
WaitCleared, DeviceUnsupported, VerifierUnavailable | Nothing |
Events never carry a token, a key or a device identifier. A collector of events that falls behind may miss earlier events, so use setEventListener when every event matters.
Reset and what survives
TrustPinAttest.reset() is a suspending call (resetBlocking() for Java). It discards the attested key and the token, and returns once the discard is complete. The next request attests a new key, and any wait after a refusal ends. Use it for support flows, for example a “repair attestation” action, not routinely.
Stored state does not survive an uninstall, a clear-data or a transfer to another device, and keys and key certificates are never included in backups. In each case the app attests a new key.
Rolling out
- Develop with the policy in Monitor (recommended to start), your debug certificate listed and Require an app recognized by Google Play off. Check that your API receives and verifies the header.
- Test through a Play testing track with the Play app signing certificate listed. Keep monitor mode, and have your API log
tp.resultandtp.reasonswithout blocking. - Measure the share of requests without a header and the
failresults by reason, then adjust the policy. - Enforce. Switch Mode to Enforce, turn Allow debug tokens off, remove certificates you no longer ship with, and start refusing what you decided to refuse, starting with your most sensitive endpoints.
- Keep a fallback. App versions without attestation stay in the field for a while, so decide per endpoint until when they are served.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| Requests arrive without the header | The host is not in attested_hosts, the URL is http://, the client does not add headers, or there is no token yet | Check the host list and the path, then turn on DEBUG logging |
AttestationFailed(reasons=[app_not_allowed]) | The package or its signing certificate is not in the policy | Add them. Builds from Play use the Play app signing key |
AttestationFailed(reasons=[too_many_attestations]) | The verifier’s per-key limit was reached | The SDK keeps the key and waits as long as the verifier asks. Look for a loop in your code calling reset() or assertionHeaders |
Failed(EvidenceUnavailable, …) | The device lacks the evidence the option needs, or the project number is missing | Check Evidence and google_cloud_project_number |
Failed(VerifierUnavailable, …) every time | The verifier’s domain is not registered, the verifier is down, or attest_verifier_url is wrong | Register the domain, then check the URL and the verifier’s health |
Failed(VerifierUnavailable, …) in a release build on an emulator or a non-OEM OS image | The pinning SDK refuses these images by default | Opt in with the cloud.trustpin.android.allowNonOemImages manifest entry if you support them |
| Play Integrity refused on a test build | The build was installed from Android Studio or sideloaded | Turn Require an app recognized by Google Play off while testing, or use a Play testing track |
| Key Attestation refused on a development phone | Unlocked bootloader or unverified boot, with those requirements on | Expected for such devices. Relax the setting only for testing |
| A real phone attests with a debug token | A debuggable build with a debug token, and Allow debug tokens on | Build without the token, or turn the setting off |
| Your API refuses an assertion | A header is missing, or your API’s digest is not over the bytes the app digested | Send both headers and digest the exact request bytes on both sides. Run verifier 0.2.2 or later |
| Your API refuses a valid-looking token | Wrong audience or project in the check, a binding computed from a different header value, or clock skew | See Verifying tokens in your API |
Support flow
Failed(UnsupportedDevice, …) after repeated key failures, or a refusal that persists, is cleared by TrustPinAttest.reset() once the cause is fixed. The events and your verifier’s logs tell a throttled device from a refused one. Report a device that keeps failing after a reset to your verifier’s operator with the time of the attempt, which the verifier logs.
Privacy and Play Console Data safety
kotlin-attest sends attestation evidence and tokens only to your own verifier. It sends nothing to TrustPin and collects no device or user identifier. The device key stays in the device’s Android Keystore, and bound values never leave the device. Tokens identify a device key, not a person, and change when the key does.
With a project number configured, the app talks to Google Play services from setup on, so declare your use of the Play Integrity API. If you forward events to a third-party service, that disclosure is yours to make.