Mobile applications handle some of the most sensitive data in your users' digital lives -- banking credentials, health records, personal messages, and payment information. Unlike web applications that benefit from the browser's built-in security sandbox, mobile apps operate in an environment where the device itself is the attack surface. Reverse engineering an APK or IPA is trivial with freely available tools. Network traffic can be intercepted on compromised Wi-Fi. And users routinely install apps from unknown sources or jailbreak their devices.
Building secure mobile authentication is not about checking a compliance box. It is about understanding the threat model unique to mobile platforms and implementing layered defenses that protect users even when individual layers are compromised. This guide covers the essential techniques every mobile development team needs to implement: OAuth 2.0 with PKCE, biometric authentication, secure token storage, certificate pinning, and practical strategies for avoiding the most common vulnerabilities.
OAuth 2.0 and PKCE: The Standard for Mobile Authentication
The OAuth 2.0 Authorization Code flow with Proof Key for Code Exchange (PKCE) has become the industry standard for mobile authentication. Unlike the implicit flow (which should never be used in mobile apps), PKCE protects against authorization code interception attacks -- a real threat on mobile platforms where custom URL schemes can be hijacked by malicious apps.
The PKCE flow works as follows: your app generates a cryptographically random code_verifier and derives a code_challenge from it using SHA-256. The code_challenge is sent with the authorization request, but the code_verifier stays on the device. When the authorization server returns an authorization code, your app exchanges it for tokens by presenting the original code_verifier. The server verifies that the code_verifier matches the code_challenge it received earlier, ensuring that only the app that initiated the request can complete it.
Here is how you implement the PKCE flow in a React Native application using react-native-app-auth, which wraps the AppAuth libraries for both iOS and Android:
import { authorize, AuthConfiguration } from 'react-native-app-auth';
const config: AuthConfiguration = {
issuer: 'https://auth.example.com',
clientId: 'mobile-app-client',
redirectUrl: 'com.example.app://oauth/callback',
scopes: ['openid', 'profile', 'email', 'offline_access'],
usePKCE: true,
additionalParameters: {
prompt: 'consent',
},
};
async function login(): Promise<void> {
try {
const result = await authorize(config);
// result contains accessToken, refreshToken, idToken,
// accessTokenExpirationDate, and tokenAdditionalParameters
await securelyStoreTokens(result.accessToken, result.refreshToken);
} catch (error) {
console.error('Authentication failed:', error);
}
}
On native iOS with Swift, the equivalent uses ASWebAuthenticationSession, which provides a secure, system-managed browser context that isolates cookies and session data from your app and from Safari:
import AuthenticationServices
class AuthManager: NSObject, ASWebAuthenticationPresentationContextProviding {
func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor {
return UIApplication.shared.windows.first { $0.isKeyWindow }!
}
func authenticate() {
let codeVerifier = generateCodeVerifier()
let codeChallenge = generateCodeChallenge(from: codeVerifier)
var components = URLComponents(string: "https://auth.example.com/authorize")!
components.queryItems = [
URLQueryItem(name: "client_id", value: "mobile-app-client"),
URLQueryItem(name: "redirect_uri", value: "com.example.app://oauth/callback"),
URLQueryItem(name: "response_type", value: "code"),
URLQueryItem(name: "scope", value: "openid profile email"),
URLQueryItem(name: "code_challenge", value: codeChallenge),
URLQueryItem(name: "code_challenge_method", value: "S256"),
]
let session = ASWebAuthenticationSession(
url: components.url!,
callbackURLScheme: "com.example.app"
) { callbackURL, error in
guard let url = callbackURL,
let code = URLComponents(url: url, resolvingAgainstBaseURL: false)?
.queryItems?.first(where: { $0.name == "code" })?.value
else { return }
self.exchangeCodeForTokens(code: code, codeVerifier: codeVerifier)
}
session.presentationContextProvider = self
session.prefersEphemeralWebBrowserSession = true
session.start()
}
}
A critical detail: never embed a WebView for authentication. WebViews give the host app access to the user's credentials and cookies. System-managed browser contexts like ASWebAuthenticationSession on iOS and Chrome Custom Tabs on Android provide the security isolation that WebViews cannot.
Biometric Authentication: Face ID, Touch ID, and Fingerprint
Biometric authentication adds a layer of security that is both stronger and more convenient than passwords. On iOS, Face ID and Touch ID are managed by the Secure Enclave -- a dedicated hardware security processor that stores biometric templates in an encrypted form that never leaves the device. On Android, the BiometricPrompt API provides a unified interface for fingerprint sensors, face recognition, and iris scanners across device manufacturers.
The key architectural decision is what biometric authentication actually protects. In most implementations, biometrics are not a replacement for server-side authentication. Instead, they gate access to securely stored credentials (tokens, encryption keys) on the device. The user authenticates with the server once using OAuth, the tokens are stored in the platform's secure storage, and subsequent access to those tokens requires biometric verification.
Here is a Swift implementation using the Local Authentication framework:
import LocalAuthentication
class BiometricAuthManager {
func authenticateWithBiometrics(completion: @escaping (Bool, Error?) -> Void) {
let context = LAContext()
var error: NSError?
guard context.canEvaluatePolicy(
.deviceOwnerAuthenticationWithBiometrics,
error: &error
) else {
completion(false, error)
return
}
// Check the specific biometric type available
switch context.biometryType {
case .faceID:
print("Face ID available")
case .touchID:
print("Touch ID available")
case .opticID:
print("Optic ID available")
default:
print("No biometric available")
}
context.evaluatePolicy(
.deviceOwnerAuthenticationWithBiometrics,
localizedReason: "Authenticate to access your account"
) { success, evaluateError in
DispatchQueue.main.async {
completion(success, evaluateError)
}
}
}
}
On Android with Kotlin, the BiometricPrompt API provides a consistent experience:
import androidx.biometric.BiometricPrompt
import androidx.core.content.ContextCompat
import androidx.fragment.app.FragmentActivity
class BiometricAuthManager(private val activity: FragmentActivity) {
fun authenticate(onSuccess: () -> Unit, onFailure: (String) -> Unit) {
val executor = ContextCompat.getMainExecutor(activity)
val prompt = BiometricPrompt(activity, executor,
object : BiometricPrompt.AuthenticationCallback() {
override fun onAuthenticationSucceeded(
result: BiometricPrompt.AuthenticationResult
) {
super.onAuthenticationSucceeded(result)
onSuccess()
}
override fun onAuthenticationError(
errorCode: Int, errString: CharSequence
) {
super.onAuthenticationError(errorCode, errString)
onFailure(errString.toString())
}
override fun onAuthenticationFailed() {
super.onAuthenticationFailed()
onFailure("Biometric authentication failed")
}
})
val promptInfo = BiometricPrompt.PromptInfo.Builder()
.setTitle("Authenticate")
.setSubtitle("Verify your identity to continue")
.setAllowedAuthenticators(
BiometricPrompt.Authenticators.BIOMETRIC_STRONG
)
.setNegativeButtonText("Use password")
.build()
prompt.authenticate(promptInfo)
}
}
Always provide a fallback authentication method. Biometric enrollment is optional, devices may lack biometric hardware, and biometric data can change (for example, after a significant facial injury). A secure PIN or password fallback ensures users are never locked out of their own accounts.
Secure Token Storage: Keychain and Keystore
Storing authentication tokens correctly is one of the most frequently mishandled aspects of mobile security. Tokens stored in plain text in SharedPreferences (Android) or UserDefaults (iOS) can be extracted from device backups, accessed by other apps on rooted or jailbroken devices, or leaked through logging.
On iOS, the Keychain Services API provides hardware-backed encrypted storage. Items stored in the Keychain are encrypted with a key derived from the device's Secure Enclave and are not included in unencrypted backups. You can further restrict access using access control flags:
import Security
class SecureTokenStorage {
func storeToken(_ token: String, for key: String) -> Bool {
guard let data = token.data(using: .utf8) else { return false }
// Define access control: require biometric authentication
// to read the token
var accessError: Unmanaged<CFError>?
guard let accessControl = SecAccessControlCreateWithFlags(
kCFAllocatorDefault,
kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
.biometryCurrentSet,
&accessError
) else { return false }
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key,
kSecValueData as String: data,
kSecAttrAccessControl as String: accessControl,
]
// Delete any existing item first
SecItemDelete(query as CFDictionary)
let status = SecItemAdd(query as CFDictionary, nil)
return status == errSecSuccess
}
func retrieveToken(for key: String) -> String? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne,
]
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
guard status == errSecSuccess,
let data = result as? Data,
let token = String(data: data, encoding: .utf8)
else { return nil }
return token
}
}
On Android, the Android Keystore system provides equivalent protection. Keys are stored in a hardware-backed keystore (on devices with a Trusted Execution Environment or Secure Element), and cryptographic operations happen inside the secure hardware:
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import java.security.KeyStore
import javax.crypto.Cipher
import javax.crypto.KeyGenerator
import javax.crypto.SecretKey
import javax.crypto.spec.GCMParameterSpec
class SecureTokenStorage(private val context: Context) {
private val keyAlias = "auth_token_key"
private val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
private fun getOrCreateKey(): SecretKey {
keyStore.getKey(keyAlias, null)?.let { return it as SecretKey }
val keyGenerator = KeyGenerator.getInstance(
KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore"
)
keyGenerator.init(
KeyGenParameterSpec.Builder(
keyAlias,
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
)
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.setUserAuthenticationRequired(true)
.setUserAuthenticationParameters(300, // 5 minute timeout
KeyProperties.AUTH_BIOMETRIC_STRONG)
.build()
)
return keyGenerator.generateKey()
}
fun encrypt(plainText: String): Pair<ByteArray, ByteArray> {
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
cipher.init(Cipher.ENCRYPT_MODE, getOrCreateKey())
val iv = cipher.iv
val encrypted = cipher.doFinal(plainText.toByteArray(Charsets.UTF_8))
return Pair(iv, encrypted)
}
}
For React Native applications, the react-native-keychain library provides a cross-platform abstraction over both Keychain and Keystore:
import * as Keychain from 'react-native-keychain';
async function storeTokens(
accessToken: string,
refreshToken: string
): Promise<void> {
await Keychain.setGenericPassword('auth_tokens', JSON.stringify({
accessToken,
refreshToken,
}), {
accessControl: Keychain.ACCESS_CONTROL.BIOMETRY_CURRENT_SET,
accessible: Keychain.ACCESSIBLE.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
securityLevel: Keychain.SECURITY_LEVEL.SECURE_HARDWARE,
});
}
async function getTokens(): Promise<{ accessToken: string; refreshToken: string } | null> {
const credentials = await Keychain.getGenericPassword();
if (credentials) {
return JSON.parse(credentials.password);
}
return null;
}
The WHEN_UNLOCKED_THIS_DEVICE_ONLY accessibility level ensures tokens are only accessible when the device is unlocked and are never migrated to a new device through backups or device transfer. This is the most secure option for authentication tokens.
Certificate Pinning: Preventing Man-in-the-Middle Attacks
TLS protects data in transit, but it relies on the device's trust store to verify server certificates. On a compromised device -- or on a network where a corporate proxy has installed its own root certificate -- TLS alone is not sufficient. Certificate pinning binds your app to a specific server certificate or public key, rejecting connections even if a technically valid but unexpected certificate is presented.
There are two approaches: pinning the full certificate or pinning only the public key. Public key pinning is preferred because it survives certificate renewals as long as the same key pair is used.
In a React Native app using axios with react-native-ssl-pinning:
import { fetch as pinnedFetch } from 'react-native-ssl-pinning';
async function makeSecureRequest(endpoint: string): Promise<any> {
const response = await pinnedFetch(`https://api.example.com${endpoint}`, {
method: 'GET',
headers: {
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
sslPinning: {
certs: ['api-server-cert'], // references bundled .cer file
},
timeoutInterval: 10000,
});
return response.json();
}
On native iOS with URLSession, you implement pinning in the session delegate:
class PinnedSessionDelegate: NSObject, URLSessionDelegate {
private let pinnedPublicKeyHash = "base64EncodedSHA256HashOfPublicKey"
func urlSession(
_ session: URLSession,
didReceive challenge: URLAuthenticationChallenge,
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
) {
guard let serverTrust = challenge.protectionSpace.serverTrust,
let certificate = SecTrustGetCertificateAtIndex(serverTrust, 0),
let publicKey = SecCertificateCopyKey(certificate),
let publicKeyData = SecKeyCopyExternalRepresentation(publicKey, nil)
else {
completionHandler(.cancelAuthenticationChallenge, nil)
return
}
let keyHash = (publicKeyData as Data).sha256().base64EncodedString()
if keyHash == pinnedPublicKeyHash {
completionHandler(.useCredential, URLCredential(trust: serverTrust))
} else {
completionHandler(.cancelAuthenticationChallenge, nil)
}
}
}
A word of caution: certificate pinning requires careful operational planning. If you pin to a specific certificate and that certificate expires or is rotated, your app will stop working until users update. Always pin to the public key rather than the full certificate, maintain a backup pin for your next key, and implement a mechanism to update pins remotely in emergency situations.
Common Vulnerabilities and Security Testing
Even with strong authentication and secure storage, mobile apps face a range of vulnerabilities that require active defense.
Insecure data logging is surprisingly common. Debug logging that includes tokens, passwords, or PII can be captured by any app on the device that has log access. Ensure all sensitive data is stripped from logs in release builds. On iOS, use os_log with appropriate privacy levels. On Android, use ProGuard or R8 rules to strip log calls from release builds.
Clipboard exposure is another risk. When users copy passwords or tokens, that data sits on the clipboard where other apps can read it. Implement UIPasteboardNameFind exclusion on iOS and ClipboardManager restrictions on Android, and consider clearing sensitive clipboard content after a timeout.
Root and jailbreak detection provides a layer of defense against environments where the OS security model is compromised. Libraries like ios-jailbreak-detector and Google's Play Integrity API (which replaced SafetyNet) help detect tampered environments. These checks are not foolproof -- determined attackers can bypass them -- but they raise the bar significantly.
For security testing, several tools are essential for mobile development teams:
- OWASP MAS (Mobile Application Security) -- the standard checklist and testing guide for mobile security assessments.
- Frida -- a dynamic instrumentation toolkit that lets you inject scripts into running apps to test for vulnerabilities.
- MobSF (Mobile Security Framework) -- an automated security analysis tool that performs static and dynamic analysis of Android and iOS apps.
- Burp Suite -- an HTTP proxy for intercepting and analyzing network traffic, essential for testing API security and certificate pinning.
- objection -- a runtime mobile exploration toolkit built on Frida, designed for assessing mobile apps without jailbreaking or rooting.
Run these tools as part of your CI/CD pipeline and before every release. Security is not a one-time audit; it is an ongoing practice that must evolve as new attack vectors emerge.
Building Secure Mobile Apps with Expert Guidance
Mobile authentication and security are not features you bolt on at the end of a project. They are architectural decisions that need to be made early and implemented correctly from the start. The difference between a secure app and a vulnerable one often comes down to subtle implementation details -- using WHEN_UNLOCKED_THIS_DEVICE_ONLY instead of AFTER_FIRST_UNLOCK, implementing PKCE instead of the implicit flow, or pinning public keys instead of certificates.
At Maranatha Technologies, we build mobile applications with security as a foundational concern, not an afterthought. From authentication architecture to secure storage to penetration testing, our team ensures your users' data is protected at every layer. If you are building a mobile app that handles sensitive data and want to get security right from day one, explore our mobile app development services or reach out to discuss your project.