Token-launch bootstrap — QA login bypass without weakening production auth

July 03, 2026

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_BUILD gating)
  • 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 order
  • launchParams.test.ts — native arg parsing and normalization
  • fetchBaseQuery.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_login or --bypass-login is explicitly set to true.
  • 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:

  1. Never ship bypass in production schemes — compile-time gates and CI checks, not runtime if (__DEV__).
  2. Treat bootstrap as a state machine — tokens → tenant config → modules → persist, in that order.
  3. Guard your HTTP layer during bootstrap — 401 handlers must distinguish "not ready yet" from "session expired."
  4. Do not overtrust local validation — when a third-party runner owns launch behavior, unit tests prove the rules, but QA builds prove the orchestration.
  5. 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.


Profile picture

Hi, I'm Pavel Ivanov, a staff engineer building React Native apps and AI-powered products.

I write about frontend and mobile engineering, React and React Native, platform architecture, technical leadership, and the practical side of building reliable products and teams.

Say hi on LinkedIn.

* use of site content is strictly prohibited without prior written approval