Automated mobile tests hate login screens. OAuth flows, MFA, and SSO web views are slow, flaky in CI, and expensive to maintain — yet production auth must stay strict. We needed QA and visual regression builds that start already authenticated without embedding bypass logic in store releases.
On paper, the solution was straightforward: inject access and refresh tokens at cold start through native launch arguments, resolve tenant configuration, then enter the app as a normal session — but only on QA-signed builds.
In practice, this was one of those changes that looked small in code and became tricky in validation. The most difficult part was not the token injection itself. It was testing the full behavior through a third-party automation tool running against QA builds, where failures were hard to reproduce locally and results were not always deterministic.
This post is about that bootstrap path, what made it fragile, and the ordering rules that eventually made it reliable enough to operate.
Requirements
The QA automation and mobile visual regression teams needed:
- Start on feature test environments, not always the default development environment
- Skip full IdP login while using real tokens from test identity
- Support multi-tenant public/private and tenant-specific QA flavors
- Keep production binaries free of bypass code paths (
QA_BUILDgating) - Reach the same post-login state as a real user: tenant configuration loaded, modules prefetched, encryption key rehydrated
Security agreement: bypass exists only in QA bundles distributed to automation infrastructure — never in store production schemes.
Architecture overview
We introduced a dedicated launch module (mobile/app/v3/launch/) that owns cold-start bypass:
Native launch args (--bypass_login, --token, --refresh-token, --tenant-environment)
↓
AppDelegate persists props → JS reads after bridge ready
↓
bootstrapTokenSession()
↓
Prime Redux auth tokens (Bearer available for API calls)
↓
resolveTenantConfiguration() via identity lookup + JWT claims
↓
Set tenant configuration before module prefetch
↓
Rehydrate encryption key (parity with normal login)
↓
PersistGate / main navigator
Legacy loginBypass.ts was removed once v3 launch became the sole path — one implementation, fewer drift bugs.
Native launch arguments
On iOS, QA schemes compile with QA_BUILD and read process arguments in AppDelegate. Tokens and the target tenant environment pass from the external test runner into UserDefaults, then surface to JavaScript through a native module after the bridge initializes.
Critical gate:
// Only QA schemes compile bypass handling
#ifdef QA_BUILD
if ([args containsObject:@"--bypass_login"]) {
[self persistBypassLaunchProps:args];
}
#endif
Production schemes never embed the --bypass_login string and CI checks release builds to ensure bypass args are absent from shipping artifacts.
Why this was hard to test
The uncomfortable part was that the real test path did not run like local development.
Locally, I could validate the parsing code, Redux bootstrap, and API ordering with unit tests and a debug build. That proved the pieces worked, but it did not prove the full path used by automation. The real path depended on a third-party mobile testing tool launching a QA-signed build with native arguments, real test tokens, a selected tenant environment, push or cold-start timing, and device/runtime behavior outside my laptop.
That made failures noisy:
| What I saw | Why it was hard |
|---|---|
| Test passed once, failed on rerun | The external runner controlled timing and app lifecycle |
| Local debug build worked | The real flow depended on QA build flags and signing |
| Logs showed a valid token | Tenant configuration was still not ready when modules started loading |
| A screenshot showed a black screen | The failure happened before visual regression could tell us why |
| Retrying changed the outcome | Network, identity lookup, and bridge readiness happened in a narrow window |
The lesson was simple: when the production-like test path depends on an external runner, local tests can prove invariants, but they cannot prove the orchestration. I had to design the bootstrap so the order was explicit, observable, and tolerant of slow or repeated startup events.
Bootstrap ordering — where we got it wrong first
The first bypass implementation worked functionally and still failed visual regression suites. Symptoms:
| Symptom | Root cause |
|---|---|
| Black screen after bypass | Module prefetch ran before tenant config existed |
| Identity lookup 401 | Auth tokens cleared before identity lookup; no Bearer header |
| Slower cold start vs session resume | Redundant native bridge reads; serial identity + config calls |
| Spurious token refresh loops | RTK Query saw 401 during bootstrap window and tried refresh before config loaded |
Fixes, in the order that mattered:
1. Prime tokens before identity lookup
Redux must hold access token before the identity lookup runs so the identity API receives Authorization headers.
2. Tenant configuration before module prefetch
Module prefetch depends on tenant-scoped API settings. Prefetch ordering is strict: config → modules → navigation.
3. Resolve environment from JWT, not hardcoded development defaults
Feature-environment tests pass the requested tenant environment. We resolve tenant configuration through the identity service using JWT tenant claims, then fall back to a secondary tenant claim — strict match only; reject production config fallback when the launch environment has no match.
4. Bypass-aware redux-persist
On bypass cold start, strip stale tenant configuration from persisted state. Old tenant config + new tokens = wrong API base URL and silent request failures.
5. RTK Query bootstrap guard
In fetchBaseQuery, skip 401 token refresh when configurationSettings is not loaded yet — the bootstrap window is not a session expiry event.
if (result.error?.status === 401 && !configurationSettingsLoaded) {
return result // do not attempt refresh during bootstrap
}
What I could and could not test locally
I added focused unit tests for the parts that should never depend on the external runner:
bootstrapTokenSession.test.ts— token priming and tenant resolution orderlaunchParams.test.ts— native arg parsing and normalizationfetchBaseQuery.test.ts— no refresh during bootstrap window
Those tests were necessary, but they were not enough. The third-party automation path still had to prove that a QA build could receive native args, persist them before the bridge was ready, read them from JavaScript, resolve the right tenant environment, and land on the expected screen.
That is why I stopped treating local success as final proof. Local tests covered deterministic logic. QA automation covered integration with the runner, signing mode, launch timing, and real tokens.
Feature-environment visual regression docs now point at v3/launch/ exports — when legacy bypass was deleted, outdated harness imports caused a week of "works locally" confusion.
Security boundaries
Bypass is not "auth disabled." It is auth short-circuited with real credentials on controlled builds:
- QA-only compile flags and signing identities
- No bypass strings in production release artifacts
- Tokens come from test identity infrastructure, not hardcoded secrets in repo
- Encryption key rehydration follows the same path as post-login session
- Separate feature flags for fallback cache vs Face ID + encryption (independent rollout)
Before accepting the flow, we also ran a focused penetration test around the bypass design. The goal was not to prove that QA automation was convenient; it was to prove that the same mechanism could not be activated in production by passing clever launch arguments, modifying configuration, or replaying test tokens.
The measures we kept after that review were:
- Compile-time isolation — bypass argument parsing only exists behind the QA build flag.
- Production artifact scanning — CI checks release binaries for bypass strings such as
--bypass_login, token launch keys, and related debug-only markers. - QA-only signing and bundle IDs — automation builds use separate identifiers, so they can sit beside production apps without sharing the same distribution path.
- Runtime bundle allow-list — JavaScript still checks that the running bundle belongs to an approved QA identifier before honoring bypass state.
- No static test credentials — tokens are injected by automation infrastructure and never committed to the repository or bundled with the app.
- Real-session parity — encryption rehydration, API token storage, and post-login state setup reuse the normal authenticated path instead of creating a weaker test-only session.
- Fail-closed defaults — missing tenant environment, missing token, invalid launch payload, or non-QA build identity all fall back to the regular login flow.
The native side also had a few small details that were easy to miss but important for the pentest:
- The bypass parser is compiled only inside
#if QA_BUILD; production builds do not just ignore bypass arguments, they do not compile that parser at all. - The parser accepts both dashed and underscored argument names, but it activates only when
--bypass_loginor--bypass-loginis explicitly set totrue. - Token, refresh token, and tenant environment are copied into initial React props only after that explicit bypass flag is present.
- Values are normalized before being persisted, and empty bypass props are not stored.
- The persisted launch payload uses a dedicated UserDefaults key, so the JavaScript side reads a narrow bootstrap payload instead of scanning arbitrary process arguments.
- The UI-testing flag is separate from login bypass. A build can be launched for UI testing without automatically receiving authenticated state.
That distinction matters: QA builds are allowed to understand automation arguments, but production builds should not contain the implementation surface that would make those arguments meaningful.
That extra security work mattered because this feature sits in an uncomfortable place: it is intentionally a shortcut around login, but only for controlled QA execution. The implementation had to make the shortcut useful for automation while making accidental production activation boringly impossible.
Results
After ordering fixes:
- Feature-environment visual regression runs start on the correct tenant environment
- Cold-start bootstrap became much closer to normal session resume behavior
- Black screen and bootstrap-time 401s stopped reproducing in QA suites
- Single module owns launch — future deep-link and route-param work builds on the same foundation
Takeaways
If you need authenticated automation in a React Native enterprise app:
- Never ship bypass in production schemes — compile-time gates and CI checks, not runtime
if (__DEV__). - Treat bootstrap as a state machine — tokens → tenant config → modules → persist, in that order.
- Guard your HTTP layer during bootstrap — 401 handlers must distinguish "not ready yet" from "session expired."
- Do not overtrust local validation — when a third-party runner owns launch behavior, unit tests prove the rules, but QA builds prove the orchestration.
- Delete legacy parallel implementations — one bypass path, documented launch exports for test harnesses.
Token-launch bootstrap let us keep production auth strict while making QA automation practical. The hard part was not passing tokens — it was making the rest of the app believe a real login had happened, in a launch path that could only be fully validated through QA builds and an external automation runner.