---
id: mobile-deep-link-verification
title: Deep Link Verification — Universal / App Links / 검증
category: Coding
status: draft
source_trust_level: B
verification_status: conceptual
created_at: 2026-05-09
updated_at: 2026-05-09
tags: [mobile, deep-link, universal-link, app-link, vibe-coding]
tech_stack: { language: "Swift / Kotlin", applicable_to: ["iOS", "Android"] }
applied_in: []
aliases: [App Links, Universal Links, AASA, assetlinks, deep link verification, deferred deep link]
---

# Deep Link Verification

> Web URL → app. **iOS = Universal Links + AASA, Android = App Links + assetlinks.json**. 검증 안 되면 browser 가 처리. Deferred = install 후 의도 복원.

## 📖 핵심 개념
- Verification: 서버 가 owns this domain.
- AASA (iOS): apple-app-site-association.
- assetlinks (Android): .well-known/assetlinks.json.
- Deferred: install 후 의도 복원 (Branch / Adjust).

## 💻 코드 패턴

### iOS — AASA
```json
// https://example.com/.well-known/apple-app-site-association
// (HTTP/2 + HTTPS + 정확 Content-Type: application/json)
{
  "applinks": {
    "details": [
      {
        "appIDs": ["TEAMID.com.example.app"],
        "components": [
          { "/": "/order/*" },
          { "/": "/user/*", "?": { "ref": "?*" } },
          { "/": "/admin/*", "exclude": true }
        ]
      }
    ]
  },
  "webcredentials": {
    "apps": ["TEAMID.com.example.app"]
  }
}
```

### iOS — Associated Domains
```
Xcode → Signing & Capabilities → Associated Domains
+ applinks:example.com
+ webcredentials:example.com  # password autofill
```

```swift
// SwiftUI handle
.onContinueUserActivity(NSUserActivityTypeBrowsingWeb) { activity in
    guard let url = activity.webpageURL else { return }
    handle(url)
}
.onOpenURL { url in
    handle(url)
}

func handle(_ url: URL) {
    let comps = URLComponents(url: url, resolvingAgainstBaseURL: false)
    guard let path = comps?.path else { return }
    
    // ⚠️ Path / params 검증
    if path.hasPrefix("/order/") {
        let id = String(path.dropFirst("/order/".count))
        guard isValidUuid(id) else { return }
        navigationStore.navigate(.order(id))
    }
}
```

### Android — assetlinks.json
```json
// https://example.com/.well-known/assetlinks.json
[
  {
    "relation": ["delegate_permission/common.handle_all_urls"],
    "target": {
      "namespace": "android_app",
      "package_name": "com.example.app",
      "sha256_cert_fingerprints": ["AA:BB:CC:..."]
    }
  }
]
```

→ SHA256 = signing cert. release / debug 둘 다 포함 가능.

```bash
# Cert fingerprint 추출
keytool -list -v -keystore my-release-key.keystore
# 또는 Play Console — App signing
```

### Android — Manifest
```xml
<activity android:name=".MainActivity" android:exported="true" android:launchMode="singleTask">
    <intent-filter android:autoVerify="true">
        <action android:name="android.intent.action.VIEW" />
        <category android:name="android.intent.category.DEFAULT" />
        <category android:name="android.intent.category.BROWSABLE" />
        <data android:scheme="https" />
        <data android:host="example.com" />
        <data android:pathPattern="/order/.*" />
    </intent-filter>
</activity>
```

→ `android:autoVerify="true"` = OS 가 install 시 verify.

### Android — handle
```kotlin
class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        intent?.data?.let(::handleDeepLink)
    }
    
    override fun onNewIntent(intent: Intent) {
        super.onNewIntent(intent)
        intent.data?.let(::handleDeepLink)
    }
    
    private fun handleDeepLink(uri: Uri) {
        val path = uri.path ?: return
        if (path.startsWith("/order/")) {
            val id = path.removePrefix("/order/")
            navigate(Screen.Order(id))
        }
    }
}
```

### Verification check
```bash
# iOS
swcutil verify --domain example.com --bundle-identifier com.example.app

# Android
adb shell pm get-app-links com.example.app
# 또는 Play Console → App Links

# Apple validator
https://branch.io/resources/aasa-validator/
```

### 검증 실패 원인
```
iOS:
- AASA 가 redirect / 401
- Content-Type 가 application/json X
- 잘못된 appID (TeamID 정확)
- 옛 cache (다음 install 까지 wait)

Android:
- assetlinks.json HTTP / 잘못
- SHA256 fingerprint 불일치 (debug vs release)
- pathPattern 불일치
- autoVerify 안 한 channel
```

### Cache 무효화
```bash
# iOS
xcrun simctl openurl booted https://example.com/test

# Android — clear app links
adb shell pm clear-app-links com.example.app
adb shell pm verify-app-links --re-verify com.example.app
```

### Deferred deep link (install 후 의도 복원)
```
사용자가 ad → install → first launch.
원래 URL 의 의도 (특정 product) 복원.

iOS / Android 자체 X — 외부 SDK:
- Branch.io
- Adjust
- AppsFlyer
- Singular
```

```ts
// Branch
import branch from 'react-native-branch';

const { params } = await branch.getLatestReferringParams();
if (params.product_id) navigation.navigate('Product', { id: params.product_id });
```

### Custom URL scheme (legacy)
```xml
<!-- iOS — Info.plist -->
<key>CFBundleURLTypes</key>
<array>
  <dict>
    <key>CFBundleURLSchemes</key>
    <array><string>myapp</string></array>
  </dict>
</array>
```

```kotlin
<!-- Android -->
<intent-filter>
    <action android:name="android.intent.action.VIEW" />
    <category android:name="android.intent.category.DEFAULT" />
    <category android:name="android.intent.category.BROWSABLE" />
    <data android:scheme="myapp" />
</intent-filter>
```

→ `myapp://order/123` — but Universal/App Links 권장. Custom scheme 위조 가능.

### 보안 — Path / params 검증
```swift
func handle(_ url: URL) {
    // 1. Whitelist host
    guard url.host == "example.com" else { return }
    
    // 2. Path validation
    guard let path = url.path.split(...) else { return }
    
    // 3. Param sanitization
    guard let id = url.queryItems["id"], isValidUuid(id) else { return }
    
    // 4. Auth — link 가 login 우회 X
    if requiresLogin(path) && !isLoggedIn() {
        navigateAfterLogin(.path(path))
        return
    }
    
    navigate(...)
}
```

### Notification + deep link
```ts
// Push payload
{
  aps: { alert: { ... } },
  link: 'https://example.com/order/42'
}
```

```swift
// Tap notification → openURL
let link = userInfo["link"] as? String
if let url = URL(string: link) { handle(url) }
```

### Web fallback
```html
<!-- App 미설치 시 web 표시 -->
<!-- example.com/order/42 -->
<!DOCTYPE html>
<html>
<head>
  <title>Order 42</title>
  <meta name="apple-itunes-app" content="app-id=123456789">
  <meta property="al:android:url" content="myapp://order/42">
  <meta property="al:android:package" content="com.example.app">
</head>
<body>
  <p>Open in app</p>
  <a href="myapp://order/42">Open in app</a>
  <a href="https://apps.apple.com/app/...">Install on iOS</a>
  <a href="https://play.google.com/store/apps/...">Install on Android</a>
</body>
</html>
```

### App Clip (iOS) / Instant App (Android)
```
사용자가 install 안 해도 app 의 작은 부분 실행.
QR / NFC / link 로.
```

→ [[iOS_App_Clips]].

### Test
```ts
// Maestro
- launchApp:
    arguments:
      url: "https://example.com/order/42"

# 또는 adb
adb shell am start -W -a android.intent.action.VIEW -d "https://example.com/order/42" com.example.app

# iOS
xcrun simctl openurl booted "https://example.com/order/42"
```

## 🤔 의사결정 기준
| 상황 | 추천 |
|---|---|
| 일반 deep link | Universal Links (iOS) + App Links (Android) |
| Marketing campaign | + Deferred (Branch / Adjust) |
| Private (auth callback) | Custom scheme (잠시 OK) |
| App Clip / Instant App | 가벼운 entry point |
| Notification 안 link | Universal Link 또는 custom scheme |

## ❌ 안티패턴
- **AASA / assetlinks 가 redirect**: verification 깨짐.
- **SHA256 fingerprint 불일치 (release / debug 다름)**: 옛 release 가 안 됨.
- **Path / params 검증 X**: SQL / XSS injection.
- **Deep link 가 auth 우회**: 인증 검사 매번.
- **Custom scheme 만 의존**: 위조 가능.
- **Wildcard path (`*`)**: 의도 외 link.
- **Web fallback 없음**: 미설치 시 dead link.

## 🤖 LLM 활용 힌트
- AASA + assetlinks + Associated Domains + autoVerify 4종.
- Path / params validate (zod-style).
- Auth check 매번.
- Deferred = Branch / Adjust.

## 🔗 관련 문서
- [[iOS_Universal_Links_Deep_Linking]]
- [[Android_Bluetooth_LE_Scanning]]
- [[Mobile_Push_Deep]]
