AASA Dynamic/Static Serving and Path Control#
Covers dynamically generating AASA (Apple App Site Association) on the server and excluding specific paths from Universal Links.
Triggers#
- Excluding specific paths from AASA (OAuth callbacks, etc.)
- Dynamic AASA serving setup
- Multi-domain/app AASA management
- Universal Links debugging
Path Exclusion Pattern#
Problem Scenario#
When OAuth login (Naver, Kakao, etc.) server callback URLs (/auth/naver/callback) match the AASA
"paths": ["*"] setting as iOS Universal Links, the app opens before the server completes token exchange.
1. User â Naver login page
2. Naver â Server /auth/naver/callback (token exchange needed)
3. â iOS redirects /auth/naver/callback to app
4. Server token exchange incomplete â Login failure
Solution: NOT Prefix#
{
" applinks " : {
" details " : [
{
" appID " : " TEAM_ID.BUNDLE_ID " ,
" paths " : [
" NOT /auth/* " ,
" NOT /internal/* " ,
" NOT /api/* " ,
" * "
]
}
]
}
}
Paths to Exclude#
| Path | Reason |
|---|---|
/auth/* | OAuth callbacks (Naver, Kakao, Google, etc.) |
/internal/* |
Server-to-server communication (Lambda callbacks, webhooks, etc.) |
/api/* | REST API endpoints |
/webhooks/* | External service webhooks (payment, notifications, etc.) |
/health | Health check endpoint |
Evaluation Order#
Apple AASA paths evaluation rules:
- NOT rules are evaluated first â If matched, excluded from Universal Links
- Include rules evaluated â
*,/books/*, etc. - First matching rule applies
Dynamic AASA Serving (Serverpod)#
WellKnownRoute Pattern#
class WellKnownRoute extends Route {
Map<String, dynamic> _appleAppSiteAssociation() {
return {
'applinks': {
'apps': <String>[],
'details': [
{
'appID': '\${EnvConfig.appleTeamId}.\${EnvConfig.packageName}',
'paths': [
'NOT /auth/*',
'NOT /internal/*',
'NOT /api/*',
'*',
],
},
],
},
'webcredentials': {
'apps': [
'\${EnvConfig.appleTeamId}.\${EnvConfig.packageName}',
],
},
};
}
@override
FutureOr<Result> handleCall(Session session, Request request) {
if (request.url.path == '/.well-known/apple-app-site-association') {
final body = jsonEncode(_appleAppSiteAssociation());
return Response.ok(
body: Body.fromString(body, mimeType: .json),
headers: Headers.build((headers) {
headers[HttpHeaders.cacheControlHeader] = ['max-age=31536000'];
}),
);
}
return Response.notFound();
}
}
Dynamic vs Static AASA#
| Method | Advantages | Disadvantages |
|---|---|---|
| Dynamic (server code) | Auto-applies per-environment Bundle ID, code-managed | Server dependency |
| Static (file) | Served even when server is down | Manual per-environment management |
Recommended: Use dynamic AASA as default, maintain static file as fallback.
Static AASA Fallback#
web/.well-known/apple-app-site-association
Include Bundle IDs for all environments (dev/stg/prod):
{
" applinks " : {
" details " : [
{
" appIDs " : [
" TEAM_ID.com.example.app.dev " ,
" TEAM_ID.com.example.app.stg " ,
" TEAM_ID.com.example.app "
],
" paths " : [
" NOT /auth/* " ,
" NOT /internal/* " ,
" NOT /api/* " ,
" * "
]
}
]
}
}
Difference from Android assetlinks.json#
| Item | iOS AASA | Android assetlinks.json |
|---|---|---|
| Path control location | Server (AASA paths) | App (AndroidManifest intent-filter) |
| Exclusion method | NOT /path/* |
Only specify included paths in intent-filter |
| Config change | Server deploy required | App update required |
â No assetlinks.json modification needed for Android (handles domain-level verification only)
Verification#
# Check dynamic AASA
curl -s https://yourdomain.com/.well-known/apple-app-site-association | jq .
# Verify NOT rule behavior: /auth/ path should NOT open app
# Access https://yourdomain.com/auth/naver/callback in iOS Safari
# â Should be handled on web, not open the app
# Check Apple CDN cache (up to 24-hour delay)
curl -s https://app-site-association.cdn-apple.com/a/v1/yourdomain.com | jq .
Firebase Hosting AASA Interception#
Problem: Firebase Hosting Auto-Generates AASA#
When using Firebase Hosting as a reverse proxy/CDN, .well-known/apple-app-site-association
requests do not reach the backend server.
iOS device â DNS â Firebase Hosting CDN â Backend server (Serverpod, etc.)
â
AASA intercepted here
Firebase auto-generates AASA when an iOS app is registered in the project. This auto-generated AASA only excludes Firebase internal paths (/__/auth/*,
/_/*) with NOT prefix, and does not include custom NOT rules (e.g., NOT /auth/*).
Result#
- Backend server's
WellKnownRouteis correctly configured but Firebase responds first - OAuth callbacks (
/auth/naver/callback, etc.) match as Universal Links and open the app - Server token exchange incomplete â Login failure
Solution: Deploy Custom Static AASA File#
Deploying a static AASA file in Firebase Hosting's public directory serves with higher priority
than Firebase's auto-generated AASA.
Priority:
1st: public directory static files â Custom AASA
2nd: Firebase auto-generated AASA
3rd: rewrites rules (backend proxy)
Step 1: Create Static AASA File
web/.well-known/apple-app-site-association
{
" applinks " : {
" apps " : [],
" details " : [
{
" appID " : " TEAM_ID.BUNDLE_ID " ,
" paths " : [
" NOT /auth/* " ,
" NOT /internal/* " ,
" NOT /api/* " ,
" NOT /__/auth/action/ " ,
" NOT /__/auth/handler/ " ,
" NOT /_/* " ,
" /* "
]
}
]
}
}
Important: Firebase default NOT rules (
/__/auth/*,/_/*) must also be included.
Step 2: firebase.json Configuration
{
" hosting " : {
" public " : " build/web " ,
" ignore " : [ " firebase.json " , " **/node_modules/** " ],
" headers " : [
{
" source " : " /.well-known/apple-app-site-association " ,
" headers " : [
{ " key " : " Content-Type " , " value " : " application/json " }
]
}
]
}
}
Cautions:
-
Must remove
"**/.*"fromignorearray (so.well-knowndirectory is not ignored) - Explicitly specify Content-Type header (Firebase may not auto-detect)
Step 3: Copy .well-known in Build Script
.well-known directory is not included in build output after flutter build web, so manual copy is needed.
# deploy_web.sh or CI script
flutter build web --release
# Copy .well-known directory
if [ -d " web/.well-known " ]; then
cp -r " web/.well-known " " build/web/.well-known "
fi
# Firebase deploy
firebase deploy --only hosting
Step 4: Post-Deploy Verification
# Verify Firebase Hosting serves custom AASA
curl -s https://yourdomain.com/.well-known/apple-app-site-association | jq .
# Verify NOT /auth/* rule is included
curl -s https://yourdomain.com/.well-known/apple-app-site-association | jq ' .applinks.details[0].paths '
Dynamic and Static AASA Synchronization#
In Firebase Hosting environments, both AASA locations must be synchronized:
| File | Serving Domain | Purpose |
|---|---|---|
web/.well-known/apple-app-site-association |
Firebase Hosting domain (e.g., www.example.com) |
Replaces Firebase-intercepted AASA |
well_known_route.dart (dynamic) |
Backend direct access domain (e.g., api.example.com) |
Served directly by backend server |
The paths settings in both files must be identical.
Troubleshooting#
When Universal Links Are Not Working#
- Verify AASA Content-Type is
application/json - Verify direct HTTPS serving without redirects
- Wait for Apple CDN cache (24 hours)
- iOS Settings â Developer â Associated Domains diagnostics
When OAuth Callbacks Open the App#
- Add
NOT /auth/*to AASA paths - When using Firebase Hosting: Deploy custom static AASA file (server deploy alone is insufficient)
- Redeploy server + Firebase Hosting
- Wait for Apple CDN cache refresh (or reinstall app)
- Access callback URL directly in Safari to verify web handling
When AASA Doesn't Change After Firebase Hosting Deploy#
- Verify
"**/.*"removed fromfirebase.jsonignore - Verify
web/.well-knownâbuild/web/.well-knowncopy in build script - Wait for Firebase CDN cache (15 minutes)
- Verify with
curl -H "Cache-Control: no-cache"to bypass cache
Checklist#
- NOT prefix applied to server-only paths
- Both dynamic and static AASA fallback have identical paths settings
- New OAuth providers covered by
/auth/*pattern - Firebase Hosting: Custom static AASA file deployed
-
Firebase Hosting:
**/.*ignore removed fromfirebase.json -
Firebase Hosting:
.well-knowncopy added to build script - Post-deploy AASA response verified with
curl - Tested on iOS device after Apple CDN cache refresh