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──│ │ │ │
└──────────┘ └────────────────┘ └──────────────┘
- App opens the system browser (or an in-app browser tab) to
/oauth/authorize. - User authenticates with SingleSign.
- SingleSign redirects to your custom URI scheme; the OS routes it back to your app.
- App exchanges the authorization code for tokens via
/oauth/token. - 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_idandplatform_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.plistunderCFBundleURLTypes. - Android: Add an
<intent-filter>inAndroidManifest.xml.
Universal Links / App Links (Recommended for Production)
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-associationfile 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
| Platform | Recommended Storage | Notes |
|---|---|---|
| iOS | Keychain Services | Hardware-backed, encrypted, survives app reinstalls. |
| Android | EncryptedSharedPreferences / Keystore | AES-256 encryption backed by hardware security module. |
| Desktop | OS credential manager | macOS Keychain, Windows Credential Manager, libsecret on Linux. |
Never store tokens in plain text, UserDefaults, SharedPreferences, or files on disk.
Security Best Practices for Mobile
- Use
ASWebAuthenticationSession(iOS) or Custom Tabs (Android) — never embeddedWebView. - Prefer universal links / app links over custom URI schemes for production.
- Always validate the
stateparameter to prevent CSRF. - Store tokens in the platform keychain/keystore, not in app-local storage.
- Use certificate pinning to prevent MITM attacks on the token exchange.
- Enable biometric or PIN protection for accessing stored tokens on sensitive apps.
Next Steps
- PKCE Concepts — Understand why PKCE is essential for mobile.
- Redirect URIs — Rules for custom schemes and universal links.
- Tokens & Claims — Decode and verify your JWTs.
- Troubleshooting — Fix common mobile integration issues.
Last updated: 2026-02-16
Related docs
- SingleSign Developer DocumentationLearn how to integrate SingleSign identity and authentication into your applications using OAuth 2.0 and OpenID Connect.
- Mobile app authenticationMobile app authentication for iOS and Android: Authorization Code with PKCE in a system browser, custom scheme redirects, and secure token storage on device.
- SaaS single sign-onSSO for SaaS applications using OAuth 2.0 and OpenID Connect: the flow to pick, the scopes to request, and a working integration path with SingleSign.
- React authentication with PKCEReact authentication done the recommended way: Authorization Code with PKCE, and no client secret in your JavaScript bundle.
- Proof Key for Code Exchange (PKCE)Why PKCE exists, how the S256 challenge method works, and when to use it with SingleSign.
- free identity providerSingleSign pricing in one line: sign-in is free, with no monthly-active-user meter. See exactly what is included, and what SingleSign Mail costs for businesses.
- identity provider alternativesIdentity provider alternatives, by the provider you are leaving: what actually has to change in your code, and what does not.
- Auth0 comparisonSingleSign vs Auth0 compared on the difference that matters: who owns the account. A side-by-side table, then the three cases where each one is the right call.