Files
2nd/10_Wiki/Topics/Coding/Mobile_Deep_Link_Verification.md
T
2026-05-09 21:08:02 +09:00

8.4 KiB

id, title, category, status, source_trust_level, verification_status, created_at, updated_at, tags, tech_stack, applied_in, aliases
id title category status source_trust_level verification_status created_at updated_at tags tech_stack applied_in aliases
mobile-deep-link-verification Deep Link Verification — Universal / App Links / 검증 Coding draft B conceptual 2026-05-09 2026-05-09
mobile
deep-link
universal-link
app-link
vibe-coding
language applicable_to
Swift / Kotlin
iOS
Android
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

// 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
// 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

// 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 둘 다 포함 가능.

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

Android — Manifest

<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

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

# 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 무효화

# 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
사용자가 ad → install → first launch.
원래 URL 의 의도 (특정 product) 복원.

iOS / Android 자체 X — 외부 SDK:
- Branch.io
- Adjust
- AppsFlyer
- Singular
// 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)

<!-- iOS — Info.plist -->
<key>CFBundleURLTypes</key>
<array>
  <dict>
    <key>CFBundleURLSchemes</key>
    <array><string>myapp</string></array>
  </dict>
</array>
<!-- 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 검증

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(...)
}
// Push payload
{
  aps: { alert: { ... } },
  link: 'https://example.com/order/42'
}
// Tap notification → openURL
let link = userInfo["link"] as? String
if let url = URL(string: link) { handle(url) }

Web fallback

<!-- 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

// 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.

🔗 관련 문서