Auth0.swift v3 is a Swift 6-ready release with improved error handling, predictable threading, and a cleaner API surface:
-
Swift 6 concurrency:
Sendableconformances,@MainActorcallbacks, and@Sendableclosures across all public APIs — including a JWTDecode.swift v4 upgrade. -
Throwing storage methods:
CredentialsManagerandCredentialsStoragemethods now throw instead of returningBoolornil, so failures are never silently swallowed. -
Guaranteed main-thread delivery: All callback, Combine, and async/await variants deliver results on the main thread — no more
DispatchQueue.main.asyncboilerplate. -
Updated defaults: Scope now includes
offline_access,minTTLdefaults to 60 seconds, andsignupdefaults theconnectionto"Username-Password-Authentication". -
Renamed APIs for consistency with the Android, Flutter, and React Native Auth0 SDKs.
-
New APIs: Multi-window Web Auth support,
clearAll(), automatic credentials management and ID token validation. -
Removed APIs: The Management API client and the deprecated MFA methods on the
Authenticationprotocol have been removed. -
New
Authenticationprotocol requirements: CustomAuthenticationconformances (mocks, test doubles) must implement three new passwordless OTP methods. -
New
MyAccountAuthenticationMethodsprotocol requirements: CustomMyAccountAuthenticationMethodsconformances (mocks, test doubles) must implement two password enrollment methods. -
This guide covers every breaking change and the steps to migrate. Review it fully before upgrading.
- Swift 6 Concurrency
- Default Values Changed
- Behavior Changes
- Credentials Manager: storage methods now throw
- Methods Added
- API Changes
- Removed APIs
- Getting Help
The Auth0 library target is now compiled with Swift 6 language mode, enforcing strict concurrency. v3 adopts this by adding Sendable conformances across the SDK. If your application defines custom types conforming to SDK protocols, some changes may be required.
Important
Auth0WebAuth has been changed from a final class to a struct. This change does not produce a compilation error — your existing code will continue to build — but it silently changes the behavior of any code that calls builder methods imperatively (without chaining). Review the usage patterns below to ensure your integration behaves as expected.
Change: The WebAuth protocol now inherits from Sendable, and the concrete Auth0WebAuth implementation has been changed from a final class to a struct.
// v3
public protocol WebAuth: SenderConstraining, Trackable, Loggable, Sendable { ... }Impact — custom WebAuth implementations: If your application defines a custom type conforming to WebAuth (for example, a mock or test double), that type must now also conform to Sendable.
Migration example
// v2 - no Sendable requirement
class MockWebAuth: WebAuth {
// ...
}
// v3 - must be Sendable
// For structs with only Sendable stored properties, conformance is automatic:
struct MockWebAuth: WebAuth { // implicitly Sendable - no changes needed
// ...
}
// For classes, add @unchecked Sendable and ensure thread safety manually:
final class MockWebAuth: WebAuth, @unchecked Sendable {
private let lock = NSLock()
// ... thread-safe implementation
}Impact — builder methods now return copies: In v2, Auth0WebAuth was a final class, so builder methods mutated the instance in place. In v3 it is a struct, so each builder method returns a new copy without modifying the original.
If you use method chaining, no change is needed:
// ✅ Works the same in v2 and v3
Auth0.webAuth()
.scope("openid")
.audience("https://api.example.com")
.start { result in ... }If you call builder methods imperatively, you must assign the return value:
// ⚠️ Broken in v3 — audience is called but its return value is discarded,
// so it is never applied. In v2 this worked silently because Auth0WebAuth
// was a class and the method mutated the instance in place.
// Note: in v2 this pattern would also have produced a "result unused" compiler warning.
var webAuth = Auth0.webAuth().scope("openid")
webAuth.audience("https://api.example.com")
webAuth.start { result in ... }
// ✅ Fixed — reassign the return value
var webAuth = Auth0.webAuth().scope("openid")
webAuth = webAuth.audience("https://api.example.com")
webAuth.start { result in ... }Change: Auth0Error and Logger now inherit from Sendable.
// v3
public protocol Auth0Error: LocalizedError, CustomDebugStringConvertible, Sendable { ... }
public protocol Logger: Sendable { ... }Impact: If your application defines a custom type that conforms to either protocol, that type must now also conform to Sendable.
Migration example
// v2 - no Sendable requirement on conforming types
struct MyAppError: Auth0Error {
var cause: Error?
var debugDescription: String { "my error" }
var errorDescription: String? { "my error" }
}
struct MyLogger: Logger {
func trace(request: URLRequest, session: URLSession) { ... }
func trace(response: URLResponse, data: Data?) { ... }
func trace(url: URL, source: String?) { ... }
}
// v3 - conforming types must be Sendable
// For structs with only Sendable stored properties, conformance is automatic:
struct MyAppError: Auth0Error { // implicitly Sendable - no changes needed
var cause: Error?
var debugDescription: String { "my error" }
var errorDescription: String? { "my error" }
}
// For classes or types with non-Sendable stored properties, add @unchecked Sendable
// and ensure thread safety manually:
final class MyLogger: Logger, @unchecked Sendable {
private let lock = NSLock()
// ... thread-safe implementation
}Note
This section only applies if you implement a custom WebAuthProvider — i.e. you replace the default browser (ASWebAuthenticationSession) with your own using .provider(_:) on the Web Auth builder. Most apps do not do this and can skip this section.
Change: The following public typealiases have been updated for Swift 6 concurrency:
| Symbol | v2 | v3 |
|---|---|---|
WebAuthProviderCallback |
(WebAuthResult<Void>) -> Void |
@Sendable (WebAuthResult<Void>) -> Void |
WebAuthProvider |
(_ url: URL, _ callback: @escaping WebAuthProviderCallback) -> WebAuthUserAgent |
@Sendable @MainActor (_ url: URL, _ callback: @escaping WebAuthProviderCallback) -> WebAuthUserAgent |
WebAuthProviderCallback — now @Sendable
Impact: The callback parameter your WebAuthProvider closure receives is a WebAuthProviderCallback. If you store it inside your custom user agent, any values the closure captures must be Sendable. In most cases this is automatically satisfied, but if your closure captures a non-Sendable class the compiler will flag it — as a warning in Swift 5 with -strict-concurrency=complete, or as a build error in Swift 6 language mode.
Migration example — non-Sendable capture
// ❌ Compiler warning — MyBrowserDelegate is not Sendable
class MyBrowserDelegate {
func browserDidFinish() { ... }
}
let delegate = MyBrowserDelegate()
let callback: WebAuthProviderCallback = { result in
delegate.browserDidFinish() // ⚠️ capture of non-Sendable type
}
// ✅ Fix — mark the class as Sendable
final class MyBrowserDelegate: Sendable {
func browserDidFinish() { ... }
}
// ✅ Or isolate to @MainActor (since browser callbacks are UI work)
@MainActor
class MyBrowserDelegate {
func browserDidFinish() { ... }
}WebAuthProvider — now @Sendable @MainActor
Impact: If you implement a custom WebAuthProvider, the closure is now @MainActor — its body runs on the main actor. MyUserAgent.init is therefore called on the main actor; the init does not need to be @MainActor itself, but it must not be isolated to a different actor.
Since custom providers always present UI, they should already be doing UI work on the main thread. In most cases no code changes are required — Swift infers @Sendable @MainActor from the WebAuthProvider typealias automatically.
Migration example — custom WebAuthProvider
// v2
let myProvider: WebAuthProvider = { url, callback in
let agent = MyUserAgent(url: url, callback: callback)
return agent
}
// v3 — no code change needed. Swift infers @Sendable @MainActor from the
// WebAuthProvider typealias. MyUserAgent.init runs on the main actor
// and MyUserAgent must conform to Sendable.
let myProvider: WebAuthProvider = { url, callback in
let agent = MyUserAgent(url: url, callback: callback)
return agent
}Change: All public callback parameters are now @MainActor. This affects the following APIs:
Requestable.start(_:),TokenRequestable.start(_:)— covers allAuthentication,MFAClient, andMyAccount/MyAccountAuthenticationMethodsmethods, which return these protocol typesWebAuth.start(_:),WebAuth.logout(federated:callback:),WebAuth.onClose(_:)CredentialsManager.credentials(withScope:minTTL:parameters:headers:callback:),revoke(headers:_:),apiCredentials(forAudience:scope:minTTL:parameters:headers:callback:),ssoCredentials(parameters:headers:callback:),renew(parameters:headers:callback:)
Impact:
-
Call sites — No changes required for typical trailing closures. Swift infers
@MainActorfrom the parameter type automatically. Most callbacks already perform UI work on the main thread, so this is a no-op in practice. -
Custom protocol implementations (mocks, test doubles) — If you implement
Requestable,TokenRequestable,WebAuth, or any other protocol whose method signatures include a callback, you must add@MainActoron the matching parameter.
Migration example
// v2 - start without @MainActor
struct MockRequestable: Requestable {
func start(_ callback: @escaping (Result<Credentials, AuthenticationError>) -> Void) {
callback(.success(mockCredentials))
}
// ...
}
// v3 - add @MainActor to match the updated protocol requirement
struct MockRequestable: Requestable {
func start(_ callback: @escaping @MainActor (Result<Credentials, AuthenticationError>) -> Void) {
Task { @MainActor in callback(.success(mockCredentials)) }
}
// ...
}Change: The async/await variants of start() and logout(federated:) on the WebAuth protocol are now @MainActor:
// v3
@MainActor func start() async throws -> Credentials
@MainActor func logout(federated: Bool) async throwsImpact:
- Most callers — no action required. Calling a
@MainActor asyncfunction from any async context is transparent: Swift automatically hops to the main actor at theawaitpoint and returns to the caller's executor when done.
// Works unchanged from any async context
let credentials = try await Auth0.webAuth().start()
try await Auth0.webAuth().logout()-
Custom
WebAuthconformances (mocks, test doubles) — Add@MainActorto yourstart()andlogout(federated:)implementations to match the updated protocol requirement. -
Callers using
any WebAuth(protocol existential) — The@MainActorconstraint is now enforced at the call site through the protocol. Under strict concurrency, Swift will emit a warning if you call these methods from a non-isolated context withoutawait.
Change: The JWTDecode.swift dependency has been upgraded from v3.3.0 to v4.0.0.
Impact: JWTDecode.swift v4.0.0 is fully Swift 6 compliant. No code changes are required in your application.
Change: The default scope value now includes offline_access.
The default scope value was changed from openid profile email to openid profile email offline_access. This change applies to Web Auth and all Authentication client methods (except renew(withRefreshToken:scope:), in which scope keeps defaulting to nil).
Impact: If your application doesn't need refresh tokens, you should explicitly specify the scope without offline_access.
Migration example
// v2 - offline_access had to be explicitly added
Auth0
.webAuth()
.scope("openid profile email offline_access")
.start { result in
// ...
}
// v3 - offline_access is included by default
Auth0
.webAuth()
.start { result in
// ...
}
// v3 - opt out of offline_access if not needed
Auth0
.webAuth()
.scope("openid profile email")
.start { result in
// ...
}Reason: Mobile apps typically rely on refresh tokens to maintain user sessions over extended periods. Since default values should reflect the most common usage scenarios, the default scope now includes offline_access.
Change: The default minTTL value changed from 0 to 60 seconds.
This change affects the following Credentials Manager methods:
credentials(withScope:minTTL:parameters:headers:callback:)credentials(withScope:minTTL:parameters:headers:)(async/await)credentials(withScope:minTTL:parameters:headers:)(Combine)apiCredentials(forAudience:scope:minTTL:parameters:headers:callback:)apiCredentials(forAudience:scope:minTTL:parameters:headers:)(async/await)apiCredentials(forAudience:scope:minTTL:parameters:headers:)(Combine)
Impact: Credentials will be renewed if they expire within 60 seconds, instead of only when already expired.
Note
hasValid(minTTL:) is not affected by this change — its default remains 0. hasValid() with no arguments can report true for a token expiring in 1 second, even though a credentials() call right after would trigger a renewal anyway. If your app uses hasValid() to decide whether to show a login screen, keep this asymmetry in mind.
Migration example
// v2 - minTTL defaulted to 0, had to be set explicitly
credentialsManager.credentials(minTTL: 60) { result in
// ...
}
// v3 - minTTL defaults to 60 seconds
credentialsManager.credentials { result in
// ...
}
// v3 - use 0 to restore v2 behavior
credentialsManager.credentials(minTTL: 0) { result in
// ...
}Reason: A minTTL of 0 meant credentials were not renewed until expired, which could result in delivering access tokens that expire immediately after retrieval, causing subsequent API requests to fail. Setting a default value of 60 seconds ensures the access token remains valid for a reasonable period.
Change: The connection parameter now defaults to "Username-Password-Authentication".
This change affects the following Authentication client method:
signup(email:username:password:connection:userMetadata:rootAttributes:)
Impact: You no longer need to specify the connection parameter if you're using the default database connection.
Migration example
// v2 - connection had to be specified
Auth0
.authentication()
.signup(email: email,
username: username,
password: password,
connection: "Username-Password-Authentication")
.start { result in
// ...
}
// v3 - connection parameter is optional
Auth0
.authentication()
.signup(email: email,
username: username,
password: password)
.start { result in
// ...
}
// v3 - specify connection if using a different one
Auth0
.authentication()
.signup(email: email,
username: username,
password: password,
connection: "custom-database")
.start { result in
// ...
}Reason: By default, new Auth0 tenants have a database connection called Username-Password-Authentication. Since it's the default database connection name that many customers use, having it as the default reduces boilerplate.
Change: All three API variants — callback, Combine, and async/await — now guarantee that results are delivered on the main thread.
Any method returning Requestable or TokenRequestable delivers its result on the main thread regardless of which variant you use:
| Variant | How main thread delivery is guaranteed |
|---|---|
| Callback | The callback parameter is annotated @MainActor, giving both a compile-time and runtime guarantee. |
| Combine | The publisher wraps the @MainActor callback variant, so the Future promise resolves on the main actor and subscribers always receive values and completions on the main thread. |
| Async/await | The start() method is annotated @MainActor, so it always resumes on the main actor. |
In v2, completion callbacks would sometimes execute on the main thread and sometimes on background threads, depending on where URLSession completed the network request. The Combine and async/await variants inherited the same unpredictable threading. In v3, all three variants guarantee main thread delivery.
Affected APIs:
- All Authentication API methods (callback, Combine, and async/await)
- All Credentials Manager methods (callback, Combine, and async/await)
- All Web Auth methods (callback, Combine, and async/await)
- All My Account API methods (callback, Combine, and async/await)
Impact: If your code performs CPU-intensive work in callbacks or Combine subscribers, you should explicitly dispatch to a background queue. You no longer need DispatchQueue.main.async or .receive(on: DispatchQueue.main) when consuming results from Auth0.swift.
Migration example — callback
// v2 - thread was unpredictable, had to dispatch to main for UI updates
credentialsManager.credentials { result in
DispatchQueue.main.async {
self.updateUI(result)
}
}
// v3 - already on main thread
credentialsManager.credentials { result in
self.updateUI(result)
}
// v3 - dispatch to background for CPU-intensive work
credentialsManager.credentials { result in
DispatchQueue.global().async {
let processed = self.performExpensiveOperation(result)
DispatchQueue.main.async {
self.updateUI(processed)
}
}
}Migration example — Combine
// v2 - had to use receive(on:) to ensure main thread delivery
Auth0
.authentication()
.login(usernameOrEmail: email, password: password, realmOrConnection: "Username-Password-Authentication")
.start()
.receive(on: DispatchQueue.main)
.sink(receiveCompletion: { completion in
// ...
}, receiveValue: { credentials in
self.updateUI(credentials)
})
.store(in: &cancellables)
// v3 - receive(on:) is no longer needed, results are already on the main thread
Auth0
.authentication()
.login(usernameOrEmail: email, password: password, realmOrConnection: "Username-Password-Authentication")
.start()
.sink(receiveCompletion: { completion in
// ...
}, receiveValue: { credentials in
self.updateUI(credentials)
})
.store(in: &cancellables)Migration example — async/await
// v2 - start() could resume on any thread; had to hop to main for UI work
Task {
let credentials = try await Auth0
.authentication()
.login(usernameOrEmail: email, password: password, realmOrConnection: "Username-Password-Authentication")
.start()
await MainActor.run {
self.updateUI(credentials)
}
}
// v3 - start() is @MainActor, so it always resumes on the main thread
Task {
let credentials = try await Auth0
.authentication()
.login(usernameOrEmail: email, password: password, realmOrConnection: "Username-Password-Authentication")
.start()
self.updateUI(credentials) // already on main thread
}Reason: After performing operations with Auth0.swift, it's common to run presentation logic – for example, to show an error or navigate to the main flow of the app. Guaranteeing main thread delivery across all three API variants improves developer experience and eliminates an entire class of threading bugs.
When DPoP-bound credentials are stored, the Credentials Manager now validates the DPoP state before attempting renewal. The following new errors can be thrown by credentials(), apiCredentials(), and ssoCredentials():
Note
These errors are only relevant if you are using DPoP support. Ensure you handle these errors when migrating to v3.
| Error | Trigger | Affected methods |
|---|---|---|
.dpopNotConfigured |
Stored credentials are DPoP-bound but the Authentication client was not configured with .useDPoP(). |
credentials(), apiCredentials(), ssoCredentials() |
.dpopKeyMissing |
Stored credentials are DPoP-bound but the DPoP key pair is no longer available in the Keychain. | credentials(), apiCredentials(), ssoCredentials() |
.dpopKeyMismatch |
Stored credentials are DPoP-bound but the current DPoP key pair does not match the one used when credentials were saved. | credentials(), apiCredentials(), ssoCredentials() |
Additionally, store(credentials:) now persists the DPoP thumbprint alongside credentials when the Authentication client is configured with DPoP. This allows the Credentials Manager to detect key changes across app launches.
Migration example — handling DPoP validation errors
credentialsManager.credentials { result in
switch result {
case .success(let credentials):
// use credentials
break
case .failure(let error):
switch error {
case .dpopNotConfigured:
// Developer forgot to call useDPoP() on the Authentication client
// passed to the credentials manager. Fix the client configuration.
// e.g.:
CredentialsManager(authentication: Auth0.authentication().useDPoP())
case .dpopKeyMissing:
// DPoP key was lost. Clear local state and prompt user to re-authenticate
case .dpopKeyMismatch:
// DPoP key exists but doesn't match the one used at login (key rotation). Clear local state and prompt user to re-authenticate
default:
showError(error)
}
}
}Change: CredentialsManager storage methods now throw errors instead of returning Bool or optional Data.
The following methods have been updated:
| v2 | v3 |
|---|---|
store(credentials:) -> Bool |
store(credentials:) throws |
clear() -> Bool |
clear() throws |
clear(forAudience:scope:) -> Bool |
clear(forAudience:scope:) throws |
store(apiCredentials:forAudience:forScope:) (silent failure) |
store(apiCredentials:forAudience:forScope:) throws |
user (property, returns UserProfile?) |
userProfile() (throwing function, returns UserProfile?) |
The CredentialsStorage protocol methods have also changed:
| v2 | v3 |
|---|---|
getEntry(forKey:) -> Data? |
getEntry(forKey:) throws -> Data |
setEntry(_:forKey:) -> Bool |
setEntry(_:forKey:) throws |
deleteEntry(forKey:) -> Bool |
deleteEntry(forKey:) throws |
Impact: Any code that checks the Bool return value of store or clear must be updated to use do-try-catch. Code that ignores the return value (e.g. _ = credentialsManager.store(...)) can be migrated to try? if you want to continue ignoring failures, or wrapped in do-try-catch to handle them. If you have a custom CredentialsStorage implementation, you must update your method signatures and throw an error instead of returning false or nil.
Migration example — store and clear
// v2
if credentialsManager.store(credentials: credentials) {
// stored successfully
} else {
// handle failure
}
if credentialsManager.clear() {
// cleared successfully
} else {
// handle failure
}
// v3
do {
try credentialsManager.store(credentials: credentials)
// stored successfully
} catch {
// Storage failed — the underlying Keychain error is available via `error`.
// The user is still authenticated (credentials are valid in memory),
// but they will be prompted to log in again on the next app launch.
// Report to your error monitoring service (e.g. Sentry, Crashlytics, Datadog):
// Sentry.capture(error: error)
// Crashlytics.crashlytics().record(error: error)
}
do {
try credentialsManager.clear()
// cleared successfully
} catch {
// Keychain delete failed. Treat the user as logged out regardless —
// the session is no longer usable.
// Report to your error monitoring service (e.g. Sentry, Crashlytics, Datadog):
// Sentry.capture(error: error)
// Crashlytics.crashlytics().record(error: error)
navigateToLogin()
}
// v3 — if you want to silently ignore failures (not recommended)
try? credentialsManager.store(credentials: credentials)
try? credentialsManager.clear()Migration example — custom CredentialsStorage
// v2
class MyCustomStorage: CredentialsStorage {
func getEntry(forKey key: String) -> Data? {
return myStore[key]
}
func setEntry(_ data: Data, forKey key: String) -> Bool {
myStore[key] = data
return true
}
func deleteEntry(forKey key: String) -> Bool {
myStore.removeValue(forKey: key)
return true
}
}
// v3
class MyCustomStorage: CredentialsStorage {
func getEntry(forKey key: String) throws -> Data {
guard let data = myStore[key] else {
throw MyStorageError.itemNotFound
}
return data
}
func setEntry(_ data: Data, forKey key: String) throws {
myStore[key] = data
}
func deleteEntry(forKey key: String) throws {
guard myStore[key] != nil else {
throw MyStorageError.itemNotFound
}
myStore.removeValue(forKey: key)
}
}Downstream impact on async methods: Because clear() and store(credentials:) now surface errors, several callback-based methods gain new failure paths that were previously silent:
| Error | Trigger | Affected methods |
|---|---|---|
.noCredentials |
New in v3: getEntry(forKey:) throws when reading stored credentials (e.g. item not found). In v2 this was swallowed by try? — revoke() even returned .success when nothing was stored. .noCredentials already existed in v2 but was only thrown when stored data couldn't be decoded; it now also fires when the Keychain read itself fails. |
credentials(), renew(), apiCredentials(), ssoCredentials(), revoke() |
.storeFailed |
Keychain write fails when saving renewed or exchanged credentials. | credentials(), renew(), apiCredentials(), ssoCredentials() |
.clearFailed |
Keychain delete fails after successful token revocation, or when no refresh token is present. | revoke() |
In v3, the underlying storage error is propagated as the cause of the CredentialsManagerError, giving callers full context to respond appropriately.
Additionally, the user property has been replaced with the userProfile() method that now throws errors instead of silently returning nil:
| Method | Error thrown | Trigger |
|---|---|---|
userProfile() |
.noCredentials |
getEntry(forKey:) throws (e.g. item not found) or ID token cannot be decoded |
Migration example — userProfile error handling
// v2 — storage/decoding failures were silently swallowed
let user = credentialsManager.user // nil on any failure
// v3 — errors are now propagated
do {
let user = try credentialsManager.userProfile()
print("User profile: \(user)")
} catch {
// handle storage or decoding error
print("Failed to retrieve user profile: \(error)")
}
// v3 — if you want to preserve v2 behavior (not recommended)
let user = try? credentialsManager.userProfile()Migration example — handling new revoke failure path
// v2 — storage failures were silently ignored;
// revoke returned .success even when nothing was stored
credentialsManager.revoke { result in
switch result {
case .success:
navigateToLogin()
case .failure(let error):
// only .revokeFailed was possible here
showError(error)
}
}
// v3 — storage errors are now surfaced
credentialsManager.revoke { result in
switch result {
case .success:
navigateToLogin()
case .failure(let error):
switch error {
case CredentialsManagerError.noCredentials:
// no credentials in storage (getEntry threw) — nothing to revoke
navigateToLogin()
case CredentialsManagerError.revokeFailed:
// network revocation failed — refresh token may still be active
showError(error)
case CredentialsManagerError.clearFailed:
// token was revoked but credentials could not be removed from storage
// treat as logged out since the token is no longer valid
navigateToLogin()
default:
showError(error)
}
}
}Reason: Returning Bool or nil silently swallows the underlying Keychain error, making it impossible to distinguish a missing item from a permissions failure or a corrupted entry. Throwing the error directly gives callers full context to respond appropriately—for example, by prompting the user to re-authenticate or surfacing a meaningful error message.
Note
presentationWindow(_:) and useCredentialsManager(_:) (below) are new requirements on the WebAuth protocol itself, not just builder methods. If you have a custom type conforming to WebAuth (uncommon — most apps only call Auth0.webAuth()), it will fail to compile until it implements both. Auth0's own Auth0WebAuth implementation already conforms.
Auth0.swift uses the current key window to present the in-app browser for Web Auth. When using ASWebAuthenticationSession, it grabs the key window and uses it as the ASPresentationAnchor; with SFSafariViewController, it presents using the topmost view controller in that window. While this works well for single-window apps, multi-window apps may see the in-app browser appear in an unexpected window. Auth0.swift now supports passing a custom window for presentation. The following methods are added to the Web Auth builder:
presentationWindow(_ window:)
Code
Auth0
.webAuth()
.presentationWindow(window)
.start { result in
// ...
}New method: useCredentialsManager(_ credentialsManager:) — pass a CredentialsManager instance to the Web Auth client to automatically store credentials after a successful login and clear them after a successful logout.
Note
useCredentialsManager(_:) is optional. If you prefer to store and clear credentials manually after login and logout, you can continue to do so without calling this method.
Impact: If your app was manually storing credentials after login or clearing them after logout, you can now pass your CredentialsManager to the Web Auth client and remove that manual code. If you were using a custom CredentialsStorage (e.g., for a custom Keychain configuration), you can pass a CredentialsManager initialized with that storage and it will be used automatically.
Migration example: default storage
// v2 - had to manually store credentials after login
let credentialsManager = CredentialsManager(authentication: Auth0.authentication())
Auth0
.webAuth()
.start { result in
switch result {
case .success(let credentials):
try? credentialsManager.store(credentials: credentials)
case .failure(let error):
print("Failed with: \(error)")
}
}
// v2 - had to manually clear credentials after logout
Auth0
.webAuth()
.clearSession { result in
switch result {
case .success:
try? credentialsManager.clear()
case .failure(let error):
print("Failed with: \(error)")
}
}
// v3 - pass the CredentialsManager and credentials are stored/cleared automatically
let credentialsManager = CredentialsManager(authentication: Auth0.authentication())
Auth0
.webAuth()
.useCredentialsManager(credentialsManager)
.start { result in
switch result {
case .success(let credentials):
print("Obtained credentials: \(credentials)")
// Credentials are already stored automatically
case .failure(let error):
print("Failed with: \(error)")
}
}
Auth0
.webAuth()
.useCredentialsManager(credentialsManager)
.logout { result in
switch result {
case .success:
print("Logged out")
// Credentials are already cleared automatically
case .failure(let error):
print("Failed with: \(error)")
}
}Migration example: custom storage
If you were already using a custom CredentialsStorage implementation (for example, a custom Keychain configuration), simply pass the CredentialsManager with your custom storage to the Web Auth client.
// v2 - custom storage with manual store/clear
let customStorage = MyCustomKeychainStorage()
let credentialsManager = CredentialsManager(
authentication: Auth0.authentication(),
storage: customStorage
)
Auth0
.webAuth()
.start { result in
switch result {
case .success(let credentials):
try? credentialsManager.store(credentials: credentials)
case .failure(let error):
print("Failed with: \(error)")
}
}
// v3 - pass the CredentialsManager with custom storage
let customStorage = MyCustomKeychainStorage()
let credentialsManager = CredentialsManager(
authentication: Auth0.authentication(),
storage: customStorage
)
Auth0
.webAuth()
.useCredentialsManager(credentialsManager)
.start { result in
switch result {
case .success(let credentials):
print("Obtained credentials: \(credentials)")
// Credentials are stored using your custom storage automatically
case .failure(let error):
print("Failed with: \(error)")
}
}Note
If the credentials manager fails to store or clear credentials, a WebAuthError.credentialsManagerError will be thrown. The underlying error can be accessed via the cause property.
Important
You must call useCredentialsManager(_:) on both your start() and logout() call chains — omitting it on logout() will succeed but credentials will not be cleared automatically. Do not manually call store(credentials:) after login or clear() after logout on the same instance; the Web Auth client handles this automatically and doing so can lead to race conditions or inconsistent state.
// ✅ Credentials will be cleared automatically
Auth0.webAuth().useCredentialsManager(credentialsManager).logout { ... }Reason: Many apps need to store credentials after login and clear them after logout. The useCredentialsManager(_:) method reduces boilerplate and prevents common mistakes such as forgetting to store or clear credentials.
New method: clearAll() throws has been added to CredentialsManager.
This method removes all entries managed by the Credentials Manager from the Keychain (its configured storage/service), including the default credentials entry and any API credentials stored via store(apiCredentials:). It also resets the biometric authentication session (if biometric authentication was enabled).
This is different from the existing clear() method, which only removes the default credentials entry.
Warning
clearAll() delegates to the underlying storage's deleteAllEntries(), which removes every entry for the configured Keychain service/access group — not just the entries this specific CredentialsManager instance wrote. If you share the default Keychain service across multiple CredentialsManager instances (e.g. one per test, or one per audience with distinct storeKeys but the same default storage), calling clearAll() on any one of them wipes the others' stored credentials too. Give each instance its own dedicated storage (a SimpleKeychain with a distinct service) if you need clearAll() to be scoped to just that instance. See EXAMPLES.md for the same caution.
Impact: This is a new additive API. No migration is required. Use it when you need to completely wipe all stored credentials (e.g., on account deletion or full sign-out).
New method: deleteAllEntries() throws has been added to the CredentialsStorage protocol with a default implementation that triggers an assertionFailure.
If you're using a custom CredentialsStorage and plan to call clearAll(), you'll need to implement deleteAllEntries() in your custom storage — otherwise it will trigger an assertion failure. If you're not using clearAll(), no migration is required.
Migration example
// v2 - CredentialsStorage protocol
class MyCustomCredentialStorage: CredentialsStorage {
func getEntry(forKey key: String) -> Data? { ... }
func setEntry(_ data: Data, forKey key: String) -> Bool { ... }
func deleteEntry(forKey key: String) -> Bool { ... }
}
// v3 - Implement deleteAllEntries() if you plan to use clearAll()
class MyCustomCredentialStorage: CredentialsStorage {
func getEntry(forKey key: String) throws -> Data { ... }
func setEntry(_ data: Data, forKey key: String) throws { ... }
func deleteEntry(forKey key: String) throws { ... }
func deleteAllEntries() throws {
// Delete all entries from your custom storage
}
}Impact: If you have a custom CredentialsStorage implementation and use clearAll(), you must implement the deleteAllEntries() method. If you don't use clearAll(), no changes are needed — the default implementation will only trigger an assertion if called. The default SimpleKeychain-based storage already provides this implementation.
The following additive features were also included in this release. They require no migration — see the linked guides for usage:
- Passwordless OTP for database connections [EA]:
passwordlessChallenge(email:connection:allowSignup:),passwordlessChallenge(phoneNumber:connection:deliveryMethod:allowSignup:), andlogin(otp:challenge:audience:scope:)onAuthentication. If you have a customAuthenticationconformance, see New required methods on the Authentication protocol. Usage: EXAMPLES.md. - Password authentication method enrollment:
enrollPassword(userIdentityId:connection:)andconfirmPasswordEnrollment(id:authSession:newPassword:)onMyAccountAuthenticationMethods, plus the newPasswordEnrollmentChallengeandPasswordPolicytypes, for enrolling a password authentication method via the My Account API. If you have a customMyAccountAuthenticationMethodsconformance, see New required methods on the MyAccountAuthenticationMethods protocol. Usage: EXAMPLES.md. - Custom token exchange with actor tokens [EA]:
customTokenExchange(subjectToken:subjectTokenType:audience:scope:organization:actorToken:parameters:)convenience overload onAuthentication, plus the newActorTokenandActClaimtypes, for delegation and impersonation flows. Usage: EXAMPLES.md. - IPSIE
session_expiryenforcement [EA]:CredentialsManagernow enforces an upstream IdP session ceiling when your enterprise connection emits asession_expiryclaim, via the newCredentialsManagerError.sessionExpirederror andCredentials.sessionExpiresAtproperty. Usage: EXAMPLES.md.
Change: WebAuthError has been refined to provide more actionable error information:
Removed cases (now return .unknown with descriptive messages):
.noBundleIdentifier- Configuration error that should be caught during development.noAuthorizationCode- Rare edge case in PKCE flow.invalidInvitationURL- Configuration error for organization invitations.pkceNotAllowed- Configuration error (Application Type must be "Native" and Token Endpoint Authentication Method must be "None"). This error happens at most once when integrating the SDK into the app
New cases:
.authenticationFailed- Server-side authentication failures (wrong password, MFA required, account locked, etc.).codeExchangeFailed- Token exchange failures (network issues, invalid grant, backend errors, etc.).credentialsManagerError- The credentials manager failed to store or clear credentials. The underlying error can be accessed via thecauseproperty.
Impact: Error handling code needs to be updated to use the new error cases. The removed cases will now throw .unknown errors with descriptive messages.
Reason: The removed error cases represent configuration issues that should be caught during development, not handled in production code. This will result in a more useful and meaningful set of WebAuthError cases.
The following APIs have been renamed to align with the Android, Flutter, and React Native Auth0 SDKs:
| v2 | v3 |
|---|---|
clearSession(federated:) |
logout(federated:) |
UserInfo |
UserProfile |
Credentials.expiresIn |
Credentials.expiresAt |
APICredentials.expiresIn |
APICredentials.expiresAt |
SSOCredentials.expiresIn |
SSOCredentials.expiresAt |
Telemetry |
Auth0ClientInfo |
clearSession() → logout()
The clearSession(federated:) method on the Web Auth client has been renamed to logout(federated:). This affects all three API flavors: callback-based, Combine, and async/await.
UserInfo → UserProfile
The UserInfo struct has been renamed to UserProfile. The userInfo(withAccessToken:) method name on the Authentication client is unchanged, as it maps to the OIDC /userinfo endpoint.
expiresIn → expiresAt
The expiresIn property on Credentials, APICredentials, and SSOCredentials has been renamed to expiresAt. The JSON key (expires_in) and Keychain storage key are unchanged.
Telemetry → Auth0ClientInfo
The Telemetry struct has been renamed to Auth0ClientInfo, and the telemetry property on Trackable conforming types has been renamed to auth0ClientInfo.
Important
This section only affects you if your app has a custom type conforming to Authentication (for example, a mock or test double). If you only call Auth0.authentication(), no action is required.
Change: Three new methods have been added as requirements to the Authentication protocol, supporting the passwordless OTP flow for database connections:
func passwordlessChallenge(email: String, connection: String, allowSignup: Bool) -> any Requestable<PasswordlessChallenge, AuthenticationError>
func passwordlessChallenge(phoneNumber: String, connection: String, deliveryMethod: DeliveryMethod, allowSignup: Bool) -> any Requestable<PasswordlessChallenge, AuthenticationError>
func login(otp: String, challenge: PasswordlessChallenge, audience: String?, scope: String) -> any TokenRequestable<Credentials, AuthenticationError>Impact: Any custom type conforming to Authentication — for example a mock Authentication implementation used in unit tests — will fail to compile until it implements these three methods. Auth0's own Auth0Authentication implementation already conforms; this only affects custom conformances.
Migration example
// v3 beta.1/beta.2 - compiles
class MockAuthentication: Authentication {
// ... existing method implementations
}
// v3 (this release) - add the three new methods
class MockAuthentication: Authentication {
// ... existing method implementations
func passwordlessChallenge(email: String, connection: String, allowSignup: Bool) -> any Requestable<PasswordlessChallenge, AuthenticationError> {
fatalError("not implemented")
}
func passwordlessChallenge(phoneNumber: String, connection: String, deliveryMethod: DeliveryMethod, allowSignup: Bool) -> any Requestable<PasswordlessChallenge, AuthenticationError> {
fatalError("not implemented")
}
func login(otp: String, challenge: PasswordlessChallenge, audience: String?, scope: String) -> any TokenRequestable<Credentials, AuthenticationError> {
fatalError("not implemented")
}
}Reason: The passwordless OTP flow for database connections (see EXAMPLES.md) is exposed on the Authentication protocol, consistent with every other authentication flow in the SDK.
Important
This section only affects you if your app has a custom type conforming to MyAccountAuthenticationMethods (for example, a mock or test double). If you only call Auth0.myAccount(token:).authenticationMethods, no action is required.
Change: Two new methods have been added as requirements to the MyAccountAuthenticationMethods protocol, supporting password authentication method enrollment:
func enrollPassword(userIdentityId: String?, connection: String?) -> any Requestable<PasswordEnrollmentChallenge, MyAccountError>
func confirmPasswordEnrollment(id: String, authSession: String, newPassword: String) -> any Requestable<AuthenticationMethod, MyAccountError>Impact: Any custom type conforming to MyAccountAuthenticationMethods will fail to compile until it implements these two methods. Auth0's own Auth0MyAccountAuthenticationMethods implementation already conforms; this only affects custom conformances.
Migration example
// Before - compiles
class MockMyAccountAuthenticationMethods: MyAccountAuthenticationMethods {
// ... existing method implementations
}
// After - add the two new methods
class MockMyAccountAuthenticationMethods: MyAccountAuthenticationMethods {
// ... existing method implementations
func enrollPassword(userIdentityId: String?, connection: String?) -> any Requestable<PasswordEnrollmentChallenge, MyAccountError> {
fatalError("not implemented")
}
func confirmPasswordEnrollment(id: String, authSession: String, newPassword: String) -> any Requestable<AuthenticationMethod, MyAccountError> {
fatalError("not implemented")
}
}Reason: Password authentication method enrollment (see EXAMPLES.md) is exposed on the MyAccountAuthenticationMethods protocol, consistent with every other authentication method (passkey, recovery code, TOTP, push, email, phone) in the SDK.
Change: All Authentication, MyAccountAuthenticationMethods and MFAClient methods now return protocol types instead of the concrete Request struct:
- Credential-returning methods return
any TokenRequestable<T, E>(extendsRequestable, adds.validateClaims()and related modifiers — see ID Token Validation below). - All other methods (e.g.
signup,resetPassword,userInfo,jwks) returnany Requestable<T, E>.
Impact: Existing call sites that call .start(_:) directly are unaffected. The main benefit is that mocking the Authentication layer in tests no longer requires URLProtocol — you can implement the protocol directly.
Reason: With the previous concrete Request return type, the only way to intercept Auth0 calls in tests was by stubbing at the URL session layer. Protocol return types let you supply a lightweight mock implementation directly.
Mocking example
// Conforming type for credential-returning methods (TokenRequestable)
struct MockTokenRequest: TokenRequestable {
typealias ResultType = Credentials
typealias ErrorType = AuthenticationError
let mockResult: Result<Credentials, AuthenticationError>
func start(_ callback: @escaping @MainActor (Result<Credentials, AuthenticationError>) -> Void) {
Task { @MainActor in callback(mockResult) }
}
func validateClaims() -> any TokenRequestable<Credentials, AuthenticationError> { self }
func withLeeway(_ leeway: Int) -> any TokenRequestable<Credentials, AuthenticationError> { self }
func withIssuer(_ issuer: String) -> any TokenRequestable<Credentials, AuthenticationError> { self }
func withNonce(_ nonce: String?) -> any TokenRequestable<Credentials, AuthenticationError> { self }
func withMaxAge(_ maxAge: Int?) -> any TokenRequestable<Credentials, AuthenticationError> { self }
func withOrganization(_ organization: String?) -> any TokenRequestable<Credentials, AuthenticationError> { self }
}
class MockAuthentication: Authentication {
let mockResult: Result<Credentials, AuthenticationError>
init(mockResult: Result<Credentials, AuthenticationError>) {
self.mockResult = mockResult
}
func login(email: String, code: String, audience: String?, scope: String)
-> any TokenRequestable<Credentials, AuthenticationError> {
return MockTokenRequest(mockResult: mockResult)
}
}
// Test
func testLoginSuccess() {
let mockCredentials = Credentials(accessToken: "token", tokenType: "Bearer",
expiresAt: Date(), idToken: "id_token")
let mockAuth = MockAuthentication(mockResult: .success(mockCredentials))
mockAuth.login(email: "test@example.com", code: "123456", audience: nil, scope: "openid")
.start { result in
XCTAssertEqual(try? result.get().accessToken, "token")
}
}Change: All credential-returning methods on Authentication and MFAClient now return any TokenRequestable<T, E>. TokenRequestable extends Requestable and adds opt-in ID token claim validation via a chainable builder API.
Existing call sites that call .start(_:) directly are unaffected.
Usage example
// Basic — validate with defaults
Auth0
.authentication()
.renew(withRefreshToken: credentials.refreshToken)
.validateClaims()
.start { result in ... }
// Custom options
Auth0
.authentication()
.codeExchange(withCode: code, codeVerifier: verifier, redirectURI: redirectURI)
.validateClaims()
.withLeeway(120) // 2-minute clock skew
.withNonce("expected-nonce")
.withOrganization("org_abc123")
.start { result in ... }Chain any combination of the following modifiers after validateClaims():
| Modifier | Default | Description |
|---|---|---|
.withLeeway(_ leeway: Int) |
60 s |
Clock-skew tolerance in seconds. |
.withIssuer(_ issuer: String) |
Auth0 domain URL | Expected iss claim. |
.withNonce(_ nonce: String?) |
nil (skip) |
Expected nonce claim. |
.withMaxAge(_ maxAge: Int?) |
nil (skip) |
Maximum seconds since last authentication (auth_time). |
.withOrganization(_ organization: String?) |
nil (skip) |
Expected org_id or org_name claim. |
[!NOTE] When using Web Auth (PKCE flow), ID token validation is performed automatically. You do not need to call
validateClaims()yourself.
[!NOTE] This applies to the
Authenticationclient only. IfvalidateClaims()is enabled but the response does not contain an ID token, the request fails withAuthenticationErrorwrappingIDTokenDecodingError.missingIDTokenrather than silently succeeding. In Web Auth, a missing ID token results inWebAuthError.idTokenValidationFailedinstead.
Affected methods (now return any TokenRequestable instead of Request)
Authentication.login(email:code:audience:scope:)Authentication.login(phoneNumber:code:audience:scope:)Authentication.login(usernameOrEmail:password:realmOrConnection:audience:scope:)Authentication.loginDefaultDirectory(withUsername:password:audience:scope:)Authentication.login(appleAuthorizationCode:fullName:profile:audience:scope:)Authentication.login(facebookSessionAccessToken:profile:audience:scope:)Authentication.login(passkey:challenge:connection:audience:scope:)(bothLoginPasskeyandSignupPasskeyvariants)Authentication.codeExchange(withCode:codeVerifier:redirectURI:)Authentication.ssoExchange(withRefreshToken:)Authentication.renew(withRefreshToken:audience:scope:)MFAClient.verify(oobCode:bindingCode:mfaToken:)MFAClient.verify(otp:mfaToken:)MFAClient.verify(recoveryCode:mfaToken:)
Impact: No migration required for existing call sites. If you implement the Authentication protocol in your own mocks or test doubles, update the return type of the affected methods from Request<Credentials, AuthenticationError> to any TokenRequestable<Credentials, AuthenticationError> (see the mocking example above).
Note
This section only impacts you if your app used the Management API client (Auth0.users(...)). Removing it makes the SDK leaner — all consumers benefit from a smaller binary without carrying code they don't use.
Change: The Management API client has been removed. This includes the Auth0.users(token:domain:) and Auth0.users(token:session:bundle:) factory functions, the Users protocol, ManagementError, ManagementResult, and UserPatchAttributes.
Impact: Any code that calls Auth0.users(...) or references the removed types will no longer compile.
Migration: Instead of calling the Management API directly from your mobile app, expose dedicated endpoints in your own backend that perform the required operations, and call those from the app using the access token you already have.
For example, if you were reading or updating user metadata:
- Create a backend endpoint (e.g.
PATCH /me/metadata) that accepts the operation your app needs. - Call that endpoint from your app, passing the user's access token as a
Bearertoken in theAuthorizationheader. - On your backend, obtain a machine-to-machine token via the Client Credentials flow and use it to call the Management API with the precise scopes required.
Change: The following deprecated MFA methods have been removed from the Authentication protocol:
login(withOTP:mfaToken:)login(withOOBCode:mfaToken:bindingCode:)login(withRecoveryCode:mfaToken:)multifactorChallenge(mfaToken:types:authenticatorId:)
Impact: Any code that calls these methods on an Authentication client will no longer compile.
Migration: Use the dedicated MFAClient protocol instead, accessible via Auth0.mfa():
| Before (v2) | After (v3) |
|---|---|
Auth0.authentication().login(withOTP: otp, mfaToken: mfaToken) |
Auth0.mfa().verify(otp: otp, mfaToken: mfaToken) |
Auth0.authentication().login(withOOBCode: code, mfaToken: mfaToken, bindingCode: bindingCode) |
Auth0.mfa().verify(oobCode: code, bindingCode: bindingCode, mfaToken: mfaToken) |
Auth0.authentication().login(withRecoveryCode: code, mfaToken: mfaToken) |
Auth0.mfa().verify(recoveryCode: code, mfaToken: mfaToken) |
Auth0.authentication().multifactorChallenge(mfaToken: mfaToken, types: types, authenticatorId: id) |
Auth0.mfa().challenge(with: id, mfaToken: mfaToken) |
See the MFA API section in EXAMPLES.md for full usage details.
If you encounter issues during migration:
- GitHub Issues - Report bugs or ask questions
- Auth0 Community - Community support
If you use coding agents such as Claude Code or Cursor, we highly recommend adding the Auth0.swift migration skill to your repository:
npx skills add auth0/agent-skills --skill auth0-swift-major-migration