Skip to content

Mobile & Native App Integration

Mobile and desktop applications are public clients — like SPAs, they cannot securely store a client secret. SingleSign uses the Authorization Code flow with PKCE to secure authentication for these platforms.

The key difference from browser-based SPAs is how the authorization callback is captured: mobile apps use custom URI schemes (e.g., com.yourapp://callback) or universal links / app links instead of an HTTPS redirect.


Flow Overview

┌──────────┐     ┌────────────────┐     ┌──────────────┐
│  Mobile   │──1─▶│  System Browser │──2─▶│  SingleSign   │
│  App      │     │  / In-App Tab   │     │  /authorize   │
│           │◀─3──│  (redirect)     │◀─3──│  (callback)   │
│           │──4─▶│                 │     │  /token       │
│           │◀─5──│                 │     │               │
└──────────┘     └────────────────┘     └──────────────┘
  1. App opens the system browser (or an in-app browser tab) to /oauth/authorize.
  2. User authenticates with SingleSign.
  3. SingleSign redirects to your custom URI scheme; the OS routes it back to your app.
  4. App exchanges the authorization code for tokens via /oauth/token.
  5. App receives and stores tokens securely.

Prerequisites

  • A registered SingleSign app with Application Type set to "Mobile / Native."
  • A custom URI scheme registered as a redirect URI in the developer portal.
  • Your client_id and platform_guid.

Step 1: Choose a Redirect Strategy

Custom URI Schemes

Register a unique scheme based on your app's bundle identifier:

com.yourcompany.yourapp://callback
  • iOS: Add the scheme to Info.plist under CFBundleURLTypes.
  • Android: Add an <intent-filter> in AndroidManifest.xml.

For stronger security, use verified HTTPS links that the OS associates with your app:

https://auth.yourapp.com/callback
  • iOS: Configure an apple-app-site-association file on your domain.
  • Android: Configure a Digital Asset Links file (assetlinks.json) on your domain.

Why universal links? Custom URI schemes can be hijacked by a malicious app that registers the same scheme. Universal links are verified by the OS against your domain.

Register your chosen redirect URI in the SingleSign developer portal.


Step 2: Platform Setup

iOS (Swift)

Info.plist — Register custom URL scheme:

<key>CFBundleURLTypes</key>
<array>
  <dict>
    <key>CFBundleURLSchemes</key>
    <array>
      <string>com.yourcompany.yourapp</string>
    </array>
  </dict>
</array>

SceneDelegate.swift — Handle the callback:

func scene(_ scene: UIScene,
           openURLContexts URLContexts: Set<UIOpenURLContext>) {
    guard let url = URLContexts.first?.url else { return }
    SingleSignAuth.shared.handleCallback(url: url)
}

Android (Kotlin)

AndroidManifest.xml — Intent filter:

<activity android:name=".AuthCallbackActivity"
          android:exported="true">
    <intent-filter>
        <action android:name="android.intent.action.VIEW" />
        <category android:name="android.intent.category.DEFAULT" />
        <category android:name="android.intent.category.BROWSABLE" />
        <data android:scheme="com.yourcompany.yourapp"
              android:host="callback" />
    </intent-filter>
</activity>

AuthCallbackActivity.kt — Handle the callback:

class AuthCallbackActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        val uri = intent.data ?: return
        SingleSignAuth.handleCallback(uri)
        finish()
    }
}

Step 3: PKCE Implementation

Both platforms generate PKCE values the same way. Here's the logic in each language:

Swift

import CryptoKit
import Foundation

struct PKCE {
    let codeVerifier: String
    let codeChallenge: String

    init() {
        var buffer = [UInt8](repeating: 0, count: 32)
        _ = SecRandomCopyBytes(kSecRandomDefault, buffer.count, &buffer)
        self.codeVerifier = Data(buffer).base64URLEncodedString()
        
        let challengeData = Data(SHA256.hash(data: Data(codeVerifier.utf8)))
        self.codeChallenge = challengeData.base64URLEncodedString()
    }
}

extension Data {
    func base64URLEncodedString() -> String {
        base64EncodedString()
            .replacingOccurrences(of: "+", with: "-")
            .replacingOccurrences(of: "/", with: "_")
            .replacingOccurrences(of: "=", with: "")
    }
}

Kotlin

import java.security.MessageDigest
import java.security.SecureRandom
import android.util.Base64

data class PKCE(val codeVerifier: String, val codeChallenge: String) {
    companion object {
        fun generate(): PKCE {
            val bytes = ByteArray(32)
            SecureRandom().nextBytes(bytes)
            val verifier = Base64.encodeToString(
                bytes, Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP
            )
            val digest = MessageDigest.getInstance("SHA-256").digest(verifier.toByteArray())
            val challenge = Base64.encodeToString(
                digest, Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP
            )
            return PKCE(verifier, challenge)
        }
    }
}

Step 4: Open the Authorization URL

Use the system browser or an in-app browser tab — never an embedded WebView, which allows the app to intercept credentials.

iOS — ASWebAuthenticationSession

import AuthenticationServices

func startLogin() {
    let pkce = PKCE()
    let state = UUID().uuidString

    // Store for later
    KeychainHelper.save(key: "pkce_verifier", value: pkce.codeVerifier)
    KeychainHelper.save(key: "oauth_state", value: state)

    var components = URLComponents(string: "https://api.singlesign.com/oauth/authorize")!
    components.queryItems = [
        URLQueryItem(name: "platform_guid", value: Config.platformGuid),
        URLQueryItem(name: "response_type", value: "code"),
        URLQueryItem(name: "client_id", value: Config.clientId),
        URLQueryItem(name: "redirect_uri", value: "com.yourcompany.yourapp://callback"),
        URLQueryItem(name: "scope", value: "openid profile email"),
        URLQueryItem(name: "state", value: state),
        URLQueryItem(name: "code_challenge", value: pkce.codeChallenge),
        URLQueryItem(name: "code_challenge_method", value: "S256"),
    ]

    let session = ASWebAuthenticationSession(
        url: components.url!,
        callbackURLScheme: "com.yourcompany.yourapp"
    ) { callbackURL, error in
        guard let url = callbackURL, error == nil else { return }
        self.handleCallback(url: url)
    }

    session.presentationContextProvider = self
    session.start()
}

Android — Custom Tabs

import androidx.browser.customtabs.CustomTabsIntent
import android.net.Uri

fun startLogin(activity: Activity) {
    val pkce = PKCE.generate()
    val state = java.util.UUID.randomUUID().toString()

    // Store for later
    SecureStorage.save("pkce_verifier", pkce.codeVerifier)
    SecureStorage.save("oauth_state", state)

    val uri = Uri.parse("https://api.singlesign.com/oauth/authorize").buildUpon()
        .appendQueryParameter("platform_guid", Config.PLATFORM_GUID)
        .appendQueryParameter("response_type", "code")
        .appendQueryParameter("client_id", Config.CLIENT_ID)
        .appendQueryParameter("redirect_uri", "com.yourcompany.yourapp://callback")
        .appendQueryParameter("scope", "openid profile email")
        .appendQueryParameter("state", state)
        .appendQueryParameter("code_challenge", pkce.codeChallenge)
        .appendQueryParameter("code_challenge_method", "S256")
        .build()

    CustomTabsIntent.Builder().build().launchUrl(activity, uri)
}

Step 5: Exchange Code for Tokens

After capturing the callback URL, extract the code and state, then exchange server-side (or from the app).

Swift

func handleCallback(url: URL) {
    let components = URLComponents(url: url, resolvingAgainstBaseURL: false)!
    let code = components.queryItems?.first(where: { $0.name == "code" })?.value
    let returnedState = components.queryItems?.first(where: { $0.name == "state" })?.value

    guard returnedState == KeychainHelper.load(key: "oauth_state") else {
        print("State mismatch")
        return
    }

    let verifier = KeychainHelper.load(key: "pkce_verifier")!

    var request = URLRequest(url: URL(string: "https://api.singlesign.com/oauth/token")!)
    request.httpMethod = "POST"
    request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")

    let body = [
        "grant_type=authorization_code",
        "code=\(code!)",
        "redirect_uri=com.yourcompany.yourapp://callback",
        "client_id=\(Config.clientId)",
        "code_verifier=\(verifier)",
        "platform_guid=\(Config.platformGuid)",
    ].joined(separator: "&")

    request.httpBody = body.data(using: .utf8)

    URLSession.shared.dataTask(with: request) { data, _, error in
        guard let data = data, error == nil else { return }
        let tokens = try? JSONDecoder().decode(TokenResponse.self, from: data)
        // Store tokens securely in the Keychain
        KeychainHelper.save(key: "access_token", value: tokens?.accessToken ?? "")
    }.resume()
}

Kotlin

suspend fun handleCallback(uri: Uri) {
    val code = uri.getQueryParameter("code") ?: return
    val returnedState = uri.getQueryParameter("state")
    val savedState = SecureStorage.load("oauth_state")

    if (returnedState != savedState) {
        Log.e("Auth", "State mismatch")
        return
    }

    val verifier = SecureStorage.load("pkce_verifier")!!

    val body = FormBody.Builder()
        .add("grant_type", "authorization_code")
        .add("code", code)
        .add("redirect_uri", "com.yourcompany.yourapp://callback")
        .add("client_id", Config.CLIENT_ID)
        .add("code_verifier", verifier)
        .add("platform_guid", Config.PLATFORM_GUID)
        .build()

    val request = Request.Builder()
        .url("https://api.singlesign.com/oauth/token")
        .post(body)
        .build()

    val response = OkHttpClient().newCall(request).execute()
    val tokens = Json.decodeFromString<TokenResponse>(response.body!!.string())

    // Store tokens securely
    SecureStorage.save("access_token", tokens.accessToken)
}

Secure Token Storage

PlatformRecommended StorageNotes
iOSKeychain ServicesHardware-backed, encrypted, survives app reinstalls.
AndroidEncryptedSharedPreferences / KeystoreAES-256 encryption backed by hardware security module.
DesktopOS credential managermacOS Keychain, Windows Credential Manager, libsecret on Linux.

Never store tokens in plain text, UserDefaults, SharedPreferences, or files on disk.


Security Best Practices for Mobile

  1. Use ASWebAuthenticationSession (iOS) or Custom Tabs (Android) — never embedded WebView.
  2. Prefer universal links / app links over custom URI schemes for production.
  3. Always validate the state parameter to prevent CSRF.
  4. Store tokens in the platform keychain/keystore, not in app-local storage.
  5. Use certificate pinning to prevent MITM attacks on the token exchange.
  6. Enable biometric or PIN protection for accessing stored tokens on sensitive apps.

Next Steps

Last updated: 2026-02-16