DEV Community

Attay Rasool
Attay Rasool

Posted on

I scanned 23 open-source Expo apps before they hit the App Store. Here's what a pre-flight check caught.

Every Expo team has the same Friday afternoon: you run eas build, wait twenty minutes, and the build fails on something that was knowable before you started. Or worse, the build succeeds, the store review rejects it three days later, and the reason was a missing line in app.json.

expo-doctor checks your dependencies. Nothing I could find checks the things that actually get builds and reviews rejected: usage strings, permissions, eas.json, update channels, committed secrets. So I built a small CLI to do it, and then pointed it at real apps to see whether the problems are real.

npx expo-preflight
Enter fullscreen mode Exit fullscreen mode

It reads app.json / app.config.*, package.json, eas.json and .gitignore, never runs your app and never prints secret values. It's MIT licensed: https://github.com/AttayR/expo-preflight

The experiment

I ran it, read-only, against 23 apps from 19 popular open-source repos (boilerplates, Expo's own examples, and real apps with thousands of stars). I cloned them, ran the tool, and did not open issues or PRs on any of them. No names below: these are mostly well-maintained projects and the point is the pattern, not the people.

What I got wrong first

My first run produced 36 errors and 56 warnings, and roughly half were wrong. I read the findings against the real configs and found the tool was noisy in predictable ways:

  • It flagged the standard React Native debug.keystore as a leaked secret.
  • It didn't understand EAS server-side environments or process.env.X ?? "default", so it reported env vars as undefined.
  • It only read the app folder's .gitignore, so monorepos looked like they ignored nothing.
  • Its Android permission table was missing things like WRITE_CALENDAR for expo-calendar.

That's the unglamorous lesson: a linter you can't trust gets ignored. I fixed all of these and wrote regression tests for each (72 tests now), and the error+warning count on the same 23 apps dropped from 83 to 29 (7 errors and 22 warnings on the published 0.1.2; 13 of the 23 apps still had at least one finding). The findings that survived are the ones below, and I'll be upfront that it is still not perfect: I spot-checked six of them against the real code, and four were right and two were wrong (see "Limits").

What was real

1. Missing iOS usage strings (4 of the 6 apps with expo-image-picker or expo-media-library installed, on both 0.1.1 and the stricter 0.1.2).
Apps using expo-image-picker or expo-media-library without the matching NSPhotoLibraryUsageDescription, NSCameraUsageDescription or NSPhotoLibraryAddUsageDescription. Without them iOS terminates the app when the picker opens, and review can reject it. This is the classic one because it's invisible in development builds that use Expo Go's own strings.

2. A tracked .env in git (2 of 23 apps).
Two apps commit a .env. In one it holds only a public Sentry DSN, which is low risk, but .gitignore didn't ignore .env, so the next real secret goes in the same way.

3. Update channels that don't exist (2 of the 6 apps with both expo-updates and an eas.json).
One app had expo-updates installed, but its preview and production build profiles never set a channel. Those builds can never receive an EAS Update. Nothing fails; the OTA pipeline just silently does nothing.

4. cli.appVersionSource unset (4 of the 13 apps with an eas.json).
Four apps leave it unset, which leaves it ambiguous where the app version comes from (EAS recommends setting it explicitly).

5. Play-restricted permissions (1 of 23).
REQUEST_INSTALL_PACKAGES declared in app.json needs a Play Console declaration.

What this tells me

Almost none of this is exotic. It's configuration that's correct in each file and wrong across files: a plugin in app.json that doesn't match a package in package.json, a profile in eas.json that doesn't match an expo-updates install. Humans are bad at cross-file consistency and machines are good at it, which is the entire argument for a pre-flight check.

Use it

npx expo-preflight            # pretty output
npx expo-preflight --format github   # Markdown for a PR comment
Enter fullscreen mode Exit fullscreen mode

--format github prints the report as Markdown, ready to paste into a PR comment. Rules can be toggled or re-leveled in .preflightrc.json.

Limits, honestly

  • It's static analysis. For app.config.js/ts it extracts values best-effort rather than evaluating them, and says so.
  • "Package never imported" detection is a regex over source files, not a full analysis.
  • Precision wasn't where I wanted it. In a six-finding spot check of the published 0.1.1, 4 were correct. Both wrong ones were iOS usage-string errors raised because a package is installed, not because the sensitive API is called (an app using only the accelerometer doesn't need a motion string; one that only opens the photo library never needs the camera string). Version 0.1.2 fixes this for the packages where I mapped the triggering APIs (image picker, camera, media library, location, sensors): it now errors only when the API is actually called, and downgrades the rest to info. Other packages (contacts, local authentication, BLE and so on) are still flagged just for being installed; mapping those is next. Those two findings flipped to info on re-scan, and none of the real ones disappeared. That's still only six spot-checks, not a benchmark.
  • 23 apps is a small sample, and six spot-checks is smaller. Treat the proportions as indicative, not statistics.

If it flags something wrong on your project, please open an issue with the (redacted) config. That's how 0.1.1 and 0.1.2 got better, by running the tool on real code, and I'd rather fix a false positive than have it erode trust.

https://github.com/AttayR/expo-preflight

Top comments (0)