Skip to content
Contact us

expo · react-native · ci

Securing an Expo or React Native app in CI: a practical pipeline

A step-by-step guide to adding resilience checks to the CI of an Expo or React Native app: build the artifact, scan it, gate on a grade, keep secrets out of the bundle, and authenticate the pipeline without storing a long-lived key.

By Moatgator team6 min read

Illustration: a pipeline of five steps (a policy file, a package, a scan, a key and a flag) joined by a line, with a grade badge from A to F.

A React Native or Expo app is a native shell around a JavaScript bundle. That architecture gives you a fast iteration loop, and it also changes what an attacker sees when they open your package: most of your business logic is in a bundle that ships inside the APK, AAB or IPA. If your pipeline does not look at the artifact that actually gets published, you can have a perfectly tidy repository and a leaky release.

This guide builds a small, honest pipeline: it produces the artifact, scans it, fails the job when the grade is too low, keeps long-lived secrets out of the bundle, and authenticates to the service without storing an API key. It uses the free static scan, which is the part of the Moatgator toolchain that works today. A note on status, so nobody is misled: the protection engine that would produce a hardened package is not available yet. The command line tool registers builds and runs the scan; it does not produce a protected artifact, and it says so in its output.

Start from the artifact, not from the repository

Security scanners that read your source miss what the build does to it. Environment variables inlined at build time, a debug flag that a Gradle variant flips, a native module that adds a permission: these exist only in the built package. A scan of the final AAB, APK or IPA sees what a user (and an attacker) would receive.

For Expo this means choosing how you build. With EAS Build, the service produces the artifacts and you download them or consume them in a later step. With a local build, expo prebuild generates the native projects and you run Gradle or Xcode yourself. Either way the principle is the same: the scan runs on the output of the step that produces the file you publish. The Expo documentation covers both workflows, and you should keep the native folders that prebuild generates out of version control if you regenerate them, so that your policy file lives at the project root and is not overwritten.

Diagram of the five-step pipeline: describe the policy, build and scan the artifact, reduce what the bundle gives away, run in CI without a stored key, and decide what a failure means.
The five steps of this article, in order.

Step 1: describe the policy once

The CLI detects the project type from your files, including React Native and Expo, and writes a starting policy:

bash
npx moatgator initnpx moatgator validate

init creates a moatgator.yml with a recommended profile, and validate checks it and reports the line and column of every error. Commit this file. It becomes the single place where the team agrees on what the app should do, and a pull request that changes it is a visible security decision.

Step 2: build and scan locally first

Before you automate anything, run the loop on a laptop so that you understand the output. For an Android release build from a React Native project:

bash
cd android && ./gradlew bundleRelease && cd ..npx moatgator scan android/app/build/outputs/bundle/release/app-release.aab --min-grade C

The scan reads the package without running it, scores the four resilience controls from the OWASP MASVS (platform integrity, tampering, static analysis, dynamic analysis) and exits with a failing code when the grade is below the minimum. Remember what the grade means and does not mean. It is a static reading, so a protection that is hidden or loaded at runtime shows as not detected, and for React Native the logic that lives in the JavaScript bundle is mostly invisible to a native-level reading, which the report marks as unknown rather than absent. Treat the result as a checklist for the native shell and the configuration, not as a verdict on your whole app.

Step 3: reduce what the bundle gives away

The bundle is the part of your app most worth protecting and the part where tools help least, so start with hygiene.

  • Enable Hermes bytecode. Modern React Native uses Hermes by default, which compiles the JavaScript to bytecode before it ships. This is not encryption and it does not stop a determined reader, but it removes the trivial case of opening a text file.
  • Keep secrets out entirely. Anything placed in the app configuration or in an environment variable intended for the client ends up inside the package. A payment key, a cloud credential or an admin token in the bundle is public. Keep real secrets on a server and give the app short-lived, scoped tokens.
  • Strip what you do not need. Remove source maps from the shipped artifact, turn off development menus and logging in release, and make sure the release variant is not debuggable.
  • Shrink the native side. Enable code shrinking and obfuscation for the Android build so that the native layer is harder to read, and test the release build because aggressive rules can break reflection.

None of this is exotic. The scan will tell you if the release variant is debuggable or carries a debug certificate, which are the two findings that most often turn out to be an accident of the build configuration.

Step 4: put it in CI without storing a key

The risk of any CI integration is a long-lived credential sitting in a repository secret. GitHub Actions can instead issue a short-lived identity token to the job through OpenID Connect, and a service can trust a specific repository and ref without a stored key. The Moatgator Action and CLI support this: the job asks GitHub for a token, exchanges it for a ten-minute token, and uses that.

A workflow for a React Native or Expo (Android) project looks like this. It pins third-party actions to a full commit SHA, which GitHub's own hardening guide recommends because a tag can be moved.

yaml
name: moatgatoron:  push:    tags: ["v*"]jobs:  protect:    runs-on: ubuntu-latest    permissions:      contents: read      id-token: write    steps:      - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7      - uses: actions/setup-node@1e60f620b9541d16bece96c5465dc8ee9832be0b # v4.0.3        with: { node-version: 20, cache: npm }      - uses: actions/setup-java@99b8673ff64fbf99d8d325f52d9a5bdedb8483e9 # v4.2.1        with: { distribution: temurin, java-version: 17 }      - run: npm ci      - run: cd android && ./gradlew bundleRelease      - uses: moatgator-hq/github-action@v1        with:          artifact: android/app/build/outputs/bundle/release/*.aab          app-version: ${{ github.ref_name }}          app-id: app_REPLACE_ME          config: moatgator.yml          scan-min-grade: C

Three details are worth explaining. The permissions block gives the job the minimum it needs, and id-token: write is what allows the OIDC exchange. The trigger runs on tags, so the scan runs on the artifact you are about to release rather than on every commit. And scan-min-grade makes the job fail when the grade drops, which turns a security regression into a red build that someone has to look at. In production, pin the Moatgator action to a full SHA as well; the v1 tag above is shorthand for readability.

Before the first run you also need a trust rule on the service side that names the repository and the ref pattern allowed to authenticate. Without a rule, nobody gets in, which is the right default.

Step 5: decide what a failure means

A gate that nobody understands gets disabled. Decide in advance what happens when the scan fails. For a tag build, a failure should stop the release until someone either fixes the finding or records a reasoned exception in the pull request that changes the minimum grade. For a pull request build, you may prefer to report the grade and not fail, so the team sees the trend without blocking small changes.

Keep the exit codes in mind when you script around the CLI. Authentication problems, usage errors, a failing result and a timeout each return a different code, so your pipeline can tell "the grade is too low" from "the credentials were refused".

What this pipeline will not do

It will not make a vulnerable app safe, and it will not protect a bundle that contains a hard-coded secret. It does not test your server's authorization, which is where a large share of real mobile incidents begin. It gives you three things worth having: the artifact is checked on every release, regressions show up as a failed build, and no long-lived key is stored anywhere. Add the server-side controls and the monitoring on top, and you will have a defensible story when someone asks how the app is protected.

Sources

Every claim above can be checked here. If a link changed, tell us and we will fix the article.

  1. Expo documentation: EAS Build introduction
  2. Expo documentation: Prebuild
  3. React Native documentation: Using Hermes
  4. GitHub Docs: About security hardening with OpenID Connect
  5. GitHub Docs: Security hardening for GitHub Actions (pin third-party actions to a commit SHA)
  6. OWASP MASVS-RESILIENCE-3: anti-static analysis

All articles

Check your own app

The free Scan reads an APK, AAB or IPA and grades the resilience checks described here. It never runs your app.