How to Build an OAuth Flow in SwiftUI
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
-
PKCE generation (lines 5–22):
PKCEHelper.generateVerifier()fills 32 bytes from the secure random generator viaSecRandomCopyBytes, 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. -
Presentation context (lines 25–52):
OAuthSessionwraps the callback-basedASWebAuthenticationSessionin a Swift concurrencywithCheckedThrowingContinuation, making it awaitable. SettingprefersEphemeralWebBrowserSession = trueprevents credential sharing with Safari, which is the correct default for most apps. -
State parameter validation (lines 88–92): Before trusting the callback URL, the code checks that the returned
statequery item matches the one included in the authorization request. This mitigates CSRF attacks against the redirect. -
Token exchange (lines 98–124): The private
exchangeCode(_:verifier:)method POSTs to the token endpoint withapplication/x-www-form-urlencodedencoding. Notice thecode_verifierfield — the server will verify it against the challenge it received during authorization. -
Graceful cancellation (line 79): When the user taps Cancel inside the browser sheet,
ASWebAuthenticationSessionthrowsASWebAuthenticationSessionError.canceledLogin. Thecatchbranch 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
-
iOS 17 requirement for the async wrapper pattern:
ASWebAuthenticationSessionitself ships back to iOS 12, but the@Observablemacro used inAuthStaterequires iOS 17+. If you need broader deployment, replace@ObservablewithObservableObject+@Published. -
Forgetting the URL scheme in Info.plist: The redirect URI
myapp://oauth/callbackwill silently fail unless you registermyappas a URL scheme under Info → URL Types in Xcode.ASWebAuthenticationSessionintercepts the scheme before iOS opens another app, but the scheme must still be declared. -
Storing tokens in UserDefaults:
UserDefaultsis not encrypted and is accessible in device backups. Always store access and refresh tokens in the Keychain withkSecAttrAccessibleAfterFirstUnlockso they survive device restarts but remain protected at rest. -
Missing
presentationContextProvideron iOS 17: Without a validASWebAuthenticationPresentationContextProvidingobject, the session will throw The operation couldn't be completed at runtime. Ensure you setsession.presentationContextProviderbefore callingsession.start().
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.