Introduction
Last year, a popular Australian fintech app made headlines when researchers discovered their OAuth implementation allowed account takeover through a redirect URI manipulation. The fix took 48 hours; the reputation damage took longer.
OAuth is the standard for mobile app authentication, but implementing it incorrectly creates worse security than having no authentication at all. This guide walks through how to implement OAuth properly and avoid the vulnerabilities we see in security audits.
OAuth 2.0 Basics for Mobile Apps
OAuth 2.0 is an authorization framework, not an authentication protocol. The distinction matters:
- Authorization: “This app can access your photos”
- Authentication: “You are who you claim to be”
For user login, you typically use OAuth 2.0 with OpenID Connect (OIDC), which adds an identity layer. But the security considerations for the OAuth flow itself are critical.
The Authorization Code Flow
Mobile apps should use the Authorization Code flow with PKCE (Proof Key for Code Exchange). Never use the Implicit flow—it’s deprecated for good reason.
┌──────────┐ ┌──────────────┐
│ Mobile │ │ Auth Server │
│ App │ │ (Google, │
│ │ │ Auth0,etc) │
└────┬─────┘ └──────┬───────┘
│ │
│ 1. Generate code_verifier (random string) │
│ Create code_challenge = SHA256(verifier)│
│ │
│ 2. Open browser with auth URL │
│ + code_challenge + state │
│ ──────────────────────────────────────────▶│
│ │
│ User logs in, consents │
│ │
│ 3. Redirect to app with authorization_code │
│◀────────────────────────────────────────── │
│ │
│ 4. Exchange code + code_verifier for tokens│
│ ──────────────────────────────────────────▶│
│ │
│ 5. Return access_token + refresh_token │
│◀────────────────────────────────────────── │
│ │
Implementing PKCE Correctly

PKCE prevents authorization code interception attacks. Here’s how to implement it:
iOS Implementation (Swift)
import CryptoKit
import AuthenticationServices
class OAuthManager {
private var codeVerifier: String?
// Step 1: Generate a cryptographically random code verifier
func generateCodeVerifier() -> String {
var bytes = [UInt8](repeating: 0, count: 32)
_ = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes)
let verifier = Data(bytes).base64EncodedString()
.replacingOccurrences(of: "+", with: "-")
.replacingOccurrences(of: "/", with: "_")
.replacingOccurrences(of: "=", with: "")
.prefix(128)
self.codeVerifier = String(verifier)
return self.codeVerifier!
}
// Step 2: Create the code challenge from the verifier
func generateCodeChallenge(from verifier: String) -> String {
let data = Data(verifier.utf8)
let hash = SHA256.hash(data: data)
return Data(hash).base64EncodedString()
.replacingOccurrences(of: "+", with: "-")
.replacingOccurrences(of: "/", with: "_")
.replacingOccurrences(of: "=", with: "")
}
// Step 3: Build the authorization URL
func buildAuthorizationURL() -> URL? {
let verifier = generateCodeVerifier()
let challenge = generateCodeChallenge(from: verifier)
let state = generateState() // Random string to prevent CSRF
var components = URLComponents(string: "https://auth.example.com/authorize")!
components.queryItems = [
URLQueryItem(name: "response_type", value: "code"),
URLQueryItem(name: "client_id", value: Config.clientID),
URLQueryItem(name: "redirect_uri", value: Config.redirectURI),
URLQueryItem(name: "scope", value: "openid profile email"),
URLQueryItem(name: "code_challenge", value: challenge),
URLQueryItem(name: "code_challenge_method", value: "S256"),
URLQueryItem(name: "state", value: state),
]
// Store state for validation
UserDefaults.standard.set(state, forKey: "oauth_state")
return components.url
}
private func generateState() -> String {
var bytes = [UInt8](repeating: 0, count: 16)
_ = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes)
return Data(bytes).base64EncodedString()
}
}
Android Implementation (Kotlin)
import java.security.MessageDigest
import java.security.SecureRandom
import android.util.Base64
class OAuthManager(private val context: Context) {
private var codeVerifier: String? = null
private var state: String? = null
fun generateCodeVerifier(): String {
val bytes = ByteArray(32)
SecureRandom().nextBytes(bytes)
val verifier = Base64.encodeToString(bytes, Base64.URL_SAFE or Base64.NO_WRAP)
.replace("=", "")
.take(128)
codeVerifier = verifier
return verifier
}
fun generateCodeChallenge(verifier: String): String {
val bytes = verifier.toByteArray(Charsets.UTF_8)
val digest = MessageDigest.getInstance("SHA-256")
val hash = digest.digest(bytes)
return Base64.encodeToString(hash, Base64.URL_SAFE or Base64.NO_WRAP)
.replace("=", "")
}
fun buildAuthorizationUrl(): Uri {
val verifier = generateCodeVerifier()
val challenge = generateCodeChallenge(verifier)
state = generateState()
// Store state for validation
context.getSharedPreferences("oauth", Context.MODE_PRIVATE)
.edit()
.putString("state", state)
.apply()
return Uri.parse("https://auth.example.com/authorize")
.buildUpon()
.appendQueryParameter("response_type", "code")
.appendQueryParameter("client_id", BuildConfig.CLIENT_ID)
.appendQueryParameter("redirect_uri", BuildConfig.REDIRECT_URI)
.appendQueryParameter("scope", "openid profile email")
.appendQueryParameter("code_challenge", challenge)
.appendQueryParameter("code_challenge_method", "S256")
.appendQueryParameter("state", state)
.build()
}
private fun generateState(): String {
val bytes = ByteArray(16)
SecureRandom().nextBytes(bytes)
return Base64.encodeToString(bytes, Base64.URL_SAFE or Base64.NO_WRAP)
}
}
Common Vulnerabilities and How to Prevent
Them
1. Missing State Parameter Validation
The state parameter prevents CSRF attacks. Without it, an attacker can trick a user into linking the attacker’s account to their session.
Vulnerable code:
// DON'T DO THIS
func handleCallback(url: URL) {
let code = extractCode(from: url)
exchangeCodeForTokens(code: code) // Missing state validation!
}
Secure code:
func handleCallback(url: URL) -> Bool {
guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false),
let codeItem = components.queryItems?.first(where: { $0.name == "code" }),
let stateItem = components.queryItems?.first(where: { $0.name == "state" }),
let code = codeItem.value,
let returnedState = stateItem.value else {
return false
}
// Validate state matches what we sent
let storedState = UserDefaults.standard.string(forKey: "oauth_state")
guard returnedState == storedState else {
print("State mismatch - possible CSRF attack")
return false
}
// Clear stored state (one-time use)
UserDefaults.standard.removeObject(forKey: "oauth_state")
exchangeCodeForTokens(code: code)
return true
}
2. Insecure Redirect URI Configuration
On iOS, your redirect URI uses a custom URL scheme. If another app registers the same scheme, they can intercept the callback.
Solution: Use Universal Links (iOS) and App Links (Android):
// Info.plist - Associated Domains
<key>com.apple.developer.associated-domains</key>
<array>
<string>applinks:yourapp.com</string>
</array>
// Use HTTPS redirect URI instead of custom scheme
let redirectURI = "https://yourapp.com/oauth/callback"
Android - App Links in AndroidManifest.xml:
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:scheme="https"
android:host="yourapp.com"
android:pathPrefix="/oauth/callback" />
</intent-filter>
3. Token Storage Vulnerabilities
Storing tokens in SharedPreferences (Android) or UserDefaults (iOS) without encryption is a security risk.
iOS - Use Keychain:
import Security
class TokenStorage {
func saveToken(_ token: String, for key: String) -> Bool {
let data = token.data(using: .utf8)!
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key,
kSecValueData as String: data,
kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly
]
// Delete any existing item
SecItemDelete(query as CFDictionary)
let status = SecItemAdd(query as CFDictionary, nil)
return status == errSecSuccess
}
func getToken(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
}
func deleteToken(for key: String) {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key
]
SecItemDelete(query as CFDictionary)
}
}
Android - Use EncryptedSharedPreferences:
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
class TokenStorage(context: Context) {
private val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
private val securePrefs = EncryptedSharedPreferences.create(
context,
"secure_tokens",
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)
fun saveToken(token: String, key: String) {
securePrefs.edit().putString(key, token).apply()
}
fun getToken(key: String): String? {
return securePrefs.getString(key, null)
}
fun deleteToken(key: String) {
securePrefs.edit().remove(key).apply()
}
}
4. Insufficient Token Validation
Always validate tokens server-side. Client-side validation can be bypassed.
// Backend token validation (Node.js)
import jwt from 'jsonwebtoken';
import jwksClient from 'jwks-rsa';
const client = jwksClient({
jwksUri: 'https://auth.example.com/.well-known/jwks.json',
cache: true,
rateLimit: true,
});
async function validateAccessToken(token) {
const decoded = jwt.decode(token, { complete: true });
if (!decoded) {
throw new Error('Invalid token format');
}
// Get the signing key
const key = await client.getSigningKey(decoded.header.kid);
const publicKey = key.getPublicKey();
// Verify signature, expiration, issuer, and audience
const verified = jwt.verify(token, publicKey, {
algorithms: ['RS256'],
issuer: 'https://auth.example.com',
audience: 'your-api-audience',
});
// Additional checks
if (verified.exp < Date.now() / 1000) {
throw new Error('Token expired');
}
return verified;
}
// Middleware
app.use('/api', async (req, res, next) => {
const authHeader = req.headers.authorization;
if (!authHeader?.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Missing token' });
}
try {
const token = authHeader.substring(7);
req.user = await validateAccessToken(token);
next();
} catch (error) {
return res.status(401).json({ error: 'Invalid token' });
}
});
5. Refresh Token Rotation Missing
If a refresh token is stolen, the attacker has indefinite access. Implement refresh token rotation:
// Token refresh endpoint
app.post('/oauth/token', async (req, res) => {
const { grant_type, refresh_token } = req.body;
if (grant_type !== 'refresh_token') {
return res.status(400).json({ error: 'Invalid grant type' });
}
try {
// Validate refresh token
const tokenRecord = await RefreshToken.findOne({
token: refresh_token,
revoked: false,
});
if (!tokenRecord) {
throw new Error('Invalid refresh token');
}
// Check if token has been used before (replay attack detection)
if (tokenRecord.used) {
// Token reuse detected - revoke entire family
await RefreshToken.updateMany(
{ family: tokenRecord.family },
{ revoked: true }
);
throw new Error('Token reuse detected');
}
// Mark current token as used
tokenRecord.used = true;
await tokenRecord.save();
// Generate new tokens
const newAccessToken = generateAccessToken(tokenRecord.userId);
const newRefreshToken = await createRefreshToken(
tokenRecord.userId,
tokenRecord.family // Same family for rotation tracking
);
res.json({
access_token: newAccessToken,
refresh_token: newRefreshToken,
expires_in: 3600,
});
} catch (error) {
res.status(401).json({ error: error.message });
}
});
Using ASWebAuthenticationSession (iOS) and Custom Tabs (Android)
Don’t implement your own browser for OAuth. Use the platform-provided secure browsers.
iOS with ASWebAuthenticationSession
import AuthenticationServices
class OAuthManager: NSObject, ASWebAuthenticationPresentationContextProviding {
private var authSession: ASWebAuthenticationSession?
func startOAuthFlow() {
guard let authURL = buildAuthorizationURL() else { return }
authSession = ASWebAuthenticationSession(
url: authURL,
callbackURLScheme: "yourapp"
) { [weak self] callbackURL, error in
if let error = error {
if (error as? ASWebAuthenticationSessionError)?.code == .canceledLogin {
// User cancelled - not an error
return
}
self?.handleError(error)
return
}
guard let callbackURL = callbackURL else { return }
self?.handleCallback(url: callbackURL)
}
authSession?.presentationContextProvider = self
authSession?.prefersEphemeralWebBrowserSession = true // Don't share cookies
authSession?.start()
}
func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor {
return UIApplication.shared.windows.first { $0.isKeyWindow }!
}
}
Android with Custom Tabs
import androidx.browser.customtabs.CustomTabsIntent
import net.openid.appauth.AuthorizationRequest
import net.openid.appauth.AuthorizationService
class OAuthManager(private val context: Context) {
private val authService = AuthorizationService(context)
fun startOAuthFlow(activity: Activity) {
val authRequest = AuthorizationRequest.Builder(
serviceConfig,
BuildConfig.CLIENT_ID,
ResponseTypeValues.CODE,
Uri.parse(BuildConfig.REDIRECT_URI)
)
.setCodeVerifier(generateCodeVerifier())
.setScope("openid profile email")
.setState(generateState())
.build()
val customTabsIntent = CustomTabsIntent.Builder()
.setShowTitle(true)
.build()
val authIntent = authService.getAuthorizationRequestIntent(
authRequest,
customTabsIntent
)
activity.startActivityForResult(authIntent, REQUEST_CODE_AUTH)
}
fun handleAuthResponse(intent: Intent?): AuthorizationResponse? {
intent ?: return null
val response = AuthorizationResponse.fromIntent(intent)
val exception = AuthorizationException.fromIntent(intent)
if (exception != null) {
handleError(exception)
return null
}
return response
}
}
Security Checklist
Before shipping OAuth in your app, verify:
- Using Authorization Code flow with PKCE (never Implicit flow)
- Code verifier is cryptographically random (32+ bytes)
- State parameter generated, stored, and validated
- Tokens stored in Keychain (iOS) or EncryptedSharedPreferences (Android)
- Using Universal Links / App Links for redirect URI (not custom schemes)
- Access tokens validated server-side on every API request
- Refresh token rotation implemented
- Using ASWebAuthenticationSession (iOS) or Custom Tabs (Android)
- Token expiration properly handled
- Logout clears all stored tokens
Conclusion
OAuth security isn’t about following a checklist—it’s about understanding the attack vectors. Code interception, CSRF, token theft, and replay attacks are all real threats that poorly implemented OAuth leaves wide open.
Use the platform-provided auth sessions, implement PKCE properly, validate state, store tokens securely, and rotate refresh tokens. These aren’t optional extras; they’re baseline requirements for any app handling user authentication.
If your app handles sensitive data—health records, financial information, personal documents—consider having a security professional audit your OAuth implementation. The cost of an audit is nothing compared to the cost of a breach.