Secure Your Backends With Firebase App Check

Introduction

Firebase App Check is a Google Firebase service that helps protect your backend resources from abuse and unauthorized access. It serves as an additional security layer, preventing unwanted traffic from reaching the backend. It inspects incoming requests and allows only requests from your apps, not from unknown or malicious sources. 

Why Use Firebase App Check?

In one of our projects, an attacker exploited the AWS Cognito sign-up service, specifically the phone number authentication that included SMS verification via the AWS SNS service. The attacker's automated requests resulted in a significant increase in the cost of the SNS service. We investigated various solutions to mitigate such attacks, including captcha-based options (reCAPTCHA Enterprise, hCaptcha) and encryption-based solutions.

We chose Firebase App Check for our use case after evaluating various solutions for the following reasons:

  • Simplicity: Firebase App Check is a simple and easy-to-implement solution, whereas encryption-based solutions necessitate complex setup, configuration, and maintenance of cryptographic algorithms.

  • Integration: Firebase App Check integrates seamlessly with other Firebase services. It provides a consistent and unified experience within the Firebase ecosystem If you already use Firebase for other aspects of your application, such as authentication, real-time database, or cloud functions.

  • Platform support: Firebase App Check offers SDKs for various platforms (Android, iOS, Flutter, Web, and C++), making integration into applications relatively easy.

  • User experience: Firebase App Check works silently in the background without any interruption to the user experience and without requiring any user interaction, such as solving traditional CAPTCHAs.

  • Purpose-built for Application Security: Firebase App Check is specifically designed to protect applications from abuse and unauthorized access, whereas captcha-based solutions primarily serve as a bot detection solution.

How Does It Work?

App Check uses attestation providers to validate the authenticity of client-side requests made to the backend. The attestation provider will determine whether you are interacting with a genuine app and device. 

App Check includes built-in support for the following attestation providers:

test

Here's a breakdown of the steps:

  1. For the app's attestation, the client app communicates with the attestation provider. 

  2. The attestation is sent to the App Check server, which verifies it and returns the App Check token with an expiration time.

  3. The client app sends the request to the backend server, along with the App Check token.

  4. The backend server receives the request and authenticates the App Check token.

  5. The server returns the response to the client app, allowing it to perform the requested operation.

Our Implementation

In our case, Firebase App Check was used to protect the AWS Cognito sign-up service from bots. We set up an AWS backend for our use case, but you can also use a Firebase backend or a custom backend. The Firebase project was created with Android and iOS apps and the App Check service enabled. We used Play Integrity for Android and Device Check/App Attest for iOS as attestation providers.

Using the SDK, both the Android and iOS apps generate an App Check token, which is then sent to the backend along with a sign-up request. The pre-signup lambda on the AWS backend is set up to validate the token received from the apps.

One issue with the App Check token is that it could be leaked or intercepted. To address this issue, we have enabled rate-limiting on the App Check token, which prevents more than three sign-up requests with the same token from being processed. 

The token has an expiration time of 30 minutes, which is the minimum allowed duration. It can be adjusted between 30 minutes and 7 days to meet specific needs. For most apps, the default TTL of one hour is sufficient. The App Check library refreshes tokens at roughly half the TTL duration. Although we only integrated App Check into the sign-up service, you can use it across all APIs.

The detailed flow of sign-up is as follows: 

  1. The Android or iOS app communicates with the App Check SDK to obtain the App Check token.

  2. Using the AWS Amplify SDK, apps send the App Check token in the sign-up request as a custom user attribute.

  3. The token is validated in the pre-sign-up trigger on the AWS backend, and the response is generated accordingly.

The App Check client SDK caches the token, and the same token is used until it expires. The SDK either refreshes the token when the getToken method is called or before it expires.

Let’s dive into the higher-level implementation details for integrating and releasing Android and iOS apps with App Check.

Android Implementation

In the Firebase console, we created an Android app by providing package information and the SHA-256 fingerprint of the app's signing certificate. We copied the generated google-service.json configuration file into the project's app module. See “Add Firebase to Your Android Project” for more information on setting up the Firebase project and adding the Firebase SDK to the Android project.

After creating the Android app, we registered the app to use the App Check service. You must provide the SHA-256 fingerprint of the app’s signing certificate. The token expiration time is set to 30 minutes, which you can change to meet your specific needs.

test

We also enabled the Play Integrity API in the Google Play console to manage apps distributed via Google Play. The following are steps to enable the Play Integrity API:

  • Navigate to the Google Play console.

  • Navigate to the Release section.

Select the Integrity API tab under Setup > App Integrity and link it to your Firebase project.

testtest

Following that, we added the App Check SDK and AWS Amplify SDK to the project and initialized them during the app startup. You will need to use PlayIntegrityAppCheckProvider when initializing Firebase App Check. See “Get started using App Check with Play Integrity on Android” for setting up the App Check SDK with Play Integrity. We won’t go into detail about integrating the AWS Amplify SDK with Cognito authentication, but if you want to know more about it, see “Getting Started with Cognito Authentication.”

After installing the SDK, use the FirebaseAppCheck class's getAppCheckToken() function to obtain the App Check token. App Check will refresh the token if necessary. Once you have a valid token, send it along with the request to your backend. The recommended method is to send the token in a custom HTTP header. Sending App Check tokens as part of URLs, including query parameters, exposes them to accidental leakage and interception. See “Send tokens from the client” for more information.

You can also use this helper class for your convenience. The getAppCheckToken() function is wrapped in the getToken function of the helper class. You can enable or disable auto-refresh as per your requirements.

class FBAppCheckManager {

    fun init(context: Context) {
        val isDebuggable = 0 != context.applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE
        if (isDebuggable) {
            FirebaseAppCheck.getInstance().installAppCheckProviderFactory(
                DebugAppCheckProviderFactory.getInstance(),
                false
            )
        } else {
            FirebaseAppCheck.getInstance().installAppCheckProviderFactory(
                PlayIntegrityAppCheckProviderFactory.getInstance(),
                false
            )
        }
    }

    fun getToken(
        successCallback: (AppCheckToken) -> Unit,
        errorCallback: (Exception) -> Unit
    ) {
        FirebaseAppCheck.getInstance()
            .getAppCheckToken(false)
            .addOnSuccessListener { result ->
                successCallback.invoke(result)
            }
            .addOnFailureListener { exception ->
                errorCallback.invoke(exception)
            }
    }

    fun setTokenAutoRefreshEnabled(isEnabled: Boolean) {
        FirebaseAppCheck.getInstance().setTokenAutoRefreshEnabled(isEnabled)
    }

    companion object {

        @Volatile
        private var INSTANCE: FBAppCheckManager? = null

        fun getInstance(): FBAppCheckManager =
            INSTANCE ?: synchronized(this) {
                INSTANCE ?: FBAppCheckManager().also { INSTANCE = it }
            }
    }
}

If you manage multiple app flavors with the same package name (e.g., Prod, Dev, and Staging), you can set up internal testing or closed testing track on Google Play. See this link for more information on setting up internal testing or closed testing.

If you want to distribute different app flavors with different package names through Google Play, create a new app record in Google Play for each app flavor and manage testing tracks separately.

Note: Play Integrity has a daily quota of 10,000 calls but you can request to increase usage using this form

iOS Implementation

In the Firebase console, we created an iOS app by entering the App ID and bundle information. See “Add Firebase to your Apple Project” for more information on setting up Firebase for iOS projects

Next, on the Apple developer site, create a DeviceCheck private key.

We used both DeviceCheck and App Attest, with DeviceCheck available starting with iOS 11.0 and App Attest starting with iOS 14. Please keep in mind that App Attest requires Xcode 12.5 or higher.

To set up DeviceCheck, Enable the DeviceCheck capability for your Xcode project. In Xcode, open your project and navigate to the "Signing & Capabilities" tab. Click the "+" button to add the "DeviceCheck" capability. Additionally, use DeviceCheck in the Firebase console to register the app to use the App Check service.

test

To set up App Attest, Enable the App Attest capability for your Xcode project. In Xcode, open your project and navigate to the "Signing & Capabilities" tab. Add the "App Attest" capability by clicking the "+" button. In your project's “.entitlements” file, set the App Attest environment to production, even in testing. Additionally, in the Firebase console, use App Attest to register the app to use the App Check service.

Note: If you are adding App Attest to a production app with a large active user base, Apple recommends gradually onboarding users to avoid encountering quota limits.

test

When initializing Firebase App Check, use DeviceCheckProvider and AppAttestProvider based on the iOS version.

import Foundation
import FirebaseAppCheck
import Firebase

class AppCheckFactory: NSObject, AppCheckProviderFactory {
    func createProvider(with app: FirebaseApp) -> AppCheckProvider? {
        if #available(iOS 14.0, *) {
            return AppAttestProvider(app: app)
        } else {
            return DeviceCheckProvider(app: app)
        }
    }
}

You can also use this helper class for your convenience.

import Foundation
import Combine
import FirebaseAppCheck
import Firebase
import DeviceCheck

class FirebaseTokenGenerator: NSObject {

    class func generateTokenDeviceCheck() -> AnyPublisher<String, TokenError> {
        let device = DCDevice.current
        return Future<String, TokenError> { promise in
            if (device.isSupported) {
                device.generateToken(completionHandler: { (data, errors) in
                    if let data = data {
                        let token = data.base64EncodedString()
                        promise(.success(token))
                    } else if let error = errors {
                        promise(.failure(TokenError.error(error.localizedDescription)))
                    } else {
                        promise(.failure(TokenError.error("Something went wrong.")))
                    }
                })
            } else {
                promise(.failure(.deviceNotSupported("Devicecheck not supported")))
            }
        }.eraseToAnyPublisher()
    }

    class func generateTokenAppCheck() -> AnyPublisher<String, TokenError> {
        return Future<String, TokenError> { promise in
            AppCheck.appCheck().token(forcingRefresh: true) { token, error in
                if error == nil {
                    guard let tokenref = token else {
                        return
                    }
                    promise(.success(tokenref.token))
                } else if let error = error {
                    promise(.failure(TokenError.error(error.localizedDescription)))
                }
            }
        }.eraseToAnyPublisher()
    }
}
enum TokenError: Error {
    case deviceNotSupported(_ reason: String)
    case error(_ with: String)
}

For more information, see Get Started with DeviceCheck on Apple platforms and Get Started with App Attest on Apple platforms.

Note: DeviceCheck and App Attest access is subject to any quotas or limitations set by Apple.

Use App Check in Debug Environments

You can use the debug provider to run the App Check in debug environments such as an emulator during development.

When initializing Firebase App Check, use DebugAppCheckProvider for Android and AppCheckDebugProvider for iOS. The SDK will log a debug secret when sending the request to the backend. Follow these steps to add this debug secret to the Firebase console:

  1. Open the Firebase console and navigate to your project.

  2. Navigate to the App Check section.

  3. From your app's overflow menu, select “Manage debug tokens.” Then, register a new debug token by clicking the "Add debug token" button and entering the name and debug token.

test

In addition, if the project has a CI/CD pipeline configured, you can generate debug tokens from the "Manage debug tokens" window and use them in the pipeline configuration. 

Please keep in mind that the built-in Play Integrity provider only works with Android apps from Google Play. If you use different app distribution platforms, such as AppCenter or Firebase App Distribution, for your dev or staging builds, you will need to employ a workaround when initializing App Check. One option is to use a debug provider with a hardcoded debug secret and ensure that your dev and staging builds are debuggable.

One limitation of DebugAppCheckProviderFactory in the context of Android is that there is no simple way to set a hardcoded debug secret. If you look at the implementation of DebugAppCheckProviderFactory, you will notice that it has a constructor that accepts debug secrets. This constructor, however, is package-private and only intended for use in tests by DebugAppCheckTestHelper. One workaround is to access this package-private constructor via reflection and provide the debug secret. This approach is demonstrated in the code snippet below.

val isDebuggable = 0 != context.applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE
if (isDebuggable) {
    val debugAppCheckProviderFactoryClass: Class<DebugAppCheckProviderFactory> =
        DebugAppCheckProviderFactory::class.java
    val argType = arrayOf(String::class.java)
    val constructor = debugAppCheckProviderFactoryClass.getDeclaredConstructor(*argType)
    FirebaseAppCheck.getInstance().installAppCheckProviderFactory(
        constructor.newInstance("BA9CF0F7-C0E3-4B89-B848-01982CE56C51") as AppCheckProviderFactory,
        false
    )
} else {
    FirebaseAppCheck.getInstance().installAppCheckProviderFactory(
        PlayIntegrityAppCheckProviderFactory.getInstance(),
        false
    )
}

For more information, see “Use App Check with the Debug Provider on Android” and “Use App Check with the Debug Provider on Apple Platforms.”

Backend Implementation

To verify App Check tokens on your backend, ensure that each request contains an App Check token and that the App Check token is verified using the Firebase Admin SDK. If the verification is successful, the Admin SDK will return the decoded App Check token. 

For the AWS backend, we created a user pool in Cognito and set up pre-sign-up lambda trigger to verify the incoming sign-up requests. Learn more about Cognito's pre-signup flow here.

test

Next, we added a custom user property called "custom:app_token" that will be sent by Android and iOS apps in Cognito sign-up requests.

test

Furthermore, we integrated the Firebase Admin SDK into the pre-sign-up trigger script to verify App Check tokens. The Admin SDK enables AWS to communicate with the Firebase project. It requires a Firebase service account, which can be obtained through the Firebase console. See “Add the Firebase Admin SDK to your server” for more information.

const firebaseAdmin = require("firebase-admin");
const serviceAccount = require("./secret/firebase.json");
const { RedisClient, RedisCacheService } = require("../service/cache");

// Init FirebaseAdmin
firebaseAdmin.initializeApp({
  credential: firebaseAdmin.credential.cert(serviceAccount),
  databaseURL: process.env.FIREBASE_DB_URL || '',
});

const max_rate_limit = 3;
const client = RedisClient.getInstance();

const asyncMain = async (redisClient, token) => {
  try {
    // If redis connection persist do not open
    if (!redisClient.isReady) {
      await redisClient.connect();
    }
    const redisSvs = new RedisCacheService(redisClient);
    const key = `token_${token}`;
    const tokenCounter = await redisSvs.getCache(key);

    if (tokenCounter && tokenCounter >= max_rate_limit) {
      console.log("RATE LIMIT EXCEEDED", tokenCounter);
      throw Error("rate limit exceeded..");
    } else {
      const res = await firebaseAdmin.appCheck().verifyToken(token);
      console.log("FIREBASE RES", JSON.stringify(res));
      // incr the counter
      if (!tokenCounter) {
        console.log("CACHE MISSED, SET TOKEN INTO CACHE");
        await redisSvs.setCache(key);
      }
      console.log("INCR COUNTER BY 1");
      await redisSvs.incrCache(key);
      return res;
    }
  } catch (err) {
    throw err;
  }
};

exports.handler = (e, ctx, callback) => {
  ctx.callbackWaitsForEmptyEventLoop = false;
  console.log(JSON.stringify(e), "<< EVENT");
  // Check token
  const { "custom:app_token": appCheckToken } = e.request.userAttributes;
  if (!appCheckToken) {
    callback(new Error("app token is missing"), e);
  }

  // Cache logic
  asyncMain(client, appCheckToken)
    .then((res) => {
      console.log("SUCCESS");
      callback(null, e);
    })
    .catch((err) => {
      callback(err, e);
    });
  // ends
};

On top of that, we used Redis to track the use of the App Check token. Android and iOS apps are not permitted to sign up with the same App Check token more than three times.

const Redis = require("redis");
const REDIS_URL = process.env.REDIS_URL

class RedisClient {
  static getInstance() {
    if (!this._instance) {
      this._instance = Redis.createClient({
        url: REDIS_URL,
        tls: {},
      });

      this._instance.on("error", (err) => {
        console.error("Redis Client Error", err);
        throw err;
      });
    }
    return this._instance;
  }
}

class RedisCacheService {
  constructor(client) {
    this.redisClient = client;
  }

  async getCache(key) {
    try {
      return await this.redisClient.get(key);
    } catch (err) {
      console.error("Error while fetching key", err);
      throw err;
    }
  }

  async setCache(key, value = 0, expiryInMin = 30) {
    try {
      return await this.redisClient.set(key, value, {
        EX: expiryInMin * 60,
      });
    } catch (err) {
      console.error("Error while saving cache", err);
      throw err;
    }
  }

  async incrCache(key) {
    try {
      return await this.redisClient.incr(key);
    } catch (err) {
      console.error("Error while incrementing key");
      throw err;
    }
  }
}

module.exports = { RedisClient, RedisCacheService };

See “Verify App Check tokens from a custom backend” for more information.

Conclusion

To summarize, implementing Firebase App Check is an excellent way to improve the security of your backend resources and protect them from abuse and unauthorized access. Firebase App Check ensures that only trusted apps can access your backend services by verifying the authenticity of client-side requests.

Throughout this blog, we discussed the advantages and simplicity of Firebase App Check as a comprehensive security solution. It is an excellent choice for application security due to its seamless integration with Firebase, support for multiple platforms, and user-friendly interface.

Rajesh Jadav

Senior Software Engineer (Android)

Published: Jul 21, 2023

Updated: Aug 22, 202318 min read

AWS Certified Team

Tech Holding Team is a AWS Certified & validates cloud expertise to help professionals highlight in-demand skills and organizations build effective, innovative teams for cloud initiatives using AWS.

By using this site, you agree to thePrivacy Policy.