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

Implementing PKCE Correctly Infographic

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

Common Vulnerabilities and How to Prevent Them Infographic 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.