All articles
20 min·

Associated Domains on iOS: the 5 services to know

Universal Links, Password AutoFill, App Clips, Handoff, and developer mode. A complete guide to understanding what hides behind the `com.apple.developer.associated-domains` entitlement.

Par Carolane Lefebvre
iOSSwiftDeep linking
Associated Domains on iOS: the 5 services to know

When you start digging into the topic of Associated Domains on iOS, you quickly realize that the term actually covers several very different mechanisms. Behind a single entitlement and a single configuration file hide five distinct services, which solve five distinct problems. Mixing them up is a classic source of bugs, and it's also the reason many iOS developers only implement a small fraction of what they could.

This article aims to clear all that up. We'll start by understanding the common mechanism that links your app to a web domain, then we'll detail each of the five services, with a concrete implementation example for each. We'll wrap up with a debug checklist that will save you several hours the next time a Universal Link stubbornly refuses to open in your app.

The whole article uses a fictional example: an application MyApp whose web domain is myapp.com, with bundle identifier com.mycompany.myapp and Team ID ABCDE12345. Replace these values with your own when you move on to practice.

1. The underlying mechanism

Before talking about the services themselves, you need to understand how iOS establishes the link between your application and a web domain. This link relies on a static file called apple-app-site-association, which we'll often abbreviate AASA in the rest of the article.

When you install your app on a device, iOS looks at its entitlements and finds a list of associated domains there. For each domain, the system downloads the AASA file and verifies that it contains a declaration authorizing your app. Without this cross-check, any application could claim to handle the URLs of any site, which would be catastrophic from a security standpoint. The mechanism therefore guarantees that an app can only intercept the links of a domain if the owner of that domain has explicitly authorized it.

The AASA file must be served at the URL https://yourdomain.com/.well-known/apple-app-site-association, with no .json extension, over HTTPS only, with no redirect, and with the Content-Type application/json. These constraints may seem arbitrary but each has a reason, and we'll see later the concrete pitfalls they hide.

An important point to know right away: iOS does not download the AASA file directly from your server. The system goes through an intermediate CDN (A CDN (Content Delivery Network) is a geographically distributed network of servers that caches files to serve them faster to end users.) managed by Apple, accessible at the URL https://app-site-association.cdn-apple.com/a/v1/yourdomain.com. This CDN queries your server, caches the file, then serves that cached version to all iOS devices on the planet. The practical consequence is that a change to your AASA can take up to 24 hours to propagate. This single point explains a good chunk of the debug headaches iOS developers run into when setting up their Universal Links for the first time.

Key takeaway: your AASA is read once by Apple, not by every device. If you change the file and it doesn't work right away, it's not necessarily your mistake, it might just be that the CDN hasn't refreshed its cache yet. We'll see how to bypass that with developer mode.

Now let's move on to the five services that rely on this mechanism.

Universal Links are by far the best known and most used service. They allow a standard HTTPS link, for example https://myapp.com/product/42, to open your application directly if it's installed on the device, instead of opening Safari. If the app isn't installed, the link opens normally in the browser, which can then display a page inviting the user to download the app.

This switch happens without the user noticing. The link works everywhere: in Messages, in Mail, in Slack, in any push notification, or even when typed by hand into the address bar. That's what distinguishes it from a custom scheme like myapp://product/42, which only works if the link's sender knows your app's scheme in advance, which is rarely the case outside your own notifications.

Setup

Implementation happens in three steps: declare the domain in your app's entitlements, drop the AASA file on the web server, and intercept URLs in Swift code.

On the app side, you add the com.apple.developer.associated-domains entitlement with the entry applinks:myapp.com. The configuration is done in four steps in Xcode:

  1. Select your project in the file navigator, then your application target in the central panel.

  2. Go to the Signing & Capabilities tab.

  3. Click the + Capability button at the top left, then double-click Associated Domains in the list that appears.

  4. An Associated Domains section appears with a Domains field. Click the + to add a row, and enter applinks:myapp.com (be careful to include the applinks: prefix and not just the domain name).

Xcode automatically generates a MyApp.entitlements file in your project and associates it with the build config via the CODE_SIGN_ENTITLEMENTS key. Its content looks like this:

<key>com.apple.developer.associated-domains</key>
<array>
    <string>applinks:myapp.com</string>
</array>

Once the capability is added, check that your App ID on the Apple developer portal has Associated Domains enabled in the services list. In Automatic signing mode, Xcode handles this on its own, but in manual signing you need to regenerate the provisioning profile after this addition, otherwise the entitlement won't be signed correctly and iOS will silently ignore your configuration.

On the server side, you drop a file named apple-app-site-association (no extension) at the location /.well-known/ of your site. Its JSON content declares which apps are authorized to intercept which paths:

{
  "applinks": {
    "details": [
      {
        "appID": "ABCDE12345.com.mycompany.myapp",
        "paths": ["/product/*", "/article/*", "NOT /admin/*"]
      }
    ]
  }
}

The appID field combines your Team ID and your bundle identifier, separated by a dot. The paths field lists the paths your app will intercept, with three useful syntaxes. A literal path like /about matches exactly that URL. The wildcard * matches any sequence of characters, so /product/* matches /product/42 as well as /product/shoes/red. The NOT prefix excludes a path, which lets you say "all URLs except the admin interface".

Apple now recommends using a more expressive syntax based on the components field, which lets you filter not only on the path but also on the query string and on the fragment. This syntax is more verbose but more precise:

{
  "applinks": {
    "details": [
      {
        "appIDs": ["ABCDE12345.com.mycompany.myapp"],
        "components": [
          { "/": "/product/*" },
          { "/": "/article/*", "?": { "source": "email" }, "comment": "Only links sent by email" },
          { "/": "/admin/*", "exclude": true }
        ]
      }
    ]
  }
}

Both syntaxes are supported since iOS 13, and components is the one to favor for any new project.

Interception in Swift code

Once the app is installed with the right entitlement and the AASA file correctly served, iOS will automatically route URLs matching your paths to your application. What remains is handling that URL in code. In SwiftUI, this is done with the onOpenURL modifier applied at the level of your main scene:

@main
struct MyApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
                .onOpenURL { url in
                    handleDeepLink(url)
                }
        }
    }

    private func handleDeepLink(_ url: URL) {
        guard url.scheme == "https", url.host == "myapp.com" else { return }

        switch url.pathComponents {
        case [_, "product", let productID]:
            // Navigate to the product page
            navigate(to: .product(id: productID))
        case [_, "article", let articleID]:
            navigate(to: .article(id: articleID))
        default:
            break
        }
    }
}

In UIKit, the same work is done in application(_:continue:restorationHandler:) of your AppDelegate or SceneDelegate, via an NSUserActivity of type NSUserActivityTypeBrowsingWeb. The mechanism is the same at heart, only the API differs.

The four pitfalls that waste hours

Once this setup is done, you'll probably test, and there's a good chance it won't work the first time. Here are the four causes that cover 90% of cases.

First pitfall: the HTTP redirect. Many servers are configured to redirect /apple-app-site-association (at the root, without .well-known/) to /.well-known/apple-app-site-association, for backward compatibility reasons. iOS, however, categorically refuses to follow a redirect on this file. If your server returns a 301 or 302 code, the file is considered invalid, period. Make sure the URL /.well-known/apple-app-site-association responds directly with a 200 code and the JSON content, without any intermediary.

Second pitfall: the Apple CDN cache. As mentioned earlier, iOS goes through an intermediate CDN to retrieve your AASA. If you published an initial incorrect version of your file, then fixed it, the CDN can keep serving the old version for several hours. You can check what the CDN has cached by opening https://app-site-association.cdn-apple.com/a/v1/myapp.com directly in your browser. If the content doesn't match your current AASA, the problem is the cache. There is no "purge cache" button accessible to developers, you have to wait, or use the developer mode described later.

Third pitfall: the Content-Type. The file must be served with the Content-Type application/json. Some web servers, not recognizing the file name without an extension, send text/plain or application/octet-stream by default. iOS then silently rejects the file. On Nginx, the configuration looks like this:

location = /.well-known/apple-app-site-association {
    default_type application/json;
}

If you use a modern framework like Next.js or SvelteKit, the file dropped in the public/ folder is generally served correctly, but it's wise to check with a curl -I what your server actually returns.

Fourth pitfall: the badly signed entitlement. If your com.apple.developer.associated-domains entitlement isn't present in the provisioning profile your app is signed with, iOS completely ignores Associated Domains. Xcode handles this automatically in Automatic signing mode, but if you're in manual signing, the provisioning profile must have been regenerated after adding the capability in the App ID on the developer portal.

Coexistence with a custom scheme

Many apps, in addition to Universal Links, use a custom scheme of type myapp:// for their internal links. For example for widgets, local notifications, or quick actions from a shortcut. The two mechanisms can coexist perfectly, and that's often the best architecture.

The practical rule is the following: Universal Links are for public, shareable links that can come from anywhere and that must fall back to the web if the app isn't installed. Custom schemes are for links internal to your ecosystem, that only make sense if your app is installed, and that are never exposed to users who don't have it.

On the implementation side, the same onOpenURL modifier receives both URL types. You differentiate by inspecting the scheme:

private func handleDeepLink(_ url: URL) {
    if url.scheme == "https" {
        handleUniversalLink(url)
    } else if url.scheme == "myapp" {
        handleCustomScheme(url)
    }
}

The custom scheme is declared in the app's Info.plist, via the CFBundleURLTypes key, and requires no server-side configuration since it goes through no web mechanism.

💡 On this kind of plumbing (generating a valid AASA, debugging a silent Universal Link, wiring routing into an existing SwiftUI app), Claude Code saves me a fair amount of time on a daily basis. I cover the full workflow in my Claude Code × iOS guide.

3. webcredentials: Password AutoFill

The second service is significantly less known than Universal Links, but much simpler to set up and much more useful than it might seem. It allows the user's iCloud Keychain to share their identifiers and passwords between your website and your mobile application.

Imagine that a user created an account on myapp.com from their browser, letting iCloud generate and remember a password. A few days later, they install your application. Without webcredentials, they would either have to remember their password, or go fetch it manually in Settings. With webcredentials, iOS recognizes that the app and the site are linked, and directly suggests the password stored for myapp.com when they tap the Password field in the app. The reverse also works: a password created from the app is automatically suggested when the user goes back to the site.

Setup

Configuration is done on both sides. In your app's entitlements, you add webcredentials:myapp.com to the list of associated domains. In your AASA, you add a webcredentials key alongside the applinks key:

{
  "applinks": {
    "details": [{
      "appID": "ABCDE12345.com.mycompany.myapp",
      "paths": ["/product/*"]
    }]
  },
  "webcredentials": {
    "apps": ["ABCDE12345.com.mycompany.myapp"]
  }
}

On the Swift code side, there's almost nothing to do. The only thing to remember is to properly type your SwiftUI TextFields with the right textContentType:

TextField("Email", text: $email)
    .textContentType(.username)

SecureField("Password", text: $password)
    .textContentType(.password)

This is what allows iOS to identify a field where it should suggest credentials. For an account creation screen, you use .newPassword instead of .password, which tells iOS to generate a strong password rather than suggesting existing ones.

A classic conceptual pitfall

webcredentials is often confused with Sign in with Apple. They are however two completely independent systems. webcredentials shares passwords stored in the user's iCloud Keychain, for a classic identifier and password account. Sign in with Apple is a federated authentication system, where the user signs in with their Apple ID without ever creating a password.

If your app offers both webcredentials and Sign in with Apple, you have two different authentication flows that coexist. webcredentials won't do anything for users who signed up via Apple, since they have no password to store. Conversely, Sign in with Apple is available whether you have webcredentials or not. The two don't influence each other.

4. appclips: App Clips

An App Clip is a lightweight, instant version of an application, limited to 15 MB uncompressed, that can run without prior installation. When the user scans a QR code associated with an App Clip, taps an NFC tag, or clicks on a particular link, iOS downloads and launches this mini-version of the app in a few seconds, without going through the App Store.

The typical use case is a parking lot where the user scans a QR code to pay for their spot. They don't want to install a full app for a single three-euro transaction. The App Clip lets them pay immediately, then optionally offers to install the full app afterwards. Another frequent case: a restaurant where the menu is accessible via a QR code on the table, with ordering directly inside the App Clip.

Setup

On the Xcode side, an App Clip is a separate target from your main app, but one that shares code and resources. You create this target via File > New > Target > App Clip, and it automatically receives its own bundle identifier, generally com.mycompany.myapp.Clip.

The App Clip's associated-domain entitlement lists the domains with the appclips: prefix:

<key>com.apple.developer.associated-domains</key>
<array>
    <string>appclips:myapp.com</string>
</array>

On the AASA side, you add an appclips section with the App Clip's bundle identifier:

{
  "appclips": {
    "apps": ["ABCDE12345.com.mycompany.myapp.Clip"]
  }
}

An App Clip URL has a particular structure that allows iOS to differentiate an App Clip from a classic Universal Link. In practice, you associate an Advanced App Clip Experience in App Store Connect, which declares that URLs matching a certain pattern trigger the App Clip rather than the Universal Link. For example, you can say that https://myapp.com/clip/table/12 triggers the restaurant's App Clip, while https://myapp.com/menu remains a classic Universal Link.

When it's worth it

App Clips have a non-negligible development cost. You have to maintain two targets, deal with the 15 MB constraint, test flows without the full app installed. It's only worth it if your product has a friction moment where installation is a major blocker. If your app is mainly used by users who install it once and for all, an App Clip brings nothing. On the other hand, for apps where discovery happens in the physical world, through a QR code or NFC tag, an App Clip can multiply your conversion rate significantly.

5. activitycontinuation: Handoff (legacy)

The activitycontinuation service exists for historical reasons. Before iOS 13, the Handoff mechanism, which lets you, for example, start reading a web page on a Mac and resume it in an app on iPhone, required an explicit activitycontinuation:yourdomain.com declaration in the entitlements.

Since iOS 13, this use case is covered transparently by applinks:. So you no longer need to add activitycontinuation if your app targets iOS 13 or higher, which is the case for any app maintained today. The service is kept by Apple only for backward compatibility with older apps.

The only situation where you might need to declare it is if you maintain an app that still targets iOS 12 or earlier. Given that iOS 12 represents an infinitesimal fraction of the installed base in 2026, you can consider this service obsolete for any new implementation.

6. Developer mode 👀

We saw above that the Apple CDN cache could waste several hours of debugging, since a change to the AASA can take up to 24 hours to propagate. There is a little-documented solution to this problem: developer mode for Associated Domains.

Activation

This mode is activated in two steps. The first is in your entitlement, where you add the parameter ?mode=developer after the domain name:

<key>com.apple.developer.associated-domains</key>
<array>
    <string>applinks:myapp.com?mode=developer</string>
</array>

The second step is on the device itself. You have to go to Settings > Developer > Associated Domains Development and turn that option on. Note that this section only appears on devices where Developer Mode is active, which is enabled from Settings > Privacy & Security > Developer Mode.

Once this mode is active, iOS no longer goes through the Apple CDN to retrieve the AASA file. It directly queries your server on every install or update of the app. As a result, you can change your AASA, reinstall the app, and test immediately without waiting for the cache.

Limits and best practices

This mode is only meant for development. Whatever you do, don't leave ?mode=developer in the entitlements of a build distributed on the App Store. In production, you absolutely want to go through the Apple CDN, for performance reasons (the CDN is much faster than your web server) and resilience reasons (if your server goes down, the CDN keeps serving the cached version).

The good pattern consists of having two entitlement files in your Xcode project: one for Debug builds with developer mode active, one for Release builds without that parameter. The selection is done in your target's build settings, via the CODE_SIGN_ENTITLEMENTS key, which can take a different value depending on the configuration.

Now that the five services are clear, let's talk architecture. In an app that mixes Universal Links, custom schemes, and possibly App Clips, the code that receives URLs can quickly become a monster of nested conditions. Here's a pattern that holds up well over time.

The idea is to separate three responsibilities: the reception of the URL, the parsing that turns it into a business intent, and the routing that performs the navigation.

// 1. Reception: single entry point
@main
struct MyApp: App {
    @State private var router = Router()

    var body: some Scene {
        WindowGroup {
            ContentView()
                .environment(router)
                .onOpenURL { url in
                    if let intent = DeepLinkParser.parse(url) {
                        router.handle(intent)
                    }
                }
        }
    }
}

// 2. Parsing: turns a URL into an intent
enum DeepLinkIntent {
    case showProduct(id: String)
    case showArticle(id: String)
    case openPaywall
}

enum DeepLinkParser {
    static func parse(_ url: URL) -> DeepLinkIntent? {
        if url.scheme == "https", url.host == "myapp.com" {
            return parseUniversalLink(url)
        }
        if url.scheme == "myapp" {
            return parseCustomScheme(url)
        }
        return nil
    }

    private static func parseUniversalLink(_ url: URL) -> DeepLinkIntent? {
        let components = url.pathComponents
        switch components {
        case [_, "product", let id]: return .showProduct(id: id)
        case [_, "article", let id]: return .showArticle(id: id)
        default: return nil
        }
    }

    private static func parseCustomScheme(_ url: URL) -> DeepLinkIntent? {
        switch url.host {
        case "paywall": return .openPaywall
        default: return nil
        }
    }
}

// 3. Routing: performs the navigation
@Observable
final class Router {
    var path: [Destination] = []
    var presentedSheet: Sheet?

    func handle(_ intent: DeepLinkIntent) {
        switch intent {
        case .showProduct(let id):
            path.append(.product(id: id))
        case .showArticle(let id):
            path.append(.article(id: id))
        case .openPaywall:
            presentedSheet = .paywall
        }
    }
}

This split has several concrete advantages. The parser is unit-testable, without having to spin up a complete app. The business intents are a closed enum type, so the compiler forces you to handle every case when you add a new screen. And the router stays agnostic of where the intent came from, which lets you inject the same intents from a widget, a push notification, or an iOS shortcut, without duplicating navigation logic.

8. Debug checklist

When an Associated Domain doesn't work, here is the order of checks to run, from quickest to deepest.

Verify the file is properly served. From your terminal:

curl -I https://myapp.com/.well-known/apple-app-site-association

You must see a 200 code, a Content-Type: application/json, and no Location header (which would indicate a redirect). If any of these three points is wrong, iOS will never load your file.

Verify the file content. Still from your terminal:

curl https://myapp.com/.well-known/apple-app-site-association | jq

The JSON must be valid, and the appIDs must include the Team ID and the bundle identifier separated by a dot. A typo on the Team ID is a great classic that wastes hours.

Verify what the Apple CDN has cached. You can see exactly what iOS actually receives by querying the CDN directly:

curl https://app-site-association.cdn-apple.com/a/v1/myapp.com

If the content doesn't match your local AASA, you know the problem is the cache. Either you wait, or you switch to developer mode.

Read the system logs on the device. Plug your iPhone into your Mac, open Console.app, filter on the swcd process, which is the iOS daemon responsible for Associated Domains. Reinstall your app, and you'll see the detailed logs go by: AASA download, parsing, validation, association to the app. The errors there are generally explicit.

Test with the swcutil command. On macOS, the swcutil command lets you inspect the state of Associated Domains for an app installed on the Mac:

swcutil dl bundle com.mycompany.myapp

This command displays the result of the last AASA download, with any errors. For an iOS test, you can run the same diagnosis by connecting your iPhone and reading the logs via Console.app filtered on swcd.

Verify the signed entitlement. From the folder of your app installed on the simulator:

codesign -d entitlements - /path/to/MyApp.app

You should see com.apple.developer.associated-domains in the output. If this entitlement doesn't appear, your app isn't signed with the right provisioning profile, and iOS will completely ignore your Associated Domains, even if the AASA file is perfect.

Conclusion

Associated Domains are an elegant mechanism that cleanly solves a complex problem: proving that an app and a website are indeed controlled by the same entity, without requiring a secret exchange or a complicated protocol. A JSON file served over HTTPS is enough.

The five services we covered address very different needs. Universal Links (applinks:) are unavoidable as soon as your app has a web counterpart, and they're the first thing to set up. Password AutoFill (webcredentials:) is a huge user experience win for an almost zero implementation cost, and it's a shame to skip it. App Clips (appclips:) are more specialized and only worth it if your product has a physical or contextual discovery dimension. The activitycontinuation: service is now obsolete for any new implementation. Developer mode isn't really a service, but it's the tool that will save you several hours of debugging the next time you touch an AASA.

If you have to invest in just one of these services as a priority, set up Universal Links. It's the one with the most visible impact for your users, and it's also the one that will train you on the general mechanism, which will let you add the other services much faster afterwards.


Want to go further?

Associated Domains is a typical case where Claude Code changes the game on an iOS project: generating a valid AASA, debugging a Universal Link that won't trigger, wiring routing into an existing SwiftUI app. On Keepio, I use Claude Code for this kind of plumbing on a daily basis.

If you want to learn the real method for working with Claude Code as an iOS dev (agents, hooks, MCP, debug workflow, and a complete end-to-end project), I wrote a guide for it.

👉 Check out the Claude Code × iOS guide

Commentaires

Connecte-toi pour laisser un commentaire.

Aucun commentaire pour le moment. Sois le premier !

More articles

.task vs .onAppear vs .refreshable en SwiftUI

iOS 26 a livré 5 améliorations discrètes en SwiftUI

safeAreaInset vs safeAreaBar en SwiftUI