Work with integrity verdicts

The Play Integrity API uses integrity verdicts to communicate information about the validity of devices, apps, and users. Your app's server can use the resulting payload in a decrypted, verified verdict to determine whether how best to proceed with a particular action or request in your app.

Generate a nonce

When you protect an action in your app with the Play Integrity API, you can leverage the nonce field to mitigate certain types of attacks, such as person-in-the-middle (PITM) tampering attacks, and replay attacks. The Play Integrity API returns the value you set in this field, inside the signed integrity response.

The value set in the nonce field must be correctly formatted:

  • String
  • URL-safe
  • Encoded as Base64 and non-wrapping
  • Minimum of 16 characters
  • Maximum of 500 characters

The following are some common ways to use the nonce field in the Play Integrity API. To get the strongest protection from the nonce, you can combine the methods below.

Protect high-value actions against tampering

You can use the nonce field of the Play Integrity to protect the contents of a specific high value action against tampering. For example, a game may want to report the player’s score, and you want to ensure this score has not been tampered with by a proxy server. The implementation is as follows:

  1. The user initiates the high-value action.
  2. Your app prepares a message it wants to protect, for example, in JSON format.
  3. Your app calculates a cryptographic hash of the message it wants to protect. For example, with the SHA-256, or the SHA-3-256 hashing algorithms.
  4. Your app calls the Play Integrity API, and calls setNonce() to set the nonce field to the cryptographic hash calculated in the previous step.
  5. Your app sends both the message it wants to protect, and the signed result of the Play Integrity API to your server.
  6. Your app server verifies that the cryptographic hash of the message that it received matches the value of the nonce field in the signed result, and rejects any results that don't match.

Figure 1 contains a sequence diagram that illustrates these steps:

Figure 1. Sequence diagram that shows how to protect high-value actions in your app against tampering.

Protect your app against replay attacks

In order to prevent malicious users from reusing previous responses from the Play Integrity API, you can use the nonce field to uniquely identify each message. The implementation is as follows:

  1. You need a globally unique value in a way that malicious users cannot predict. For example, a cryptographically-secure random number generated on the server side can be such a value. We recommend creating values 128 bits or larger.
  2. Your app calls the Play Integrity API, and calls setNonce() to set the nonce field to the unique value received by your app server.
  3. Your app sends the signed result of the Play Integrity API to your server.
  4. Your server verifies that the nonce field in the signed result matches the unique value it previously generated, and rejects any results that don't match.

Figure 2 contains a sequence diagram that illustrates these steps:

Figure 2. Sequence diagram that shows how to protect your app against replay attacks.

Combining both protections

It is possible to use the nonce field to protect both against replay attacks and tampering at the same time. To do so, you can append the server generated globally unique value to the hash of the high-value message, and set this value as the nonce field in the Play Integrity API. An implementation that combines both approaches is as follows:

  1. The user initiates the high-value action.
  2. Your app asks the server for a unique value to identify the request
  3. Your app server generates a globally unique value in a way that malicious users cannot predict. For example, you may use a cryptographically-secure random number generator to create such a value. We recommend creating values 128 bits or larger.
  4. Your app server sends the globally unique value to the app.
  5. Your app prepares a message it wants to protect, for example, in JSON format.
  6. Your app calculates a cryptographic hash of the message it wants to protect. For example, with the SHA-256, or the SHA-3-256 hashing algorithms.
  7. Your app creates a string by appending the unique value received from your app server, and the hash of the message it wants to protect.
  8. Your app calls the Play Integrity API, and calls setNonce() to set the nonce field to the string created in the previous step.
  9. Your app sends both the message it wants to protect, and the signed result of the Play Integrity API to your server.
  10. Your app server splits the value of the nonce field, and verifies that the cryptographic hash of the message, as well as the unique value it previously generated match to the expected values, and rejects any results that don't match.

Figure 3 contains a sequence diagram that illustrates these steps:

Figure 3. Sequence diagram that shows how to both protect your app against replay attacks and protect high-value actions in your app against tampering.

Request an integrity verdict

After generating a nonce, you can request an integrity verdict from Google Play. To do so, complete the following steps:

  1. Create an IntegrityManager, as shown in the following examples.
  2. Construct an IntegrityTokenRequest, supplying the nonce through the setNonce() method in the associated builder. Apps exclusively distributed outside of Google Play and SDKs also have to specify their Google Cloud project number through the setCloudProjectNumber() method. Apps on Google Play are linked to a Cloud project in the Play Console and do not need to set the Cloud project number in the request.
  3. Use the manager to call requestIntegrityToken(), supplying the IntegrityTokenRequest.

Kotlin

// Receive the nonce from the secure server.
val nonce: String = ...

// Create an instance of a manager.
val integrityManager =
    IntegrityManagerFactory.create(applicationContext)

// Request the integrity token by providing a nonce.
val integrityTokenResponse: Task<IntegrityTokenResponse> =
    integrityManager.requestIntegrityToken(
        IntegrityTokenRequest.builder()
             .setNonce(nonce)
             .build())

Java

import com.google.android.gms.tasks.Task; ...

// Receive the nonce from the secure server.
String nonce = ...

// Create an instance of a manager.
IntegrityManager integrityManager =
    IntegrityManagerFactory.create(getApplicationContext());

// Request the integrity token by providing a nonce.
Task<IntegrityTokenResponse> integrityTokenResponse =
    integrityManager
        .requestIntegrityToken(
            IntegrityTokenRequest.builder().setNonce(nonce).build());

Unity

IEnumerator RequestIntegrityTokenCoroutine() {
    // Receive the nonce from the secure server.
    var nonce = ...

    // Create an instance of a manager.
    var integrityManager = new IntegrityManager();

    // Request the integrity token by providing a nonce.
    var tokenRequest = new IntegrityTokenRequest(nonce);
    var requestIntegrityTokenOperation =
        integrityManager.RequestIntegrityToken(tokenRequest);

    // Wait for PlayAsyncOperation to complete.
    yield return requestIntegrityTokenOperation;

    // Check the resulting error code.
    if (requestIntegrityTokenOperation.Error != IntegrityErrorCode.NoError)
    {
        AppendStatusLog("IntegrityAsyncOperation failed with error: " +
                requestIntegrityTokenOperation.Error);
        yield break;
    }

    // Get the response.
    var tokenResponse = requestIntegrityTokenOperation.GetResult();
}

Native

/// Create an IntegrityTokenRequest opaque object.
const char* nonce = RequestNonceFromServer();
IntegrityTokenRequest* request;
IntegrityTokenRequest_create(&request);
IntegrityTokenRequest_setNonce(request, nonce);

/// Prepare an IntegrityTokenResponse opaque type pointer and call
/// IntegerityManager_requestIntegrityToken().
IntegrityTokenResponse* response;
IntegrityErrorCode error_code =
        IntegrityManager_requestIntegrityToken(request, &response);

/// ...
/// Proceed to polling iff error_code == INTEGRITY_NO_ERROR
if (error_code != INTEGRITY_NO_ERROR)
{
    /// Remember to call the *_destroy() functions.
    return;
}
/// ...
/// Use polling to wait for the async operation to complete.
IntegrityResponseStatus response_status;

/// Check for error codes.
IntegrityErrorCode error_code =
        IntegrityTokenResponse_getStatus(response, &response_status);
if (error_code == INTEGRITY_NO_ERROR
    && response_status == INTEGRITY_RESPONSE_COMPLETED)
{
    const char* integrity_token = IntegrityTokenResponse_getToken(response);
    SendTokenToServer(integrity_token);
}
/// ...
/// Remember to free up resources.
IntegrityTokenRequest_destroy(request);
IntegrityTokenResponse_destroy(response);
IntegrityManager_destroy();

Decrypt and verify the integrity verdict

When you request an integrity verdict, the Play Integrity API provides a signed response token. The nonce that you include in your request becomes part of the response token.

Token format

The token is a nested JSON Web Token (JWT), that is JSON Web Encryption (JWE) of JSON Web Signature (JWS). The JWE and JWS components are represented using compact serialization.

The encryption / signing algorithms are well-supported across various JWT implementations:

  • JWE uses A256KW for alg and A256GCM for enc.
  • JWS uses ES256.

Decrypt and verify on Google's servers (recommended)

The Play Integrity API allows you to decrypt and verify the integrity verdict on Google's servers, which enhances your app's security. To do so, complete these steps:

  1. Create a service account within the Google Cloud project that's linked to your app. During this account creation process, you need to grant your service account the roles of Service Account User and Service Usage Consumer.
  2. On your app's server, fetch the access token from your service account credentials using the playintegrity scope, and make the following request:

    playintegrity.googleapis.com/v1/PACKAGE_NAME:decodeIntegrityToken -d \
      '{ "integrity_token": "INTEGRITY_TOKEN" }'
  3. Read the JSON response.

Decrypt and verify locally

If you choose to manage and download your response encryption keys, you can decrypt and verify the returned token within your own secure server environment. You can obtain the returned token by using the IntegrityTokenResponse#token() method.

The following example shows how to decode the AES key and the DER-encoded public EC key for signature verification from the Play Console to language-specific (the Java programming language, in our case) keys in the app's backend. Note that the keys are base64-encoded using default flags.

Kotlin

// base64OfEncodedDecryptionKey is provided through Play Console.
var decryptionKeyBytes: ByteArray =
    Base64.decode(base64OfEncodedDecryptionKey, Base64.DEFAULT)

// Deserialized encryption (symmetric) key.
var decryptionKey: SecretKey = SecretKeySpec(
    decryptionKeyBytes,
    /* offset= */ 0,
    AES_KEY_SIZE_BYTES,
    AES_KEY_TYPE
)

// base64OfEncodedVerificationKey is provided through Play Console.
var encodedVerificationKey: ByteArray =
    Base64.decode(base64OfEncodedVerificationKey, Base64.DEFAULT)

// Deserialized verification (public) key.
var verificationKey: PublicKey = KeyFactory.getInstance(EC_KEY_TYPE)
    .generatePublic(X509EncodedKeySpec(encodedVerificationKey))

Java


// base64OfEncodedDecryptionKey is provided through Play Console.
byte[] decryptionKeyBytes =
    Base64.decode(base64OfEncodedDecryptionKey, Base64.DEFAULT);

// Deserialized encryption (symmetric) key.
SecretKey decryptionKey =
    new SecretKeySpec(
        decryptionKeyBytes,
        /* offset= */ 0,
        AES_KEY_SIZE_BYTES,
        AES_KEY_TYPE);

// base64OfEncodedVerificationKey is provided through Play Console.
byte[] encodedVerificationKey =
    Base64.decode(base64OfEncodedVerificationKey, Base64.DEFAULT);
// Deserialized verification (public) key.
PublicKey verificationKey =
    KeyFactory.getInstance(EC_KEY_TYPE)
        .generatePublic(new X509EncodedKeySpec(encodedVerificationKey));

Next, use these keys to first decrypt the integrity token (JWE part) and then verify and extract the nested JWS part.

Kotlin

val jwe: JsonWebEncryption =
    JsonWebStructure.fromCompactSerialization(integrityToken) as JsonWebEncryption
jwe.setKey(decryptionKey)

// This also decrypts the JWE token.
val compactJws: String = jwe.getPayload()

val jws: JsonWebSignature =
    JsonWebStructure.fromCompactSerialization(compactJws) as JsonWebSignature
jws.setKey(verificationKey)

// This also verifies the signature.
val payload: String = jws.getPayload()

Java

JsonWebEncryption jwe =
    (JsonWebEncryption)JsonWebStructure
        .fromCompactSerialization(integrityToken);
jwe.setKey(decryptionKey);

// This also decrypts the JWE token.
String compactJws = jwe.getPayload();

JsonWebSignature jws =
    (JsonWebSignature) JsonWebStructure.fromCompactSerialization(compactJws);
jws.setKey(verificationKey);

// This also verifies the signature.
String payload = jws.getPayload();

The resulting payload is a plain-text token that contains integrity signals.

Returned payload format

The payload is plain-text JSON and contains integrity signals alongside developer-provided information.

The general payload structure is as follows:

{
  requestDetails: { ... }
  appIntegrity: { ... }
  deviceIntegrity: { ... }
  accountDetails: { ... }
}

You must first check that the values in the requestDetails field match those of the original request before checking each integrity verdict.

The following sections describe each field in more detail.

Request details field

The requestDetails field contains information that was provided in the request, including the nonce.

requestDetails: {
  // Application package name this attestation was requested for.
  // Note that this field might be spoofed in the middle of the
  // request.
  requestPackageName: "com.package.name"
  // base64-encoded URL-safe no-wrap nonce provided by the developer.
  nonce: "aGVsbG8gd29scmQgdGhlcmU"
  // The timestamp in milliseconds when the request was made
  // (computed on the server).
  timestampMillis: 1617893780
}

These values should match those of the original request. Therefore, verify the requestDetails part of the JSON payload by making sure that the requestPackageName and nonce match what was sent in the original request, as shown in the following code snippet:

Kotlin

val requestDetails = JSONObject(payload).getJSONObject("requestDetails")
val requestPackageName = requestDetails.getString("requestPackageName")
val nonce = requestDetails.getString("nonce")
val timestampMillis = requestDetails.getLong("timestampMillis")
val currentTimestampMillis = ...

// Ensure the token is from your app.
if (!requestPackageName.equals(expectedPackageName)
        // Ensure the token is for this specific request. See “Generate nonce”
        // section of the doc on how to store/compute the expected nonce.
    || !nonce.equals(expectedNonce)
        // Ensure the freshness of the token.
    || currentTimestampMillis - timestampMillis > ALLOWED_WINDOW_MILLIS) {
        // The token is invalid! See below for further checks.
        ...
}

Java

JSONObject requestDetails =
    new JSONObject(payload).getJSONObject("requestDetails");
String requestPackageName = requestDetails.getString("requestPackageName");
String nonce = requestDetails.getString("nonce");
long timestampMillis = requestDetails.getLong("timestampMillis");
long currentTimestampMillis = ...;

// Ensure the token is from your app.
if (!requestPackageName.equals(expectedPackageName)
        // Ensure the token is for this specific request. See “Generate nonce”
        // section of the doc on how to store/compute the expected nonce.
    || !nonce.equals(expectedNonce)
        // Ensure the freshness of the token.
    || currentTimestampMillis - timestampMillis > ALLOWED_WINDOW_MILLIS) {
        // The token is invalid! See below for further checks.
        ...
}

Application integrity field

The appIntegrity field contains package-related information.

appIntegrity: {
  // PLAY_RECOGNIZED, UNRECOGNIZED_VERSION, or UNEVALUATED.
  appRecognitionVerdict: "PLAY_RECOGNIZED"
  // The package name of the app.
  // This field is populated iff appRecognitionVerdict != UNEVALUATED.
  packageName: "com.package.name"
  // The sha256 digest of app certificates.
  // This field is populated iff appRecognitionVerdict != UNEVALUATED.
  certificateSha256Digest: ["6a6a1474b5cbbb2b1aa57e0bc3"]
  // The version of the app.
  // This field is populated iff appRecognitionVerdict != UNEVALUATED.
  versionCode: 42
}

appRecognitionVerdict can have the following values:

PLAY_RECOGNIZED
The app and certificate match the versions distributed by Google Play.
UNRECOGNIZED_VERSION
The certificate or package name does not match Google Play records.
UNEVALUATED
Application integrity was not evaluated. A necessary requirement was missed, such as the device not being trustworthy enough.

To ensure that the token was generated by an app that was created by you, verify that the application integrity is as expected, as shown in the following code snippet:

Kotlin

val requestDetails = JSONObject(payload).getJSONObject("appIntegrity")
val appRecognitionVerdict = requestDetails.getString("appRecognitionVerdict")

if (appRecognitionVerdict == "PLAY_RECOGNIZED") {
    // Looks good!
}

Java

JSONObject requestDetails =
    new JSONObject(payload).getJSONObject("appIntegrity");
String appRecognitionVerdict =
    requestDetails.getString("appRecognitionVerdict");

if (appRecognitionVerdict == "PLAY_RECOGNIZED") {
    // Looks good!
}

You can also check the app package name, app version, and app certificates manually.

Device integrity field

The deviceIntegrity field contains a single value, device_recognition_verdict, that represents how well a device can enforce app integrity.

deviceIntegrity: {
  // "MEETS_DEVICE_INTEGRITY" is one of several possible values.
  deviceRecognitionVerdict: ["MEETS_DEVICE_INTEGRITY"]
}

By default, device_recognition_verdict can have one of the following labels:

MEETS_DEVICE_INTEGRITY
The app is running on an Android device powered by Google Play services. The device passes system integrity checks and meets Android compatibility requirements.
No labels (a blank value)
The app is running on a device that has signs of attack (such as API hooking) or system compromise (such as being rooted), or the app is not running on a physical device (such as an emulator that does not pass Google Play integrity checks).

To ensure that the token came from a trustworthy device, verify the device_recognition_verdict is as expected, as shown in the following code snippet:

Kotlin

val requestDetails = JSONObject(payload).getJSONObject("deviceIntegrity")
val deviceRecognitionVerdict =
    requestDetails.getList("deviceRecognitionVerdict")

if (deviceRecognitionVerdict.contains("MEETS_DEVICE_INTEGRITY")) {
    // Looks good!
}

Java

JSONObject requestDetails =
    new JSONObject(payload).getJSONObject("deviceIntegrity");
String deviceRecognitionVerdict =
    requestDetails.getList("deviceRecognitionVerdict");

if (deviceRecognitionVerdict.contains("MEETS_DEVICE_INTEGRITY")) {
    // Looks good!
}

If you are having problems with your testing device meeting device integrity, make sure the factory ROM is installed (for example, by resetting the device) and that the bootloader is locked. You can also create Play Integrity tests in your Play Console.

If you opt in to receive additional labels in the integrity verdict, device_recognition_verdict can have the following additional labels:

MEETS_BASIC_INTEGRITY
The app is running on a device that passes basic system integrity checks. The device may not meet Android compatibility requirements and may not be approved to run Google Play services. For example, the device may be running an unrecognized version of Android, may have an unlocked bootloader, or may not have been certified by the manufacturer.
MEETS_STRONG_INTEGRITY
The app is running on an Android device powered by Google Play services and has a strong guarantee of system integrity such as a hardware-backed proof of boot integrity. The device passes system integrity checks and meets Android compatibility requirements.

Furthermore, if your app is being released to approved emulators, the device_recognition_verdict can also take on the following label:

MEETS_VIRTUAL_INTEGRITY
The app is running on an Android emulator powered by Google Play services. The emulator passes system integrity checks and meets core Android compatibility requirements.

Account details field

The accountDetails field contains a single value, licensingVerdict, that represents app licensing/entitlement status.

accountDetails: {
  // This field can be LICENSED, UNLICENSED, or UNEVALUATED.
  licensingVerdict: "LICENSED"
}

licensingVerdict can have the following values:

LICENSED
The user has an app entitlement. In other words, the user installed or bought your app on Google Play.
UNLICENSED
The user doesn't have an app entitlement. This happens when, for example, the user sideloads your app or doesn't acquire it from Google Play.
UNEVALUATED

Licensing details were not evaluated because a necessary requirement was missed.

This could happen for several reasons, including the following:

  • The device is not trustworthy enough.
  • The version of your app installed on the device is unknown to Google Play.
  • The user is not signed in to Google Play.

To check that the user has an app entitlement for your app, verify that the licensingVerdict is as expected, as shown in the following code snippet:

Kotlin

val requestDetails = JSONObject(payload).getJSONObject("accountDetails")
val licensingVerdict = requestDetails.getString("licensingVerdict")

if (licensingVerdict == "LICENSED") {
    // Looks good!
}

Java

JSONObject requestDetails =
    new JSONObject(payload).getJSONObject("accountDetails");
String licensingVerdict = requestDetails.getString("licensingVerdict");

if (licensingVerdict == "LICENSED") {
    // Looks good!
}