← Back to Blog Engineering

How We Built Google-Like Cross-App Authentication for a Flutter Ecosystem

Zovia Studio | | 16 min read
authentication session-management flutter ios android cross-app
Cross-app session architecture connecting multiple apps through shared identity

At a Glance

  • Log into one app, and every other app on the device is instantly authenticated — no second login, no “sign in with” prompts, no account linking
  • iOS uses a shared keychain access group so all apps read and write the same session token; Android uses a ContentProvider fan-out that discovers installed apps at runtime and mirrors the session to each one
  • A dual-layer notification system — IPC broadcast for instant detection, 5-second polling for reliability — ensures no app misses a login or logout event
  • A tombstone pattern solves the logout race condition: the logout marker is written before the session is deleted, so no app can read a stale session during the gap
  • We built this for Zovia’s growing app ecosystem. This post is the full blueprint so you can build one too

Log into Zupply to scan a receipt. Open Zyve to check your calendar. Open Zots to track a habit. No login screen on any of them — you authenticated once, and every app knows who you are.


At Zovia, we ship a growing ecosystem of production apps — Zistil, Zots, Zyve, Zupply, and Zuzzle — all built on the same Flutter package ecosystem with a shared Parse backend. When a user installs their second Zovia app, we wanted the experience Google provides: install Gmail after already using Google Maps, and you’re already signed in.

The technical challenge isn’t authentication itself — email/OTP login is straightforward. The challenge is making multiple independent app binaries, each with their own sandboxed storage, behave as if they share a single identity. On two platforms with fundamentally different security models.

This post breaks down the full architecture — the storage layer, the notification system, the race conditions we solved, and the design decisions — so you can build cross-app session sharing for your own app ecosystem.


The Problem: App Sandboxes Are Designed to Prevent This

Both iOS and Android are built on the principle that apps cannot access each other’s data. This is a security feature, not a bug. But it means sharing a session token between apps requires deliberately opting into a controlled sharing mechanism on each platform.

The naive approach — “just store the token somewhere both apps can read it” — doesn’t work. There is no shared filesystem on iOS. Android’s shared storage is too public (any app could read it). And cloud-based session sharing (each app checks the backend on launch) is too slow and fails offline.

We needed a solution that is:

  • Instant. Opening the second app should feel like switching tabs, not logging in.
  • Offline-capable. Session sharing should work without network access.
  • Secure. The session token should never be readable by non-Zovia apps.
  • Reliable. No race conditions between login, logout, and app switching.

The Architecture: Five Layers

The system has five layers, each solving a specific problem. You can adopt them incrementally — layers 1 and 2 give you 80% of the value.

┌─────────────────────────────────────────────────┐
│  Layer 5: Auth Service (app-facing API)         │
│  Login flow, OTP auth, fast resume              │
├─────────────────────────────────────────────────┤
│  Layer 4: Session Bridge (type conversion)      │
│  ZoviaSession ↔ AuthUser, token ordering        │
├─────────────────────────────────────────────────┤
│  Layer 3: Session Manager (coordinator)         │
│  In-memory cache, stream, version comparison    │
├─────────────────────────────────────────────────┤
│  Layer 2: IPC Broadcast (instant notification)  │
│  Darwin notifications (iOS) / BroadcastIntent   │
├─────────────────────────────────────────────────┤
│  Layer 1: Secure Storage (platform-specific)    │
│  Shared Keychain (iOS) / ContentProvider (Andr) │
└─────────────────────────────────────────────────┘

Layer 1: Shared Secure Storage

This is the foundation. Every app in the ecosystem reads and writes the same session data to a platform-specific shared store.

iOS: Shared Keychain Access Group

iOS provides keychain access groups — a mechanism where multiple apps from the same developer can read and write the same keychain items. This is the same mechanism Apple uses for iCloud Keychain sharing across its own apps.

The setup requires three things:

  1. All apps must be signed with the same Apple Developer Team ID
  2. All apps must declare the same keychain access group in their entitlements
  3. The FlutterSecureStorage instance must specify the groupId
_storage = const FlutterSecureStorage(
  iOptions: IOSOptions(
    accessibility: KeychainAccessibility.first_unlock_this_device,
    groupId: 'ABCDE12345.com.example.shared',
  ),
);

The groupId format is {TeamID}.{identifier}. Every Zovia app uses the same group, so a write() from Zupply is immediately readable by Zyve.

Three keychain items are written on every session save:

KeyValuePurpose
shared_sessionFull session JSONThe canonical session data
shared_session_versionInteger stringChange detection without parsing JSON
shared_session_timestampISO-8601 datetimeOrdering for conflict resolution

The first_unlock_this_device accessibility means the keychain is available after the user unlocks their phone for the first time after a reboot. This is the right tradeoff — it’s secure enough (not available at boot) but available for background app launches (unlike when_unlocked which requires the screen to be actively unlocked).

Android: ContentProvider Fan-Out

Android has no equivalent of shared keychain groups. Each app’s storage is fully sandboxed. The solution is a ContentProvider embedded in every app, acting as a controlled API for session data.

Each app registers a provider with the authority {packageName}.session.provider. When any app writes a session, the native plugin discovers all installed Zovia apps and mirrors the write to each one:

fun findAllProviders(): List<String> {
    val knownApps = listOf(
        "com.example.app.alpha",
        "com.example.app.bravo",
        "com.example.app.charlie",
        // ... every app in the ecosystem
    )

    return knownApps.filter { packageName ->
        try {
            val uri = Uri.parse("content://$packageName.session.provider/ping")
            val cursor = context.contentResolver.query(uri, null, null, null, null)
            cursor?.close()
            cursor != null  // PING succeeded — app is installed
        } catch (e: Exception) {
            false
        }
    }
}

The PING call is the discovery mechanism — each ContentProvider responds to a ping URI, confirming the app is installed. The write then fans out to every responding provider.

For reads, the plugin checks its own provider first (the self-first principle), then iterates other known apps until it finds a non-null session. This means every app can bootstrap from any other installed app’s session.

Why ContentProvider over alternatives?

ApproachProblem
Shared filesToo public — any app with storage permission could read the token
Account ManagerRequires system-level permissions, complex API, poor Flutter support
Bound ServiceRequires the source app to be running
ContentProviderSandboxed, works when source app isn’t running, permission-controllable

The ContentProvider is the closest Android equivalent to iOS keychain groups: it’s controlled, sandboxed to declared authorities, and works even when the providing app isn’t in the foreground.

The Critical Rule: Never deleteAll()

On iOS, calling deleteAll() on a FlutterSecureStorage instance configured with a shared keychain group wipes every item in the group — including items written by other apps. If Zupply calls deleteAll(), Zyve’s session disappears too.

The rule is absolute: individual key deletes only. When logging out, delete each key by name:

await _storage.delete(key: 'shared_session');
await _storage.delete(key: 'shared_session_version');
await _storage.delete(key: 'shared_session_timestamp');

Never:

await _storage.deleteAll();  // Destroys sessions for ALL apps

This is the kind of bug that’s easy to introduce and devastating to debug — the user appears randomly logged out of apps they weren’t using, with no error and no stack trace.


Layer 2: Real-Time IPC Broadcast

Shared storage solves persistence but not speed. Without a notification mechanism, apps would only detect a new session when they happen to read the storage — which could be minutes later, or never if the app is suspended.

The IPC broadcast provides instant notification: “a session changed, go read the storage now.”

iOS: Darwin Notification Center

Darwin notifications are system-level inter-process notifications that work even when apps are in the background. They carry no payload — they’re pure signals.

// Sending (after writing session to keychain)
let center = CFNotificationCenterGetDarwinNotifyCenter()
CFNotificationCenterPostNotification(center, name, nil, nil, true)

// Receiving (registered on app init)
CFNotificationCenterAddObserver(center, observer, callback, name, nil, .deliverImmediately)

When App A writes a session and posts a Darwin notification, App B’s observer fires immediately. The observer calls back into Dart, which triggers a keychain read. The entire propagation — write, notify, read, update UI — takes milliseconds.

Android: Targeted BroadcastIntent

Android’s implicit broadcast restrictions (introduced in Android 8) mean you can’t broadcast to unknown receivers. The solution is explicit targeting — send a separate intent to each known package:

knownApps.forEach { targetPackage ->
    val intent = Intent("com.example.SESSION_UPDATED").apply {
        setPackage(targetPackage)
        putExtra("userId", userId)
        putExtra("sessionToken", sessionToken)
        putExtra("appId", sourceAppId)
    }
    context.sendBroadcast(intent)
}

Unlike Darwin notifications, Android broadcasts can carry payload. We include the userId and sessionToken directly in the intent extras, so the receiving app can update its state immediately without a ContentProvider read. The ContentProvider is still the source of truth — the broadcast is an optimization to avoid the round-trip.

Two intent actions are used: one for login/session changes and one for explicit sign-outs. Separating them allows the receiver to take the appropriate action without parsing the payload.


Layer 3: The Session Manager

The ZoviaSessionManager is the Dart-side coordinator. It owns three things:

  1. A broadcast stream — any code in the app can subscribe to session state changes
  2. An in-memory cache — avoids redundant storage reads
  3. A 5-second polling timer — the reliability fallback

Version-Based Change Detection

Every session write increments a monotonic version integer. The polling loop reads only the version from storage and compares it to the cached version. If they match, nothing changed — no need to parse the full session JSON.

Future<void> _reloadFromStorage() async {
  final storageVersion = await _storage.readVersion();
  if (storageVersion == _cachedStorageVersion) return;  // No change

  final session = await _storage.readSession();
  if (session != null && session != _cachedSession) {
    _cachedSession = session;
    _cachedStorageVersion = storageVersion;
    _sessionController.add(session);  // Notify listeners
  }
}

This is an O(1) comparison on every poll tick — reading a single integer from the keychain, comparing it in memory. The full JSON parse only happens when the version actually changes, which is rare (login, logout, tier change).

Why Poll When You Have Broadcasts?

Because broadcasts aren’t guaranteed. On iOS, Darwin notifications can be dropped if the system is under memory pressure. On Android, a BroadcastReceiver registered at runtime is destroyed when the app’s process is killed. The poll is the safety net.

The design principle: broadcast for latency, poll for reliability. Both feed into the same handleRemoteUpdate code path, so the app behaves identically regardless of which detection mechanism fires first.

The iOS Keychain Sync Delay

On iOS, there’s a subtle timing issue: the shared keychain has a small propagation delay. If App A writes a session and App B reads immediately (triggered by a Darwin notification), the read occasionally returns stale data.

The fix is a deliberate retry on first initialization:

if (_cachedSession == null && Platform.isIOS) {
  await Future.delayed(const Duration(milliseconds: 500));
  await _reloadFromStorage();
}

This 500ms delay only fires once, only on iOS, and only when the initial read returned null. It catches the keychain sync race without slowing down the common case.


The Tombstone Pattern: Solving the Logout Race

This is the subtlest problem in the system and the one most likely to bite you if you build cross-app session sharing without planning for it.

The race condition: User logs out of App A. App A deletes the session from shared storage. But App B is running in the background with a cached copy of the session. App B’s next poll reads the (now empty) storage and… does what? If it ignores the empty read (maybe the keychain is temporarily unavailable), the user appears still logged in on App B. If it logs out, that’s correct — but what if App A is simultaneously writing a new session (the user logged into a different account)?

The solution: a tombstone.

When logout happens, the sequence is:

  1. Write a shared_session_cleared key with the current timestamp
  2. Delete the session keys
  3. Broadcast the logout

On the next read, the tombstone is checked:

final tombstoneTime = await _storage.read(key: 'shared_session_cleared');
final sessionTime = await _storage.read(key: 'shared_session_timestamp');

if (tombstoneTime != null && sessionTime != null) {
  if (DateTime.parse(sessionTime).isBefore(DateTime.parse(tombstoneTime))) {
    // This session was written BEFORE the logout — ignore it
    return null;
  }
}

If a session’s timestamp predates the tombstone, the session is stale and should be ignored. If a new session is written after the tombstone (a re-login), its timestamp will be newer, and it will be accepted.

The tombstone is the coordination primitive that makes logout atomic across apps, even when those apps are reading and writing concurrently.


The Login Flow: Email/OTP End-to-End

The authentication method is email/OTP — no passwords, no OAuth providers. The user enters their email, receives a 6-digit code, enters it, and they’re authenticated across all apps.

Client Side

User enters email


sendOtpToEmail (cloud function)
    │  ← 6-digit code generated, stored with 5-min TTL
    │  ← Branded email sent via SendGrid

User enters OTP code


validateOtp (cloud function)
    │  ← Code + expiry verified
    │  ← 5 consecutive failures → account blocked

unifiedLogin (cloud function)
    │  ← Session token obtained via REST API
    │  ← Returns: sessionToken, userId, email, name, tier

Save session to shared storage


Broadcast to other apps

The REST API Login Trick

This is the most important server-side detail for cross-app session sharing. Parse Server’s Parse.User.logIn() method associates the session with an installationId. When the same user logs in from a different installation (a different app), Parse invalidates the previous session.

That’s exactly what we don’t want. If logging into Zupply invalidates Zyve’s session, we’ve broken the entire cross-app experience.

The fix: bypass Parse.User.logIn() and call the Parse REST API directly:

const response = await axios.post(
  `${process.env.SERVER_URL}/login`,
  { username: email, password: deterministicPassword },
  {
    headers: {
      'X-Parse-Application-Id': process.env.APP_ID,
      'X-Parse-REST-API-Key': process.env.REST_API_KEY,
      // No X-Parse-Installation-Id header
    }
  }
);

By omitting the X-Parse-Installation-Id header, the session is not bound to any specific installation. Multiple apps can hold valid sessions simultaneously for the same user. This is what enables the “log in once, authenticated everywhere” behavior.

Deterministic Passwords

Parse requires a username/password pair for its _User model. Since we use OTP authentication, there’s no user-chosen password. The server fabricates one deterministically:

const password = `${email}-otp-${APP_SECRET_SALT}`;

The password can be reconstructed from the email at any time, so it never needs to be stored separately. But there’s a critical rule: never call set('password', ...) on an existing user, even with the same value. Parse Server re-hashes the password on every set(), generating a new bcrypt hash. This invalidates all existing sessions across all devices and all apps — a cascading logout that’s extremely difficult to debug.


Session Recovery: The 209 Problem

Parse Server returns error code 209 when a session token is invalid. This happens when:

  • The token expired
  • The token was revoked by a password change
  • The server restarted and the session cache was cleared

Every Zovia app registers a two-level defense:

Level 1: refreshSessionToken (non-destructive). Re-reads the shared keychain. If another app has written a newer, valid session, use that. This handles the case where App A refreshed the session but App B is still using the old token.

Level 2: recoverInvalidSession (destructive fallback). Clears the in-memory session state. But before touching the shared keychain, it checks a critical guard:

final storedSession = await _sessionManager.getSession();
if (storedSession != null) {
  await _sessionManager.logout();
} else {
  // No stored session — skip keychain cleanup
}

Why the guard? Consider this scenario: User installs App B for the first time. App B has no session. During initialization, it makes a Parse API call that returns 209 (maybe a stale cached token from a previous install). Without the guard, App B would call logout(), which writes a tombstone to the shared keychain, which invalidates App A’s perfectly valid session. The guard prevents a sessionless app from destroying another app’s session.


Fast Resume: Sub-500ms App Launch

For returning authenticated users, the full initialization sequence — initializing 10+ packages, registering services, checking permissions — takes seconds. Users shouldn’t wait for all of that just to see their data.

The ResumeCoordinator runs a minimal check path:

1. Check init marker (has this app initialized before?)
   └─ No → full initialization required
2. Initialize session manager only
3. Check for stored session
   └─ No session → full initialization (show auth screen)
4. Validate session with backend
   └─ Network failure → fail OPEN (allow resume, offline mode)
   └─ Invalid session → full initialization
5. ✅ Resume — show main screen immediately

The key decision is fail-open on network errors. If the device is offline or the backend is slow, the coordinator allows resume with the cached session. The user sees their data instantly. Full initialization runs in the background after the main screen is shown, deferring device registration, analytics, and notification setup by 2 seconds.

This means a returning user with cached data sees their app in under 500ms, even on a cold launch. The session validation and package initialization happen invisibly behind the UI.


The Session Data Model

Everything revolves around a single serializable object:

class ZoviaSession {
  final String sessionToken;
  final String zId;           // Parse user objectId
  final String? displayName;
  final String? email;
  final ZoviaTier tier;       // free, premium, gold, coPremium
  final DateTime? expiresAt;
  final String createdByApp;  // Which app created this session
  final int version;          // Monotonically incrementing
}

The createdByApp field records which app originally created the session. This is metadata — it doesn’t affect behavior — but it’s invaluable for debugging. When a user reports “I was logged out of Zyve,” you can check: which app created the session? Which app last wrote to it? Did a tombstone appear?

The tier field means any app can render the correct paywall state (free vs. premium features) without a backend call on launch. The tier is updated in the session whenever it changes, and the version increment triggers all other apps to pick up the new tier.

Equality is defined on three fields only: sessionToken, userId, and version. Display name changes don’t trigger cross-app updates. Tier changes do (because they increment the version).


Results

The system runs across our production apps on iOS and Android:

Cross-app login detection:  < 1 second (via broadcast)
Fallback detection:          ≤ 5 seconds (via polling)
Fast resume (cold launch):   < 500ms
Session storage overhead:    ~2KB per app (shared keychain/provider)
Platform mechanisms:         2 (Keychain + Darwin / ContentProvider + BroadcastIntent)

A user installing their second Zovia app is authenticated before the onboarding screen finishes its animation. Logout from any app propagates to all others within a second. Network failures don’t lock users out. And the entire system works without a central “identity app” that needs to be installed first.


Design Decisions Worth Stealing

These are the choices we’d make again. Consider them for your own multi-app ecosystem.

Platform-native sharing over cloud sync. It’s tempting to solve cross-app auth with a backend check — each app calls validateSession on launch. But that requires network, adds latency, and fails offline. Platform-native sharing (keychain groups, ContentProviders) is instant, offline-capable, and doesn’t cost API calls. Use the backend for validation, not for sharing.

Dual-layer notification: broadcast + poll. Any IPC mechanism can fail silently — broadcasts dropped under memory pressure, receivers destroyed when processes are killed. A polling fallback with version-based change detection (O(1) per tick) guarantees eventual consistency. The broadcast is an optimization, not a requirement. Design your system to work correctly with polling alone, then add broadcasts for speed.

Tombstone before delete. When sharing state across processes, deletion is the hardest operation to coordinate. A tombstone — a marker that says “anything older than this timestamp is invalid” — turns deletion into a write, which is much easier to reason about. Write the tombstone, then delete. Readers check the tombstone first.

Guard clauses on destructive recovery. When a session error triggers cleanup, always check whether this app actually has a session worth cleaning up. A newly installed app hitting a 209 error should not be able to wipe another app’s valid session from shared storage. The guard is one if statement; the bug it prevents takes hours to diagnose.

Fail-open for returning users. If the backend is unreachable and the user has a cached session and cached data, let them in. The worst case is they see slightly stale data. The alternative — blocking the app on a loading screen until the network returns — is a worse user experience. Validate in the background, correct if needed.

REST API login without installation binding. If your backend ties sessions to device installations, cross-app session sharing breaks — logging into App B invalidates App A’s session. Use an API path that creates unbound sessions. This is the single most important server-side decision for multi-app auth.

Version integers over content hashing. For change detection on shared state, a monotonically incrementing integer is simpler and faster than hashing the full payload. Increment on every write. Compare on every read. O(1) in both directions.


What’s Next

The system continues to evolve:

  • Cross-device session sharing. The current architecture is per-device. Extending it to sync sessions across a user’s iPhone and iPad would require a cloud relay — the platform-native mechanisms don’t cross device boundaries.
  • App-aware session scoping. Currently, all apps share one session. If a user wanted to be logged into different accounts on different apps (unlikely but possible), the system would need per-app session slots within the shared storage.
  • Biometric re-authentication. For sensitive operations (deleting an account, changing email), requiring a FaceID/fingerprint confirmation would add a security layer without disrupting the seamless cross-app experience.

The hard lesson from building this: the gap between “users can log in” and “users are seamlessly authenticated across an ecosystem” is where all the platform engineering lives. Login is a solved problem. Session sharing across sandboxed apps, with race condition safety, offline support, and sub-second propagation — that’s where an auth system becomes an experience.


Built at Zovia Studio. We ship apps that families use every day, and we take the “every day” part seriously.

← Back to Blog