If you need to include recaptcha in your application, here's the solution using google-cloud/recaptcha-enterprise-react-native, expo and firebase functions:

Enable Recaptcha

Enable Recaptcha enterprise for your project. Billing has to be enabled for your project.

Set up reCAPTCHA protection
Set up reCAPTCHA protection

Proceed with "Set up reCAPTCHA protection" and create a key for each platform (iOS, Android) where you intend to use recaptcha.

Android

For android it only involves entering your package name.

iOS

For iOS you need the bundle id and create a private key for device check. Create a key in apple developer, check DeviceCheck, save p8 file. Team ID is in the upper right corner of Apple Developer account.

Note the site keys, you will need them in your app.

In your Expo App

Install recaptcha client dependency(I used ^18.8.2):

npm i --save @google-cloud/recaptcha-enterprise-react-native

Recaptcha client should be initialized once per app's lifecycle. I chose to initialize it like this, on module level:

import { Recaptcha } from '@google-cloud/recaptcha-enterprise-react-native';
import { Platform } from 'react-native';
import { RecaptchaAction } from '@google-cloud/recaptcha-enterprise-react-native/src/recaptcha_action';

const recaptchaClientPromise = Recaptcha.fetchClient(
  Platform.select({
    android: process.env.EXPO_PUBLIC_RECAPTCHA_ANDROID_SITE_KEY!,
    ios: process.env.EXPO_PUBLIC_RECAPTCHA_IOS_SITE_KEY!,
  })
);

export const getRecaptchaToken = async (action: string): Promise<string> => {
  const client = await recaptchaClientPromise;
  return await client.execute(RecaptchaAction.custom(action));
};

And use it getRecaptchaToken your code where you submit something. You can choose the default Recaptcha action like SIGNUP or LOGIN, or a custom action. Action is used for extra validation step, where the recaptcha service not only verifies that the token is ok, but that the expected action is valid too.

Default timeout is 2 minutes, but you can change it in the execute method as the 2nd argument.

It didn't work on Android

This was the error I got when I tried to build android:

Dependency ':google-cloud_recaptcha-enterprise-react-native' requires core library desugaring to be enabled
Dependency ':google-cloud_recaptcha-enterprise-react-native' requires core library desugaring to be enabled

Recaptcha library requires desugaring. I thought I could fix this by setting expo-build-properties like this:

['expo-build-properties', {
  android: {
    // Rest of properties
    compileOptions: {
            sourceCompatibility: '1.8',
            targetCompatibility: '1.8',
            coreLibraryDesugaringEnabled: true,
          },
          dependencies: {
            coreLibraryDesugaring: 'com.android.tools:desugar_jdk_libs:2.0.4',
          },
  }]

It didn't work, nothing was appended to build gradle file. For context, expo-build-properties plugin allows modifying certain Android and iOS build settings (such as SDK versions, compile options, and Gradle flags) from the Expo app configuration while remaining in the managed workflow.

What expo-build-properties doesn't support, a custom Expo plugin can.

Add a custom Expo plugin

With expo plugins you can change native code while still remaining in the Expo managed workflow. But it isn't pretty.

To enable desugaring that recaptcha requires, I need to modify the build gradle file. Regex knowledge comes in handy:

import { ConfigPlugin, withAppBuildGradle } from 'expo/config-plugins';

type Props = {
  desugarVersion?: string;
};
const withAndroidDesugaring: ConfigPlugin<Props> = (
  config,
  options = { desugarVersion: '2.0.4' }
) => {
  const desugarVersion = options.desugarVersion;
  return withAppBuildGradle(config, (config) => {
    let contents = config.modResults.contents;
    if (!contents.includes('coreLibraryDesugaringEnabled')) {
      if (contents.includes('compileOptions {')) {
        contents = contents.replace(
          /compileOptions\s*{\s*/,
          `compileOptions {
              coreLibraryDesugaringEnabled true
          `
        );
      } else {
        contents = contents.replace(
          /android\s*{\s*/,
          `android {
            compileOptions {
              sourceCompatibility JavaVersion.VERSION_1_8
              targetCompatibility JavaVersion.VERSION_1_8
              coreLibraryDesugaringEnabled true
              }
          `
        );
      }
    }
    if (!contents.includes('desugar_jdk_libs')) {
      contents = contents.replace(
        /dependencies\s*{\s*/,
        `dependencies {
          coreLibraryDesugaring "com.android.tools:desugar_jdk_libs:${desugarVersion}"
        `
      );
    }
    config.modResults.contents = contents;
    return config;
  });
};

export default withAndroidDesugaring;

In your expo app configuration, add to plugins:

{
  plugins: [
    ['./plugins/withAndroidDesugaring.ts', { desugarVersion: '2.1.3' }],
  ]  
}

Correct desugarVersion for ^18.8.2 is 2.1.3.

In your Backend

You need to verify the token server side. Install recaptcha in your backend:

npm install @google-cloud/recaptcha-enterprise --save

In the code, example in firebase functions:

const recaptchaClient = new RecaptchaEnterpriseServiceClient();

const recaptchaSiteKeyIos = defineString('RECAPTCHA_IOS_SITE_KEY');
const recaptchaSiteKeyAndroid = defineString('RECAPTCHA_ANDROID_SITE_KEY');
const minimumRiskScore = 0.5;

const VerifyRecaptchaSchema = z.object({
  token: z.string(),
  platform: z.enum(['ios', 'android']),
});
export const verifyRecaptcha = onCall(
  withValidation(VerifyRecaptchaSchema)(async (request) => {
    try {
      logger.info('Received verifyRecaptcha request');
      const {token, platform} = request.data;
      const expectedAction = 'verifyRecaptcha';

      const assessment = await recaptchaClient.createAssessment({
        parent: `projects/${process.env.GCLOUD_PROJECT}`,
        assessment: {
          event: {
            token,
            siteKey: platform === 'ios' ?
              recaptchaSiteKeyIos.value() : recaptchaSiteKeyAndroid.value(),
            expectedAction,
          },
        },
      });

      const tokenProperties = assessment[0].tokenProperties;

      if (!tokenProperties?.valid) {
        throw new HttpsError('permission-denied', 'Invalid reCAPTCHA token');
      }
      if (tokenProperties.action !== expectedAction) {
        throw new HttpsError('permission-denied', 'reCAPTCHA action mismatch');
      }

      const riskScore = assessment[0].riskAnalysis?.score ?? 0;
      if (riskScore < minimumRiskScore) {
        throw new HttpsError('permission-denied', 'Low reCAPTCHA score');
      }

      return {message: 'Ok'};
    } catch (error) {
      logger.error('Error verifying reCAPTCHA');
      if (error instanceof HttpsError) {
        throw error;
      }
      throw new HttpsError('internal', 'Internal server error');
    }
  }));

This function verifies if the token is valid, contains expected action and the risk score is below the minimum threshold.

The variables I use in the function are GCLOUD_PROJECT and site keys.

Site keys are defined using defineEnv, because firebase checks if those keys exists in the environment upon deploying.

GCLOUD_PROJECT is already defined by the Firebase Functions runtime (including the Firebase Emulator). I do not include it in the parameterized environment configuration because the deployment process would then require it to be set in a .env file. That is unnecessary, since Firebase injects this value automatically.

I call assessment[0] without checking if there exists a first element because function signature of createAssessment returns a 3-tuple.

Matching by expectedAction is optional.

You need to send token and platform in your request, if you have an multi-platform app.

Troubleshooting recaptcha client

If your getRecaptchaToken loads forever, your configuration is invalid. Is the site key in the environment?

If you get "[Error: Invalid Package Name.] Error: Invalid Package Name.", that's what it is. Check what package you entered in the recaptcha settings, don't trust your eyes. Copy exact bundleIdentifier from app.config into recaptcha settings.

If you get "Error verifying reCAPTCHA Error: 7 PERMISSION_DENIED: Permission denied for: recaptchaenterprise.assessments.create", did you explicitly disable app check in your firebase function?

If the expo plugin isn't building, you need to install tsx in deps, and add import 'tsx/cjs'; at the top of app.config module.

If the risk score is not present, it doesn't work from the emulator. I can verify it works from the production when the app is published to the store.


This tutorial is my foundation for the next part I'm working on, in-app contact forms with firebase functions and Mailgun. I also plan to cover integrating recaptcha enterprise with nestjs.

All of the code is available on GitHub and you can take a look at other chapters I covered about working with Firebase and Expo.

GitHub: https://github.com/amarjanica/firebase-expo-demo

Youtube: https://youtu.be/s_r5xwNIcAY