A deep dive into the practical challenges of implementing, testing, and maintaining Universal Links at scale


Originally published on the Just Eat Takeaway Engineering Blog.

Universal Links have been around since iOS 9 (2015), yet the topic remains surprisingly underrated in the iOS community. While most developers understand the basic concept (associating your website with your app so links open directly in the app) the practical challenges of implementing and maintaining Universal Links at scale are rarely discussed.

When a user taps a Universal Link, iOS checks if the domain is associated with any installed app. If it is, iOS opens the app directly. If not, it opens the link in the browser. This "universal" behavior makes them superior to custom URL schemes (deep links) for user-facing communications.

However, despite their importance, many developers treat AASA files as simple configuration files, overlooking the complex challenges involved in validating, testing, and maintaining them at scale.

In 2024, I put a lot of effort into crafting a solid solution for some overlooked challenges surrounding universal links. Every time I refer back to that work, I am impressed by how well it has served the company, which constantly renews my desire to write about it. GenAI has become incredibly helpful with the drafting process, so I finally have no excuse not to share this story!

In this post, I'll walk through the real-world challenges I've encountered and the solutions I've developed over the years working with Universal Links across multiple web domains and localized applications.


Universal Links work through a combination of three pieces:

  1. Associated Domains Entitlement in your app (the .entitlements file)
  2. Apple App Site Association (AASA) file on your website
  3. Proper handling of incoming links in your app code

The AASA file must be served from /.well-known/apple-app-site-association over HTTPS, without redirects, and with the correct content type (application/json).

Here's a simple AASA file:

{
  "applinks": {
    "details": [
      {
        "appIDs": ["TEAMID.com.example.app"],
        "components": [
          { "/": "/account/login" },
          { "/": "/restaurants/*" }
        ]
      }
    ]
  }
}

What most tutorials don't tell you is that this is just the beginning. Real-world AASA files are far more complex, and validating them is a challenge in itself. The reality gets complicated when you need to:

  • Validate that your AASA file respects a schema
  • Test links before deploying to production
  • Handle dynamic URL patterns with substitution variables
  • Ensure Apple's CDN has picked up your latest changes
  • Parse and match wildcard patterns correctly
  • Handle encoding and special characters

Challenge 1: Nobody Validates Against a JSON Schema

Here's a dirty secret: most AASA files in production have never been validated against a schema. Teams deploy files, hope for the best, and only discover issues when links stop working.

Why It Matters: An invalid AASA file might be served successfully but fail to associate your app with your website. iOS won't throw errors; Universal Links simply won't work, and you might not notice until users report issues.

Online validators like branch.io/resources/aasa-validator and getuniversal.link check basic accessibility and JSON parsing, but they don't validate the actual schema. A file can be valid JSON yet completely invalid as an AASA file.

The Solution: JSON Schema Validation in CI

Create a comprehensive JSON Schema that validates the entire AASA structure, including:

  • Required fields (applinksdetailsappIDscomponents)
  • Optional fields (substitutionVariablesexcludecaseSensitivepercentEncoded)
  • Proper nesting and data types
  • Support for other AASA features (webcredentialsappclipsactivitycontinuation)

Here's a schema that defines the correct structure of an AASA file (just for the applinks section) :

{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "type": "object",
  "properties": {
    "applinks": {
      "type": "object",
      "properties": {
        "defaults": {
          "type": "object",
          "properties": {
            "caseSensitive": {
              "type": "boolean"
            },
            "percentEncoded": {
              "type": "boolean"
            }
          }
        },
        "details": {
          "type": "array",
          "items": {
            "type": "object",
            "properties": {
              "appIDs": { "type": "array", "items": { "type": "string" } },
              "components": {
                "type": "array",
                "items": {
                  "type": "object",
                  "properties": {
                    "/": { "type": "string" },
                    "?": { "type": "object" },
                    "#": { "type": "string" },
                    "exclude": { "type": "boolean" },
                    "caseSensitive": { "type": "boolean" },
                    "percentEncoded": { "type": "boolean" }
                  },
                  "required": ["/"]
                }
              },
              "defaults": {
                "type": "object",
                "properties": {
                  "caseSensitive": { "type": "boolean" },
                  "percentEncoded": { "type": "boolean" }
                }
              }
            }
          }
        },
        "substitutionVariables": { "type": "object" }
      },
      "required": ["details"]
    }
  },
  "required": ["applinks"]
}

Integrating schema validation into your CI pipeline ensures invalid files never reach production. This catches issues like:

  • Missing required fields
  • Wrong types (string instead of array)
  • Typos in property names (which would be silently ignored)
  • Invalid component structures

You might want to consider building a Swift CLI tool with Argument Parser, in which case I would suggest using JSONSchema.swift.

Challenge 2: The Apple CDN Layer

Here's something that surprises many developers: iOS doesn't fetch the AASA file directly from your website. Instead, Apple operates a CDN that caches AASA files from websites.

The CDN URL follows this pattern:

https://app-site-association.cdn-apple.com/a/v1/<domain>

For example, for just-eat.co.uk:

  • Websitehttps://just-eat.co.uk/.well-known/apple-app-site-association
  • Apple CDNhttps://app-site-association.cdn-apple.com/a/v1/just-eat.co.uk

Why It Matters: This caching happens periodically (every few hours), and there's no guarantee that your latest changes are immediately available. If your website's AASA file differs from what's cached on Apple's CDN, Universal Links may not work as expected. You might deploy a fix, but iOS devices could still be using the old cached version for hours or even days.

The Solution: CDN Validation

To ensure your AASA file has propagated correctly, you need to compare the file on your website with the one on Apple's CDN. This validates that:

  1. Your file is publicly accessible and has a valid SSL certificate
  2. The file has the correct MIME type (application/json)
  3. Apple's CDN has successfully cached your latest version

Here's a validator that does exactly this:

struct AASAContent: Equatable, Decodable {
    let appLinks: AppLinks
    
    enum CodingKeys: String, CodingKey {
        case appLinks = "applinks"
    }

    // and nested Decodable structs
}

enum AASAFileLocation {
    case website
    case appleCdn

    func buildURL(with domain: Domain) throws -> URL {
        switch self {
        case .website:
            return URL(string: "https://\(domain)")!
                .appendingPathComponent(".well-known")
                .appendingPathComponent("apple-app-site-association")
        case .appleCdn:
            return URL(string: "https://app-site-association.cdn-apple.com/a/v1/")!
                .appendingPathComponent(domain)
        }
    }
}
    
func validateCDN(for domain: Domain) async throws {
    let websiteURL = try AASAFileLocation.website.buildURL(with: domain)
    let appleCdnURL = try AASAFileLocation.appleCdn.buildURL(with: domain)

    let domainFile: AASAContent = try await downloadFile(url: websiteURL)
    let appleFile: AASAContent = try await downloadFile(url: appleCdnURL)

    guard domainFile == appleFile else {
        throw ValidateCDNError.fileMismatch(domain: domain)
    }
}

Running this validation daily in CI ensures you're alerted when CDN synchronization fails or is delayed. A daily automated check can alert you if there's a mismatch, allowing you to investigate and resolve issues before they impact users. This simple check has prevented numerous incidents where teams assumed links were working when they weren't.

Developer Mode Bypass

For development and debugging, iOS offers a bypass. By adding ?mode=developer to your associated domain:

<string>applinks:just-eat.co.uk?mode=developer</string>

Debug builds should use a specific entitlements file where the developer mode is used. Debug builds will fetch the AASA file directly from your domain, bypassing the CDN. This requires enabling "Associated Domains Development" in iOS Settings → Developer. App Store builds always use the CDN and their entitlements file shouldn't mention the developer mode.

Challenge 3: Regular Expression Parsing and Pattern Matching

The AASA file supports powerful pattern matching through wildcards for flexible URL matching. However, these patterns aren't standard regex and use Apple's own pattern syntax that needs to be converted to regular expressions for validation.

The Pattern Syntax:

  • * matches zero or more characters (converted to .* in regex)
  • ? matches exactly one character (converted to . in regex)
  • ?* matches one or more characters (converted to .+ in regex)
  • *? also matches one or more characters (converted to .+ in regex)

The Problem: I couldn't find any online tool or open-source library implementing Apple's matching logic. If you want to validate that specific URLs match your AASA file patterns (for testing or regression prevention), you need to correctly parse and convert these patterns. Online validators like Branch.io's AASA validator don't support this matching logic, they only validate the file structure.

The Solution: Implementing Apple's Matching Logic

I built a custom validator that implements the matching rules. The key insight is that Apple's wildcards map to regular expressions. A naïve conversion (where the order of substitutions is important) would look like this:

extension String {
    var regEx: String {
        self
            // One or more characters
            .replacingOccurrences(of: "?*", with: ".+")
            
            // One or more characters
            .replacingOccurrences(of: "*?", with: ".+")
            
            // Zero or more characters
            .replacingOccurrences(of: "*", with: ".*")
            
            // Exactly one character
            .replacingOccurrences(of: "?", with: ".")
    }
}

Additionally, you need to handle URL components properly. For example, if a pattern specifies only a path (/restaurants/*), you should still match URLs that have query parameters or fragments, unless explicitly excluded. This requires careful construction of the regex pattern to account for optional components, which to be completely honest was very tricky to implement by hand at a time when LLMs weren't too helpful.

Challenge 4: The substitutionVariables Problem

Apple supports applinks.substitutionVariables for dynamic URL matching. This feature allows you to define variables that can be used in path, query, and fragment components. Substitution variables are particularly helpful to reduce duplication when dealing with URLs that are localised per language. However, I couldn't find any online validator or open-source tool to support validating links against AASA files that use substitution variables.

Here's a real-world example from a multi-language website:

{
  "applinks": {
    "substitutionVariables": {
      "menu": ["speisekarte", "menu"],
      "stamp-cards": ["stempelkarten", "stamp-cards", "cartes-épargne", "stempelkaarten"]
    },
    "details": [{
      "appIDs": ["TEAMID.com.example.app"],
      "components": [
        { "/": "/$(lang)/$(menu)/?*" },
        { "/": "/", "#": "$(stamp-cards)" },
        { "/": "/", "#": "$(order-history)" }
      ]
    }]
  }
}

The pattern /$(lang)/$(menu)/?* should match URLs like:

  • /de/speisekarte/restaurant-name
  • /en/menu/restaurant-name
  • /fr/menu/pizza-place

And /#$(stamp-cards) should match:

  • /#stempelkarten
  • /#stamp-cards
  • /#cartes-épargne

Why It Matters: Without proper support for substitution variables, you can't validate that your Universal Links work correctly. You might think a URL should match, but if the substitution isn't handled correctly, it won't.

The Solution: Substitution Variable Expansion

Implement substitution variable expansion before pattern matching. Here is a trimmed down example:

  1. Parse substitution variables from the AASA file
  2. Replace variable references ($(variableName)) with regex alternatives of their possible values
  3. Handle default variables like $(lang) and $(region) which match any two characters
  4. Apply the expanded pattern to URL matching after converting Apple's pattern syntax to standard regex

The key insight is that substitution variables create a disjunction (OR) of possible values:

func replaceWithSubstitutionVariables(_ substitutionVariables: [String: [String]]) -> String {
    var modifiedString = self
    let substitutionVariablesWithDefaults = substitutionVariables.merging(defaultSubstitutionVariables) { (current, _) in current }
    
    for (key, values) in substitutionVariablesWithDefaults {
        let pattern = "\\$\\(\(key)\\)"
        let replacement = "(\(values.joined(separator: "|")))"
        // Replace $(key) with (value1|value2|value3)
        modifiedString = modifiedString.replacingOccurrences(
            of: pattern, 
            with: replacement, 
            options: .regularExpression
        )
    }
    return modifiedString
}

private var defaultSubstitutionVariables: [String: [String]] {
    [
        "lang": [".."],
        "region": [".."]
    ]
}

So /$(lang)/$(menu)/?* with the variables above becomes:

/(..)/((speisekarte|menu))/.+

Note: $(lang) and $(region) are special default variables Apple provides that match any two characters.

The order of operations matters: first expand substitution variables, then convert Apple's pattern syntax (*??*) to standard regex. This ensures that wildcards within substitution variable values are handled correctly.

The Full Matching Pipeline

The complete validation process:

  1. Parse the AASA file and extract components for the target bundle ID
  2. For each component, build a regex pattern by:
    • Replacing substitution variables with alternations
    • Converting * and ? to regex equivalents
    • Handling paths, query parameters, and fragments
  3. Match incoming URLs against these patterns
  4. Account for the exclude flag that explicitly prevents matching

The matcher also needs to handle edge cases:

  • URLs with query parameters not specified in the component (allowed)
  • URLs with fragments not specified in the component (allowed)
  • The exclude: true flag that creates negative matches
  • Case sensitivity settings
  • Percent encoding

Here's the core matching logic:

enum AllowPolicy {
    case allowed
    case notAllowed
}

func validateDeepLinking(
    policy: AllowPolicy,
    for url: URL,
    domain: Domain,
    components: [AASAContent.AppLinks.Detail.Component],
    substitutionVariables: AASAContent.AppLinks.SubstitutionVariables
) throws {
    switch policy {
    case .allowed:
        for component in components {
            let regEx = try regEx(for: component, substitutionVariables: substitutionVariables, on: domain)
            if findMatch(for: url, in: regEx) {
                if component.exclude != true {
                    return
                } else {
                    throw ValidateUniversalLinkError.excludedUniversalLink(url: url)
                }
            }
        }
        throw ValidateUniversalLinkError.unhandledUniversalLink(url: url)
    case .notAllowed:
        for component in components {
            let regEx = try regEx(for: component, substitutionVariables: substitutionVariables, on: domain)
            if findMatch(for: url, in: regEx) {
                if component.exclude == true {
                    return
                } else {
                    throw ValidateUniversalLinkError.incorrectlyHandledUniversalLink(url: url)
                }
            }
        }
    }
}

Important: Components are evaluated in order, and the first match wins. This means exclusion rules must come before the broader patterns they're excluding from.

Challenge 5: Testing Before Production

One of the trickiest aspects of Universal Links is testing. You can't just deploy to production and hope it works. Testing requires the AASA file to be hosted on a real domain with proper SSL certificates. You can't just test locally or in a simulator without additional setup.

Why It Matters: Deploying untested AASA changes to production can break Universal Links for all users. Since Apple caches AASA files, fixing issues can take hours or days to propagate.

The Solution: A Staging Environment with Real Domains

Set up a staging environment using AWS infrastructure (or similar):

  1. S3 bucket to host AASA files
  2. CloudFront distributions for each staging domain with HTTPS
  3. Route53 records pointing staging subdomains to CloudFront

The staging domains follow a pattern like:

lieferando-de.aasa-staging.mobile-team.example.com

This mirrors the production domain lieferando.de and serves the same AASA file structure.

Debug builds include both staging and production domains in its entitlements:

<key>com.apple.developer.associated-domains</key>
<array>
    <string>applinks:lieferando-de.aasa-staging.mobile-team.example.com</string>
    <string>applinks:lieferando.de</string>
    <string>applinks:www.lieferando.de</string>
</array>

Now you can test a link like:

https://lieferando-de.aasa-staging.mobile-team.example.com/menu/pizzeria

Instead of:

https://lieferando.de/menu/pizzeria

The staging URL opens your debug/ad-hoc build exactly like the production URL would open your App Store build, but without risking production changes.

Important Testing Considerations

  • TestFlight builds are production builds and therefore use production entitlements and won't work with staging domains
  • Ad-hoc and debug builds can include staging domains
  • Simulator testing has limitations. Universal Links work best on physical devices
  • Developer mode must be enabled on device for direct AASA fetching (bypassing CDN)
  • For non-debug builds, you'll need to wait for Apple's CDN to cache your staging AASA file

Manual testing doesn't scale. Every change to the AASA file needs verification across dozens or hundreds of URLs. And you need to verify both:

  1. URLs that should open the app (deep-linkable)
  2. URLs that should not open the app (non-deep-linkable, excluded)
  3. Complex URLs with query parameters, fragments, and wildcards

Why It Matters: Without comprehensive validation, you might:

  • Miss URLs that should deep link but don't
  • Accidentally deep link URLs that should open in the browser
  • Break existing functionality when making changes

The Solution: Automated Content Validation

I maintain JSON files alongside each AASA file listing the expected behavior:

{
  "deep_linkable_urls": [
    "https://lieferando.de/",
    "https://lieferando.de/punkte",
    "https://lieferando.de/#stempelkarten",
    "https://lieferando.de/en#stamp-cards",
    "https://lieferando.de/lieferservice/essen/berlin-10115",
    "https://lieferando.de/en/delivery/food/berlin-10115"
  ],
  "non_deep_linkable_urls": [
    "https://lieferando.de/?openOnWeb=true",
    "https://lieferando.de/anyPath?openOnWeb=true"
  ]
}

The validator ensures:

  • Every URL in deep_linkable_urls matches a non-excluded component
  • Every URL in non_deep_linkable_urls either doesn't match or matches an excluded component

This runs on CI on every pull request. Changes to the AASA file must include updates to the expected URLs, creating living documentation of what's supported.

Error Types

The validator catches several error conditions:

  1. Unhandled URL: A URL expected to be deep-linkable doesn't match any component
  2. Excluded URL: A URL expected to be deep-linkable matches an excluded component
  3. Incorrectly Handled URL: A URL expected to be non-deep-linkable actually matches a component

Each error provides clear diagnostics:

enum ValidateUniversalLinkError: Error {
    case unhandledUniversalLink(url: URL)
    case excludedUniversalLink(url: URL)
    case incorrectlyHandledUniversalLink(url: URL)
    // ...
}

Challenge 7: Encoding and Special Characters

Universal Links often contain special characters, especially for localized content:

https://lieferando.at/fr#cartes-épargne

The fragment cartes-épargne contains an accented character. Here's the critical rule:

Universal Links must NOT be percent-encoded in the AASA file or when shared with users.

✅ Correct: https://lieferando.at/fr#cartes-épargne
❌ Wrong: https://lieferando.at/fr#cartes-%C3%A9pargne

However, when these URLs are processed by URLComponents in Swift, they may get encoded. The validator must handle both forms and compare them correctly:

private func findMatch(for url: URL, in regEx: NSRegularExpression) -> Bool {
    let searchString = url.absoluteString.removingPercentEncoding!
    let searchRange = NSRange(location: 0, length: searchString.utf16.count)
    if let result = regEx.firstMatch(in: searchString, options: [.anchored], range: searchRange) {
        return result.range.length == searchRange.length
    }
    return false
}

The key is to decode the URL before matching against the regex.

Putting It All Together: The Complete Validation Pipeline

To address all these challenges, I built AASAValidator, a Swift command-line tool that provides three main commands:

  1. validate-schema: Validates AASA files against a JSON schema
  2. validate-cdn: Compares your website's AASA file with Apple's CDN version
  3. validate-universal-links: Validates that specific URLs match (or don't match) your AASA file patterns

The tool handles:

  • JSON schema validation
  • CDN comparison
  • Regular expression parsing and conversion
  • Substitution variable expansion
  • Complex URL matching with query parameters and fragments
  • Exclusion validation
  • Percent encoding

The Full Automation Pipeline

1. PR Validation (CI)

  • Schema validation: Ensure AASA files are structurally correct
  • Content validation: Verify all expected URLs match (or don't match) correctly
  • Bundle ID validation: Ensure the target app's bundle ID is in the AASA

2. Post-Deployment (Staging)

  • Deploy AASA files to staging environment
  • Test Universal Links on physical devices with staging builds
  • Verify end-to-end behavior

3. Post-Deployment (Production)

  • Deploy AASA files to production
  • CDN validation: Check that Apple's CDN has the latest version
  • Smoke test with production app

4. Ongoing Monitoring

  • Daily CDN synchronization checks
  • Alerting if files fall out of sync

Example GitHub Actions Integration

Here's how you might use the validator in a GitHub Actions workflow:

strategy:
  fail-fast: false
    matrix:
      domain:
        - just-eat.co.uk
        - just-eat.es
      bundle-id:
        - com.eatch.mobileapp
        - com.justeat.JUSTEAT
        - com.takeaway.lu

steps:

  - name: Validate AASA Schema
    run: |
      AASAValidator validate-schema \
        --aasa-path ./${{ matrix.domain }}-aasa.json \
        --json-schema-path path/to/JSONSchema.json

  - name: Validate Universal Links
    run: |
      AASAValidator validate-universal-links \
        --bundle-id ${{ matrix.bundle-id }} \
        --domain ${{ matrix.domain }} \
        --aasa-path ./${{ matrix.domain }}-aasa.json \
        --universal-links-path ./universal-links/${{ matrix.domain }}.json

Key Takeaways

  1. Validate against a schema: JSON parsing success doesn't mean your AASA file is valid. Use JSON Schema validation in CI to catch structural errors early.
  2. Don't trust the CDN blindly: Always verify that Apple's CDN has your latest AASA file. Implement automated daily checks.
  3. Implement custom regex parsing: No existing tool handles Apple's wildcard pattern syntax. Build your own matcher to validate URL matching.
  4. Test substitution variables properly: No existing tool handles applinks.substitutionVariables. Build your own matcher or use custom validation logic.
  5. Implement proper staging: Set up real staging domains with HTTPS. Testing Universal Links requires real infrastructure.
  6. Automate regression testing: Maintain lists of expected URLs and validate them automatically. Manual testing doesn't scale.
  7. Watch your encoding: Special characters must not be percent-encoded in Universal Links. Handle encoding carefully in your validation logic.
  8. Order matters for exclusions: Components are evaluated in order. Place exclusion rules before broader patterns.
  9. Plan for multi-language support: If your website supports multiple languages, your AASA file needs substitution variables to handle localized URLs.

Conclusion

Universal Links are deceptively simple on the surface but require careful attention to detail in practice. The challenges we've discussed (schema validation, CDN synchronization, regex parsing, substitution variables, staging environments, comprehensive link validation, and encoding) are often ignored but are essential for a robust implementation.

By building tooling that addresses these challenges and integrating validation into your development workflow, you can ensure that Universal Links work reliably for your users. The investment in proper validation and testing pays off by preventing production issues and giving you confidence when making changes to your AASA files.

Consider implementing similar validation for your own Universal Links setup and your future self will thank you.