← Back to writing

Designing Cross-App SSO on Android

Sharing one signed-in identity across several apps on the same device with AccountManager, and the lifecycle problems that come with it.

  • Android
  • Identity
  • Architecture
  • IPC

The question

In multi-application ecosystems—whether enterprise suites, interactive classroom hardware, or related consumer apps—users expect to sign in once.

On Android, each application is strictly sandboxed inside its own Linux user ID and private storage directory (/data/data/<package_name>). App A cannot peek into App B’s SharedPreferences or database.

So the engineering question becomes:

How do several distinct APKs on a single device agree on who is signed in, refresh credentials safely, and stay in sync—without each app maintaining its own brittle copy of the truth?

My initial mental model

Early in my career, my first instinct was to share authentication tokens through an exported ContentProvider or external storage:

[App A] ──┐
          ├─► Shared ContentProvider / Encrypted File ──► Stored Token
[App B] ──┘

This model breaks down almost immediately:

  1. Security Vulnerabilities: An exported ContentProvider or shared storage location exposes credentials to eavesdropping by untrusted third-party apps on the device unless guarded by strict signature permissions.
  2. Desynchronized Invalidation: If App A discovers the access token has expired and fetches a new pair, App B still holds the dead token.
  3. The Stampeding Refresh Stampede: If five apps start simultaneously, all five detect an expired token and issue parallel refresh calls to your backend API, triggering token rotation failures.

What actually happens: Native AccountManager

Android provides a dedicated operating system framework specifically designed for system-wide account coordination: AccountManager backed by an AbstractAccountAuthenticator service.

                  System Service: AccountManagerService
                                   ▲
                                   │ Binder IPC
    ┌──────────────────────────────┴──────────────────────────────┐
    │                                                             │
[Client App: Whiteboard]                                  [Client App: EduAI]
  Calls: getAuthToken()                                     Calls: getAuthToken()
    │                                                             │
    └──────────────────────────────┬──────────────────────────────┘
                                   │ Delegated IPC
                                   ▼
                   [Host App: Authenticator Service]
                   - Manages secure account credentials
                   - Negotiates OAuth token refresh
                   - Serializes network requests

Instead of each app managing its own network client and token cache:

  • The Host APK implements an AccountAuthenticator service registered in the Android manifest with an android.accounts.AccountAuthenticator XML metadata resource.
  • The Client APKs do not know or care how credentials are authenticated. They simply ask AccountManager: “Give me a valid auth token for account type com.example.account.”

Let’s look at the implementation

1. Hardening with Signature Permissions

To prevent malicious apps from querying user accounts, declare a custom permission with protectionLevel="signature" in your Host APK:

<!-- Host Manifest -->
<permission
    android:name="com.example.auth.PERMISSION_MANAGE_ACCOUNTS"
    android:protectionLevel="signature" />

<service
    android:name=".authenticator.CustomAccountAuthenticatorService"
    android:exported="true"
    android:permission="com.example.auth.PERMISSION_MANAGE_ACCOUNTS">
    <intent-filter>
        <action android:name="android.accounts.AccountAuthenticator" />
    </intent-filter>
    <meta-data
        android:name="android.accounts.AccountAuthenticator"
        android:resource="@xml/authenticator" />
</service>

Because protectionLevel="signature" is enforced at the Linux kernel and package manager level, Android only grants this permission to apps signed with your identical release keystore.

2. The Transparent Auth Token Loop

In client applications, requesting tokens is completely agnostic of token expiration logic:

// Client application code
class SessionTokenProvider(private val context: Context) {
    private val accountManager = AccountManager.get(context)

    suspend fun getValidBearerToken(account: Account): String = withContext(Dispatchers.IO) {
        val future = accountManager.getAuthToken(
            account,
            "FULL_ACCESS",
            null,       // options bundle
            false,      // notifyAuthFailure
            null, null  // callback, handler
        )
        
        val result = future.result
        result.getString(AccountManager.KEY_AUTHTOKEN) 
            ?: throw IllegalStateException("Token retrieval failed")
    }

    fun invalidateStaleToken(accountType: String, token: String) {
        accountManager.invalidateAuthToken(accountType, token)
    }
}

If the token is cached in AccountManagerService, it returns in microseconds. If invalidateAuthToken was called after a 401 Unauthorized, AccountManager automatically wakes up the Host Authenticator Service to perform the OAuth refresh transparently.

The surprising part: Identity vs. Active Profile

One key insight is that cryptographic identity and runtime UI profile are two distinct problems:

  1. Authentication Token (Identity): Cryptographic keys, refresh tokens, and OAuth scopes. These are durable, change infrequently, and belong strictly in AccountManager.
  2. Active Profile Context (Runtime State): Which student profile, classroom section, or grade level is currently active on screen.

Attempting to store rapid, ephemeral profile changes inside AccountManager.setUserData() creates heavy disk write and IPC overhead.

The cleaner solution is to treat AccountManager as the source of identity, and use a lightweight signature-guarded broadcast channel or ContentObserver for propagating live profile context changes across active apps.

Practical consequences for Android developers

1. Handling Global Account Removal

When an administrator logs out of the device, the host authenticator calls accountManager.removeAccountExplicitly().

Every client app should register an OnAccountsUpdateListener:

accountManager.addOnAccountsUpdatedListener({ accounts ->
    val hasValidAccount = accounts.any { it.type == "com.example.account" }
    if (!hasValidAccount) {
        // Purge local app caches and redirect to welcome screen
        sessionRepository.clearUserData()
        navigateToLogin()
    }
}, Handler(Looper.getMainLooper()), true)

This guarantees instantaneous, synchronized logout across all five applications simultaneously without polling.

What I would remember

  1. Leverage the platform: Don’t build bespoke AIDL singletons for authentication when AccountManager has solved system-wide account coordination since API level 5.
  2. Signature permissions are your security perimeter: They provide rock-solid inter-app authorization with zero runtime permission dialogs.
  3. Decouple identity from active state: Store credentials in AccountManager, but keep dynamic UI context in lightweight reactive pub/sub streams.

Further reading

  • AOSP Source: frameworks/base/core/java/android/accounts/AccountManager.java
  • Android Developers Guide: Creating a Custom Account Authenticator