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.
The Basics: What Makes Universal Links Work?
Universal Links work through a combination of three pieces:
- Associated Domains Entitlement in your app (the
.entitlementsfile) - Apple App Site Association (AASA) file on your website
- 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
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 (
applinks,details,appIDs,components) - Optional fields (
substitutionVariables,exclude,caseSensitive,percentEncoded) - Proper nesting and data types
- Support for other AASA features (
webcredentials,appclips,activitycontinuation)
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 LayerHere'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:
- Website:
https://just-eat.co.uk/.well-known/apple-app-site-association - Apple CDN:
https://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:
- Your file is publicly accessible and has a valid SSL certificate
- The file has the correct MIME type (
application/json) - 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 MatchingThe 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.
substitutionVariables ProblemApple 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:
- Parse substitution variables from the AASA file
- Replace variable references (
$(variableName)) with regex alternatives of their possible values - Handle default variables like
$(lang)and$(region)which match any two characters - 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:
- Parse the AASA file and extract components for the target bundle ID
- For each component, build a regex pattern by:
- Replacing substitution variables with alternations
- Converting
*and?to regex equivalents - Handling paths, query parameters, and fragments
- Match incoming URLs against these patterns
- Account for the
excludeflag 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: trueflag 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 ProductionOne 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):
- S3 bucket to host AASA files
- CloudFront distributions for each staging domain with HTTPS
- 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