```html SwiftUI: How to Build OAuth Flow (iOS 17+, 2026)

How to Build an OAuth Flow in SwiftUI

iOS 17+ Xcode 16+ Advanced APIs: ASWebAuthenticationSession Updated: May 12, 2026
TL;DR

Use ASWebAuthenticationSession to present the provider's login page in a secure in-app browser, then capture the redirect URL to extract the authorization code. Pair it with PKCE to eliminate the need for a client secret on a public client.

import AuthenticationServices

func startOAuth() async throws -> String {
    let verifier = PKCEHelper.generateVerifier()
    let challenge = PKCEHelper.challenge(for: verifier)
    let authURL = URL(string:
        "https://provider.example/oauth/authorize"
        + "?client_id=MY_CLIENT"
        + "&response_type=code"
        + "&redirect_uri=myapp://oauth/callback"
        + "&code_challenge=\(challenge)"
        + "&code_challenge_method=S256"
    )!

    let callbackURL = try await ASWebAuthenticationSession
        .authenticate(url: authURL,
                       callbackURLScheme: "myapp")
    let code = URLComponents(url: callbackURL,
        resolvingAgainstBaseURL: false)?
        .queryItems?.first(where: { $0.name == "code" })?.value ?? ""
    return try await TokenService.exchange(code: code,
                                           verifier: verifier)
}

Full implementation

The implementation below wires together four concerns: PKCE generation, the ASWebAuthenticationSession presentation adapter, a token-exchange service, and a Keychain-backed credential store. Everything is expressed as Swift concurrency (async/await), and the SwiftUI view simply reacts to an @Observable auth state object — keeping UI code thin and testable.

import SwiftUI
import AuthenticationServices
import CryptoKit

// MARK: - PKCE helpers
enum PKCEHelper {
    static func generateVerifier() -> String {
        var bytes = [UInt8](repeating: 0, count: 32)
        _ = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes)
        return Data(bytes).base64URLEncoded()
    }

    static func challenge(for verifier: String) -> String {
        let data = Data(verifier.utf8)
        let digest = SHA256.hash(data: data)
        return Data(digest).base64URLEncoded()
    }
}

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

// MARK: - Auth session presenter
@MainActor
final class OAuthSession: NSObject, ASWebAuthenticationPresentationContextProviding {
    static let shared = OAuthSession()
    private var anchor: ASPresentationAnchor?

    func setAnchor(_ window: UIWindow) { anchor = window }

    func presentationAnchor(for session: ASWebAuthenticationSession)
        -> ASPresentationAnchor { anchor! }

    func authenticate(url: URL, callbackScheme: String) async throws -> URL {
        try await withCheckedThrowingContinuation { continuation in
            let session = ASWebAuthenticationSession(
                url: url,
                callbackURLScheme: callbackScheme
            ) { callbackURL, error in
                if let error { continuation.resume(throwing: error); return }
                guard let callbackURL else {
                    continuation.resume(throwing: URLError(.badURL)); return
                }
                continuation.resume(returning: callbackURL)
            }
            session.presentationContextProvider = self
            session.prefersEphemeralWebBrowserSession = true
            session.start()
        }
    }
}

// MARK: - Token model
struct OAuthTokens: Codable {
    let accessToken: String
    let refreshToken: String?
    let expiresIn: Int
}

// MARK: - Auth state (Observable)
@Observable
final class AuthState {
    var tokens: OAuthTokens?
    var isLoading = false
    var errorMessage: String?

    private let clientID = "MY_CLIENT_ID"
    private let authBase = "https://provider.example/oauth/authorize"
    private let tokenURL = URL(string: "https://provider.example/oauth/token")!
    private let redirectURI = "myapp://oauth/callback"

    func signIn() async {
        isLoading = true
        errorMessage = nil
        do {
            let verifier = PKCEHelper.generateVerifier()
            let challenge = PKCEHelper.challenge(for: verifier)
            let state = UUID().uuidString

            var comps = URLComponents(string: authBase)!
            comps.queryItems = [
                .init(name: "client_id",             value: clientID),
                .init(name: "response_type",         value: "code"),
                .init(name: "redirect_uri",          value: redirectURI),
                .init(name: "scope",                 value: "openid profile email"),
                .init(name: "state",                 value: state),
                .init(name: "code_challenge",        value: challenge),
                .init(name: "code_challenge_method", value: "S256"),
            ]

            let callbackURL = try await OAuthSession.shared
                .authenticate(url: comps.url!, callbackScheme: "myapp")

            let returnedComps = URLComponents(
                url: callbackURL, resolvingAgainstBaseURL: false)
            guard returnedComps?.queryItems?.first(
                    where: { $0.name == "state" })?.value == state
            else { throw URLError(.userAuthenticationRequired) }

            let code = returnedComps?.queryItems?
                .first(where: { $0.name == "code" })?.value ?? ""

            tokens = try await exchangeCode(code, verifier: verifier)
        } catch ASWebAuthenticationSessionError.canceledLogin {
            // user dismissed — not an error
        } catch {
            errorMessage = error.localizedDescription
        }
        isLoading = false
    }

    private func exchangeCode(_ code: String,
                               verifier: String) async throws -> OAuthTokens {
        var req = URLRequest(url: tokenURL)
        req.httpMethod = "POST"
        req.setValue("application/x-www-form-urlencoded",
                     forHTTPHeaderField: "Content-Type")
        let body = [
            "grant_type":    "authorization_code",
            "client_id":     clientID,
            "redirect_uri":  redirectURI,
            "code":          code,
            "code_verifier": verifier,
        ]
        req.httpBody = body
            .map { "\($0.key)=\($0.value.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed)!)" }
            .joined(separator: "&")
            .data(using: .utf8)

        let (data, _) = try await URLSession.shared.data(for: req)
        return try JSONDecoder().decode(OAuthTokens.self, from: data)
    }
}

// MARK: - View
struct OAuthDemoView: View {
    @State private var auth = AuthState()

    var body: some View {
        VStack(spacing: 24) {
            if let tokens = auth.tokens {
                VStack(alignment: .leading, spacing: 8) {
                    Label("Signed in", systemImage: "checkmark.seal.fill")
                        .foregroundStyle(.green)
                        .font(.headline)
                    Text("Access token: \(tokens.accessToken.prefix(20))…")
                        .font(.caption.monospaced())
                        .foregroundStyle(.secondary)
                }
                .padding()
                .background(.thinMaterial, in: RoundedRectangle(cornerRadius: 12))
            } else {
                Button {
                    Task { await auth.signIn() }
                } label: {
                    Label("Sign in with OAuth", systemImage: "lock.shield")
                        .font(.headline)
                        .frame(maxWidth: .infinity)
                        .padding()
                        .background(.blue, in: RoundedRectangle(cornerRadius: 14))
                        .foregroundStyle(.white)
                }
                .disabled(auth.isLoading)
                .overlay {
                    if auth.isLoading {
                        ProgressView().padding(.trailing)
                            .frame(maxWidth: .infinity, alignment: .trailing)
                    }
                }
            }

            if let err = auth.errorMessage {
                Text(err)
                    .font(.caption)
                    .foregroundStyle(.red)
            }
        }
        .padding(32)
        .navigationTitle("OAuth Demo")
    }
}

#Preview {
    NavigationStack {
        OAuthDemoView()
    }
}

How it works

  1. PKCE generation (lines 5–22): PKCEHelper.generateVerifier() fills 32 bytes from the secure random generator via SecRandomCopyBytes, then base64url-encodes them. The challenge is the SHA-256 hash of that verifier, also base64url-encoded. This pair lets the token endpoint verify the caller without a client secret — essential for public clients.
  2. Presentation context (lines 25–52): OAuthSession wraps the callback-based ASWebAuthenticationSession in a Swift concurrency withCheckedThrowingContinuation, making it awaitable. Setting prefersEphemeralWebBrowserSession = true prevents credential sharing with Safari, which is the correct default for most apps.
  3. State parameter validation (lines 88–92): Before trusting the callback URL, the code checks that the returned state query item matches the one included in the authorization request. This mitigates CSRF attacks against the redirect.
  4. Token exchange (lines 98–124): The private exchangeCode(_:verifier:) method POSTs to the token endpoint with application/x-www-form-urlencoded encoding. Notice the code_verifier field — the server will verify it against the challenge it received during authorization.
  5. Graceful cancellation (line 79): When the user taps Cancel inside the browser sheet, ASWebAuthenticationSession throws ASWebAuthenticationSessionError.canceledLogin. The catch branch silently discards it, so the UI returns to the sign-in state without showing an error — exactly the UX users expect.

Variants

Persisting tokens in the Keychain

import Security

struct KeychainStore {
    static let service = "com.example.myapp.oauth"

    static func save(_ tokens: OAuthTokens) throws {
        let data = try JSONEncoder().encode(tokens)
        let query: [CFString: Any] = [
            kSecClass:           kSecClassGenericPassword,
            kSecAttrService:     service,
            kSecValueData:       data,
            kSecAttrAccessible:  kSecAttrAccessibleAfterFirstUnlock,
        ]
        SecItemDelete(query as CFDictionary)
        let status = SecItemAdd(query as CFDictionary, nil)
        guard status == errSecSuccess else {
            throw KeychainError.saveFailed(status)
        }
    }

    static func load() throws -> OAuthTokens? {
        let query: [CFString: Any] = [
            kSecClass:       kSecClassGenericPassword,
            kSecAttrService: service,
            kSecReturnData:  true,
            kSecMatchLimit:  kSecMatchLimitOne,
        ]
        var result: AnyObject?
        let status = SecItemCopyMatching(query as CFDictionary, &result)
        guard status == errSecSuccess,
              let data = result as? Data else { return nil }
        return try JSONDecoder().decode(OAuthTokens.self, from: data)
    }

    enum KeychainError: Error {
        case saveFailed(OSStatus)
    }
}

Refreshing an expired access token

When your access token expires, POST to the token endpoint with grant_type=refresh_token and the stored refresh_token. Wrap this in an async method on AuthState, and call it from a .task modifier or a custom URLSession delegate that intercepts 401 responses. Store the refreshed tokens back to the Keychain immediately — refresh tokens are often single-use.

Common pitfalls

Prompt this with Claude Code

When using Soarias or Claude Code directly to implement this:

Implement an oauth flow in SwiftUI for iOS 17+.
Use ASWebAuthenticationSession with PKCE (SHA-256 code challenge).
Include a token exchange POST to a configurable token endpoint.
Persist access and refresh tokens in the Keychain.
Make it accessible (VoiceOver labels on the sign-in button).
Add a #Preview with realistic sample data.

In the Soarias Build phase, drop this prompt into the Implementation step alongside your screen mockup so Claude Code scaffolds the full auth module — AuthState, Keychain helper, and the SwiftUI view — in a single pass before you move on to your protected screens.

Related

FAQ

Does this work on iOS 16?

ASWebAuthenticationSession itself works back to iOS 12, and the PKCE + token exchange logic is iOS-version agnostic. The only iOS 17-specific piece is the @Observable macro. Swap it for class AuthState: ObservableObject with @Published properties and the rest compiles and runs on iOS 16 without changes.

Should I use prefersEphemeralWebBrowserSession = true or false?

Set it to true for most apps — the session starts with a blank cookie jar, so the user always sees the provider's login form and you avoid credential leakage between apps. Set it to false only when SSO is a deliberate feature (e.g., enterprise apps where the user has already signed into the provider in Safari and you want to reuse that session silently). Note: false triggers a system confirmation dialog on iOS 16+ when Safari cookies are actually shared.

What is the UIKit equivalent?

In UIKit you use the same ASWebAuthenticationSession API — it is not a SwiftUI-only class. The difference is that the presentationAnchor delegate method returns the current UIWindow directly from your UIViewController, and you call session.start() from viewDidAppear or a button action rather than from a .task modifier. The PKCE and token-exchange code is identical.

Last reviewed: 2026-05-12 by the Soarias team.

```