work · active
Cross-App Android SSO
Designing a robust shared account and identity synchronization layer across multiple standalone apps on dedicated Android hardware.
Overview
In dedicated hardware environments like interactive flat panels and enterprise tablets, users do not interact with a single monolithic application. Instead, they navigate a suite of specialized apps: a digital whiteboard, a document viewer, a classroom management tool, and an AI assistant.
Asking an educator or presenter to authenticate independently into four separate applications on an 86” classroom display disrupts the entire teaching workflow. This project established a unified, device-wide Single Sign-On (SSO) and profile synchronization layer that connects multiple standalone Android apps through a shared identity service.
The problem
Android is designed around aggressive sandboxing: each APK runs with its own Linux User ID (UID), its own process, and isolated storage (/data/data/<package_name>).
- Fragmented Identity: Signing into the Whiteboard app does not grant credentials to the Classroom app.
- Token Invalidation Clashes: When one app refreshes an OAuth bearer token, how do sibling apps know the previous token is now dead?
- Multi-tenant Profile Switching: Teachers frequently switch between different grades or subjects. Switching active classroom contexts in one application needed to immediately synchronize across all open apps without restarting their processes.
Constraints
- Security & Sideloading Risks: IFP hardware frequently accepts sideloaded APKs or USB drives. The identity architecture had to ensure that untrusted third-party apps could not query, intercept, or spoof authentication tokens.
- Offline / Flaky School Networks: Authentication had to support offline token validation and graceful degradation when school Wi-Fi dropped.
- Process Boundaries: Zero reliance on shared memory or static singletons; all communication had to cross Android IPC boundaries via Binder.
My approach
Rather than inventing a proprietary IPC protocol with AIDL and Bound Services from scratch, we based the system on Android’s native AccountManager and AbstractAccountAuthenticator framework, secured by signature-level permissions:
Central Auth Provider (Host APK)
[Custom AccountAuthenticatorService]
│ ▲
getAuthToken │ │ Token Refresh / Revoke
▼ │
Android AccountManager (AOSP OS Layer)
▲ ▲
Binder IPC (Shared Signatures) Binder IPC (Shared Signatures)
│ │
┌───────────────┴──┐ ┌──┴───────────────┐
│ Client App A │ │ Client App B │
│ (Whiteboard/UI) │ │ (Companion App) │
└──────────────────┘ └──────────────────┘
- Signature-Protected Inter-App Permission: We defined a custom permission in the authenticator host app with
android:protectionLevel="signature". Only applications signed with our private release key can request tokens. - Centralized Token Lifecycle: Individual client apps never interact directly with OAuth refresh tokens. They only request an auth token from
AccountManager. If the token is cached and valid, it returns immediately; if expired,AccountManagerinvokes the host authenticator to negotiate the refresh securely. - Synchronized Active Profile: A lightweight custom broadcast channel (secured with the same signature permission) notifies active clients whenever the selected teacher profile or classroom section shifts.
Important decisions
1. Embracing System AccountManager vs. Custom AIDL
Many multi-app suites build ad-hoc AIDL bound services. We deliberately chose AccountManager. It handles Android system account storage securely via AccountManagerService, integrates into Android Settings if needed, and automatically triggers cleanup callbacks if an account is removed by an administrator.
2. Client-Side Token Expiry Handling
Instead of having client apps validate JWT expiration timestamps locally, clients follow a standard failure-driven protocol:
- App makes an API call with the current token.
- If the backend returns
401 Unauthorized, the client invalidates the token usingAccountManager.invalidateAuthToken(). - The client re-requests the token.
AccountManagerautomatically performs a refresh and returns a fresh credential.
What went wrong: The Stampeding Refresh Race
During multi-window stress testing, we launched two companion applications simultaneously immediately after network reconnection.
Both apps noticed their auth tokens had expired and simultaneously called AccountManager.getAuthToken(). Because the authenticator had not finished the network roundtrip for the first app, both instances called the backend /oauth/refresh endpoint with the identical refresh token.
Our backend implemented strict refresh-token rotation: using a refresh token immediately revoked all previous tokens. As a result:
- App A’s refresh succeeded and generated Token Pair 2.
- App B’s refresh arrived 50ms later with Token Pair 1’s refresh token. The backend detected a reuse anomaly, flagged a security violation, and revoked all tokens for the user.
- The user was abruptly logged out of all apps.
Debugging & Investigation
We captured HTTP traffic using an intercepting proxy and correlated timestamps against logcat IPC traces:
10:14:02.120 [App A] AccountManager.getAuthToken() -> cache miss -> invoke Authenticator
10:14:02.135 [App B] AccountManager.getAuthToken() -> cache miss -> invoke Authenticator
10:14:02.240 [Auth] POST /oauth/refresh (RefreshToken: RT_1) -> Success (RT_2)
10:14:02.285 [Auth] POST /oauth/refresh (RefreshToken: RT_1) -> 400 Token Already Used!
The Fix
Inside our custom AbstractAccountAuthenticator, we wrapped token refresh logic in a coroutine Mutex. If a refresh is already in-flight when another request arrives, subsequent callers suspend on the active deferred job and receive the same refreshed token once completed:
private val refreshMutex = Mutex()
private var activeRefreshJob: Deferred<String>? = null
suspend fun getOrRefreshAuthToken(account: Account): String {
return refreshMutex.withLock {
activeRefreshJob?.await() ?: coroutineScope {
val job = async(Dispatchers.IO) {
performTokenRefreshNetworkCall(account)
}
activeRefreshJob = job
try {
job.await()
} finally {
activeRefreshJob = null
}
}
}
}
Result
- Frictionless Teacher Experience: Logging in once unlocks every app on the panel instantly.
- Zero Race-Condition Lockouts: Concurrency control eliminated duplicate refresh anomalies.
- Hardened Security Perimeter: Strict signature protection prevents malicious apps from extracting tokens across the Binder bus.
What I learned
- Single-device multi-app systems are distributed systems: You have separate processes, asynchronous network calls, and concurrent actors sharing a single state source. Distributed locking principles apply directly to Android IPC.
- AOSP primitives are deeply capable: Before writing a custom Bound Service or content provider sync mechanism, understanding how Android’s native framework solved the problem usually reveals a more resilient pattern.