PKCE for mobile apps: the right way to do OAuth on iOS and Android

Mobile apps cannot keep a client secret. A client secret embedded in an iOS app binary can be extracted by anyone who downloads the app and inspects the binary. OAuth was designed with this constraint in mind: mobile apps use PKCE (Proof Key for Code Exchange, RFC 7636) instead of a client secret. PKCE lets the authorization server verify that the entity exchanging the authorization code for tokens is the same entity that initiated the authorization request, without any pre-shared secret.

How PKCE works

The flow is straightforward. The app generates a random 32-byte code_verifier at the start of the authorization request. It derives a code_challenge by hashing the verifier with SHA-256 and base64url-encoding it. The code_challenge is sent in the authorization request. The verifier is held in memory. When the app exchanges the authorization code for tokens, it includes the original verifier. The authorization server hashes the verifier and compares it to the stored challenge — if they match, the exchange is valid.

import CryptoKit
import AuthenticationServices

// iOS: generate PKCE parameters
func generatePKCE() -> (verifier: String, challenge: String) {
    var buffer = [UInt8](repeating: 0, count: 32)
    _ = SecRandomCopyBytes(kSecRandomDefault, buffer.count, &buffer)
    let verifier = Data(buffer).base64URLEncoded()

    let challenge = Data(SHA256.hash(data: Data(verifier.utf8))).base64URLEncoded()
    return (verifier, challenge)
}

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

// Build the authorization URL
let (verifier, challenge) = generatePKCE()
// Store verifier — needed for token exchange
UserDefaults.standard.set(verifier, forKey: "pkce_verifier")

var components = URLComponents(string: "https://auth.example.com/oauth/authorize")!
components.queryItems = [
    URLQueryItem(name: "response_type", value: "code"),
    URLQueryItem(name: "client_id", value: clientID),
    URLQueryItem(name: "redirect_uri", value: redirectURI),
    URLQueryItem(name: "scope", value: "openid profile email offline_access"),
    URLQueryItem(name: "code_challenge", value: challenge),
    URLQueryItem(name: "code_challenge_method", value: "S256"),
    URLQueryItem(name: "state", value: generateState()),
]

ASWebAuthenticationSession vs WKWebView

The choice of how to present the authorization page is a critical security decision. There are two wrong approaches and one right one.

Embedding a WKWebView to display the login page is wrong. A WKWebView is fully controlled by your app — your code can inject JavaScript, intercept form submissions, and read the user's credentials as they type them. This means users are trusting your app with their IdP password, not just with OAuth tokens. It also means the user's IdP session cookies do not carry over from Safari, so they cannot use SSO. Apple explicitly prohibits OAuth in WKWebView for apps using "Sign in with Apple."

Opening a custom UIWebView or SFSafariViewController with JavaScript injection disabled is better but still inferior to the recommended approach.

The correct approach on iOS is ASWebAuthenticationSession. It presents the login page in a system-controlled browser context that shares the Safari cookie jar, allowing SSO to work. Your app code cannot access the content of the page. The user sees a clear system prompt that identifies which app is requesting access. On Android, the equivalent is Custom Tabs via the Chrome Custom Tabs API.

// iOS: ASWebAuthenticationSession
import AuthenticationServices

class AuthManager: NSObject, ASWebAuthenticationPresentationContextProviding {
    func authenticate(completion: @escaping (Result<String, Error>) -> Void) {
        let (verifier, challenge) = generatePKCE()
        // Store verifier for token exchange step
        self.currentVerifier = verifier

        let authURL = buildAuthorizationURL(challenge: challenge)
        let callbackScheme = "com.example.myapp"  // Your app's URL scheme

        let session = ASWebAuthenticationSession(
            url: authURL,
            callbackURLScheme: callbackScheme
        ) { callbackURL, error in
            guard let callbackURL = callbackURL else {
                completion(.failure(error ?? AuthError.cancelled))
                return
            }
            // Extract code from callback URL
            let components = URLComponents(url: callbackURL, resolvingAgainstBaseURL: false)
            guard let code = components?.queryItems?.first(where: { $0.name == "code" })?.value else {
                completion(.failure(AuthError.missingCode))
                return
            }
            completion(.success(code))
        }

        session.presentationContextProvider = self
        session.prefersEphemeralWebBrowserSession = false  // Share Safari cookies for SSO
        session.start()
    }

    func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor {
        UIApplication.shared.windows.first!
    }
}

Custom URL schemes vs app-claimed HTTPS redirects

Custom URL schemes (myapp://callback) have a fundamental problem: any app can register the same scheme. If a malicious app registers myapp://callback before your app, it intercepts the authorization code when the OS dispatches the redirect. With PKCE this is still not a full compromise — the attacker has the code but not the verifier — but it is still bad practice.

App-claimed HTTPS redirects (Universal Links on iOS, App Links on Android) are the correct approach for production apps. Your app claims ownership of https://auth.example.com/callback/ios by hosting an Apple App Site Association file (or Android's Digital Asset Links JSON) at that URL. The OS routes the redirect to your app based on this verified claim, and only your app can make this claim because it requires control of the domain.

// iOS: Info.plist — register Universal Link domains
// (In Xcode: Signing & Capabilities > Associated Domains)
// Domain: applinks:auth.example.com

// The AASA file at https://auth.example.com/.well-known/apple-app-site-association
{
  "applinks": {
    "apps": [],
    "details": [
      {
        "appID": "TEAMID.com.example.myapp",
        "paths": ["/callback/ios"]
      }
    ]
  }
}

// Use the HTTPS redirect URI in the auth request
let redirectURI = "https://auth.example.com/callback/ios"
PKCE with S256 (SHA-256) is required. The plain method (where the verifier is sent as-is in the code_challenge) provides no security — it is equivalent to not having PKCE at all if the attacker intercepts the authorization request. Always use S256. Your authorization server should reject authorization requests that do not specify code_challenge_method=S256.

Token exchange with the verifier

// iOS: exchange authorization code for tokens
func exchangeCode(_ code: String, verifier: String) async throws -> TokenResponse {
    var request = URLRequest(url: URL(string: "https://auth.example.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": redirectURI,
        "client_id": clientID,
        "code_verifier": verifier,  // The original verifier, not the challenge
    ]
    .map { "\($0.key)=\($0.value.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed)!)" }
    .joined(separator: "&")

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

    let (data, _) = try await URLSession.shared.data(for: request)
    return try JSONDecoder().decode(TokenResponse.self, from: data)
}
← Back to blog Try Bastionary free →