Skip to content
macOSDeep Dive Published Updated 8 min readViews unavailable

ASWebAuthenticationSession on macOS: Browser OAuth Callbacks and Lifecycle

Build a safer macOS browser sign-in with ASWebAuthenticationSession, custom callbacks, PKCE state, cancellation, and clear cookie-session expectations.

ASWebAuthenticationSession is Apple’s native bridge between a macOS app and a web-based sign-in flow. The app supplies an HTTPS authorization URL and a callback contract; Authentication Services coordinates the browser interaction and returns the matching callback URL to the session’s completion handler. The browser keeps its own authentication context instead of turning a WKWebView embedded in the app into a password collector.

The boundary matters: opening a browser is not OAuth verification, and receiving a callback is not proof that an account should be signed in. The app still owns request correlation, PKCE state, strict callback parsing, token exchange, server trust, cancellation, and secret handling. The service remains responsible for issuing and validating authorization codes and tokens.

Why the browser is a security boundary

On macOS, starting a web authentication session opens the default browser when that browser supports authentication sessions; otherwise the system uses Safari. The system presents the authentication domain to the user. After the service redirects to a URL matching the session callback, Authentication Services delivers that URL to the calling session, even if another app registered the same custom URL scheme.

This is different from displaying a login page in a view the application controls. OAuth 2.0 for native applications identifies the browser as an external user agent: the app cannot inspect or change the page or read the browser’s cookies. That separation lets a user reuse browser sign-in state when the flow permits it and prevents the app from impersonating a trusted browser surface. Do not replace the flow with a WKWebView merely because it is easier to style; the native-app best current practice requires an external user agent.

ASWebAuthenticationSession is only the handoff mechanism. It does not make an app a confidential OAuth client, validate the authorization server, establish an application session, or safely persist returned tokens.

Construct one authorization attempt

Start with the identity provider’s documented authorization endpoint and an exact redirect URI registered for the native client. Do not accept an arbitrary endpoint or redirect from an untrusted deep link or remote configuration. Build query parameters with URLComponents rather than concatenating strings so reserved characters are encoded once and consistently.

For OAuth authorization-code flow, create a new high-entropy state value and a new PKCE verifier for each attempt. Keep both in the pending attempt record, along with the expected provider and redirect URI. Send the state and the PKCE challenge to the authorization endpoint; never send the verifier in that browser request. Native public clients must use PKCE. Use the S256 transform:

code_challenge = BASE64URL(SHA256(ASCII(code_verifier)))

RFC 7636 specifies a cryptographically random verifier of 43 to 128 unreserved characters. Authorization servers must support S256, and a client capable of using S256 must use it. The verifier remains with the app until it exchanges the returned authorization code at the token endpoint. A native application cannot keep an embedded client secret confidential, so do not ship one in the bundle and mistake it for client authentication.

Keep the callback identifier stable and narrowly scoped. A private-use scheme based on a domain the publisher controls, written in reverse-domain form, is more deliberate than a generic name such as myapp. For example, a provider might register com.example.desktop:/oauth2redirect/provider-a. Apple’s HTTPS callback matcher can instead constrain the callback to a host in the app’s associated domains and an exact path. The default browser must declare CallbackURLMatchingIsSupported for this API; if it does not, the system sends the request to Safari. The authorization server must register the same redirect URI the app sends.

Configure and start one session

The callback-based initializer and ASWebAuthenticationSession.Callback types shown here require macOS 14.4 or later. Mark the containing code with an availability check or annotation; if the app supports earlier macOS releases, use the older callbackURLScheme initializer on a compatible fallback path and apply the same strict callback validation. That initializer is deprecated in the current SDK, so a product may instead choose macOS 14.4 as its minimum. The session object should be configured once, started once, and treated as a finite attempt:

import AuthenticationServices

@available(macOS 14.4, *)
func startSignIn(
    authorizationURL: URL,
    expectedRedirectURI: String,
    state: String,
    codeVerifier: String,
    presentationProvider: ASWebAuthenticationPresentationContextProviding
) {
    let callback = ASWebAuthenticationSession.Callback.customScheme(
        "com.example.desktop"
    )

    let session = ASWebAuthenticationSession(
        url: authorizationURL,
        callback: callback
    ) { callbackURL, error in
        guard error == nil, let callbackURL else {
            // Treat user cancellation as a normal terminal outcome.
            return
        }

        guard let expectedURL = URL(string: expectedRedirectURI),
              let expected = URLComponents(
                  url: expectedURL,
                  resolvingAgainstBaseURL: false
              ),
              let actual = URLComponents(
                  url: callbackURL,
                  resolvingAgainstBaseURL: false
              ),
              let expectedScheme = expected.scheme,
              actual.scheme?.lowercased() == expectedScheme.lowercased(),
              actual.host?.lowercased() == expected.host?.lowercased(),
              actual.port == expected.port,
              expected.user == nil,
              expected.password == nil,
              expected.fragment == nil,
              actual.user == nil,
              actual.password == nil,
              actual.percentEncodedPath == expected.percentEncodedPath,
              actual.fragment == nil,
              let items = actual.queryItems else {
            return
        }

        let stateValues = items.filter { $0.name == "state" }
        guard stateValues.count == 1,
              stateValues[0].value == state else {
            return
        }

        let codes = items.filter { $0.name == "code" }
        let errors = items.filter { $0.name == "error" }
        guard (codes.count == 1 && errors.isEmpty) ||
              (codes.isEmpty && errors.count == 1 && errors[0].value != nil) else {
            return
        }
        guard let code = codes.first?.value, !code.isEmpty else {
            // Handle a provider error as a failed authorization, not a login.
            return
        }

        // Exchange using the registered redirect URI and this attempt's verifier.
        // Do not log callbackURL, the code, state, or tokens.
        exchangeAuthorizationCode(
            code,
            redirectURI: expectedRedirectURI,
            verifier: codeVerifier
        )
    }

    session.presentationContextProvider = presentationProvider
    session.prefersEphemeralWebBrowserSession = false

    guard session.canStart, session.start() else {
        // Surface a start failure separately from a callback cancellation.
        return
    }
}

The presentation-context provider must return the appropriate NSWindow anchor for the current app UI. Set it before start(). The API’s start() result reports whether the session started; it is not the authorization result. Apple documents that start() should be called only once per instance. A coordinator can keep the pending state and provider identity together while the session runs, but macOS’s session retains itself after starting until its completion handler runs.

Cancellation is expected user behavior. Calling cancel() dismisses a presented authentication page; the completion handler can also report the system’s canceled-login error when the person closes the browser flow. Distinguish that from a provider error in the callback query and from failure to start. Clear the pending attempt on every terminal outcome so an old state value cannot accidentally validate a later sign-in.

Parse the callback as untrusted input

The callback matcher filters URLs; it does not replace application-level validation. Compare the callback’s scheme, host, port, user information, and exact path with the registered redirect URI, and reject fragments. Then require exactly one matching state value and compare it with the pending request. Reject repeated security-sensitive query fields. Treat a provider error as a failed attempt, and accept a success only when exactly one nonempty code is present and no error is present. Apply any provider-specific response validation required by the protocol configuration.

After parsing, send the code, original redirect URI, and matching verifier to the token endpoint over TLS. The token endpoint must verify PKCE and issue tokens only if the code, client, redirect URI, and verifier agree. Only after the exchange succeeds should the app establish its own authenticated state. Do not treat a matching custom scheme, a browser dismissal, or a syntactically valid URL as login success.

Callback URLs can contain short-lived codes, state, provider errors, and other sensitive parameters. Avoid printing the full URL in application logs, crash reports, analytics, or support bundles. Log a correlation identifier and an error category instead.

Choose browser-session sharing deliberately

By default the session can use normal browser data. Set prefersEphemeralWebBrowserSession before start() only when the product deliberately wants to request a private browser session. An ephemeral request can prevent reuse of existing sign-in cookies, so the user may have to authenticate again. Apple documents that Safari respects the request, while a different default browser on macOS may or may not. It is a preference sent to the browser, not a guarantee that every browser will isolate storage.

Test both the default-browser and Safari fallback paths, cancellation, repeated starts, provider-declared errors, malformed or duplicate callback parameters, state mismatch, and a failed token exchange. Use a provider sandbox and synthetic accounts. Also test with ephemeral preference both enabled and disabled, and record which browser handled the attempt. Never make a production token or customer credential part of a fixture.

The practical contract is narrow: Authentication Services owns the browser handoff and callback delivery; the app owns OAuth correlation and token use; and the identity provider owns authorization and token validation. Keeping those responsibilities separate yields a native login flow without turning a browser callback into an authorization shortcut.

Related:

Sources:

Comments