iosNudge

How do universal links work, and how are they different from custom URL schemes?

MediumApp lifecycleSecurity 2 min read

TL;DR

A universal link is a normal https:// URL. If the app is installed, iOS opens the app. If not, the link opens in Safari. It works because your website hosts an apple-app-site-association (AASA) file, and your app has the Associated Domains entitlement. Both sides prove they belong together. A custom URL scheme (myapp://) is not verified. Any app can claim the same scheme.

Setup in three parts

1. The AASA file on your server

Host it at https://example.com/.well-known/apple-app-site-association. It is JSON with no file extension, served over HTTPS with no redirects.

{
  "applinks": {
    "details": [
      {
        "appIDs": ["ABCDE12345.com.example.shop"],
        "components": [
          { "/": "/product/*", "comment": "Product pages" },
          { "/": "/account/*", "exclude": true }
        ]
      }
    ]
  }
}

ABCDE12345 is your Team ID. The components decide which paths open the app.

2. The entitlement in the app

In Signing & Capabilities, add Associated Domains:

applinks:example.com
applinks:www.example.com

SwiftUI:

WindowGroup {
    RootView()
        .onOpenURL { url in
            router.handle(url)
        }
}

UIKit scene delegate:

// App already running
func scene(_ scene: UIScene, continue userActivity: NSUserActivity) {
    guard userActivity.activityType == NSUserActivityTypeBrowsingWeb,
          let url = userActivity.webpageURL else { return }
    router.handle(url)
}

// Cold launch: the link is in connectionOptions
func scene(_ scene: UIScene, willConnectTo session: UISceneSession,
           options: UIScene.ConnectionOptions) {
    if let url = options.userActivities.first?.webpageURL {
        router.handle(url)
    }
}

Forgetting the cold launch case is a very common bug.

Parsing the URL

func handle(_ url: URL) {
    let parts = url.pathComponents   // ["/", "product", "123"]
    if parts.count == 3, parts[1] == "product" {
        path = [.product(id: parts[2])]
    }
}

How iOS checks the file

When the app is installed or updated, iOS downloads the AASA file through Apple's CDN, not directly from your server. So:

  • Changes can take time (hours to days) to reach devices.
  • For testing, use applinks:example.com?mode=developer and turn on Associated Domains Development in Developer settings on the device. Then the device fetches the file directly.
Universal linksCustom URL scheme
Formathttps://example.com/product/1myapp://product/1
Verified ownershipYes (AASA + entitlement)No
App not installedOpens the websiteError or nothing
Other apps can hijack itNoYes
Good forMarketing links, emails, sharingApp-to-app calls, OAuth callbacks (use ASWebAuthenticationSession)
  • The user typed the link into Safari's address bar. That stays in Safari.
  • The link is on the same domain that is already open in Safari.
  • The user long-pressed and chose "Open in Safari". iOS remembers this choice.
  • The AASA file is wrong, redirected, or not served with HTTPS.
  • Some apps open links in their own in-app browser.

Security note

Treat every incoming URL as untrusted input. Validate IDs and parameters. Never perform actions like payments or deleting data just because a link was opened. Ask the user to confirm.

Common follow-up questions

  • What is the difference with App Links on Android? Same idea. Android uses assetlinks.json.
  • How do you support links when the app is not installed and then gets installed? That is "deferred deep linking". It needs a third-party service or your own server logic. iOS does not provide it.
  • Can one link open different apps? Yes, list several appIDs. iOS picks an installed one.
  • What about App Clips? They use appclips: in Associated Domains and the same AASA file.