2551 words
13 minutes
モバイルアプリ開発 ディープリンクについて

概要#

モバイルアプリ開発における「ディープリンク」について、iOS/Android それぞれの仕組み、セキュリティ面の注意点、そして Flutter での実装方法をまとめる。

Web から特定のアプリ画面にユーザーを誘導したい、メールや SNS の URL から直接「商品詳細」「特定のチャット」に飛ばしたい、というのは現代のモバイルアプリでは当たり前の体験となっている。 その裏側で動いているのがディープリンクであり、その実装の正しさはユーザー体験だけでなく、フィッシングなどのセキュリティリスクにも直結する。

💻 環境#

  • Flutter: 3.41.4 (fvm)
  • OS: macOS Tahoe
  • IDE: Cursor
  • iOS: 18 以降想定
  • Android: 12 (API 31) 以降想定

🔗 ディープリンクとは#

ディープリンクとは、Web の URL からアプリ内の特定画面に直接遷移させるリンクの総称。 モバイルにおけるディープリンクは、大きく以下の 3 種類に分類できる。

種類iOSAndroid検証安全性
Web URL ベースUniversal LinksApp Linksサーバ上の検証ファイル必須
カスタム URI スキームmyapp://...myapp://...なし
Deferred Deep Link3rd party SDK 等3rd party SDK 等サービス依存サービス依存

https://example.com/items/123 のような 通常の HTTPS URL が、アプリがインストールされていればアプリで開かれ、未インストールであれば Safari で Web ページとして開かれる仕組み。

  • ドメインの所有者であることを Apple に検証させる
  • 検証ファイル: https://example.com/.well-known/apple-app-site-association (AASA)
  • AASA は Content-Type が application/json、リダイレクトなし、HTTPS 必須

AASA の例:

{
"applinks": {
"apps": [],
"details": [
{
"appID": "TEAMID.com.example.myapp",
"paths": [ "/items/*", "/users/*", "NOT /admin/*" ]
}
]
}
}
  • appID<TeamID>.<BundleID> の形式
  • iOS 13 以降は detailsappIDs(複数) と components を使う新フォーマットも推奨

iOS 側設定:

  • Xcode の Signing & CapabilitiesAssociated Domains を追加
  • applinks:example.com を登録

https://example.com/items/123 のような HTTPS URL をアプリで処理する仕組み。Android 12 以降は デフォルトで未検証のリンクはアプリで開かれなくなった ので、Web 検証が必須に近い扱いとなっている。

  • 検証ファイル: https://example.com/.well-known/assetlinks.json
  • アプリ署名証明書の SHA-256 fingerprint を記載

assetlinks.json の例:

[
{
"relation": ["delegate_permission/common.handle_all_urls"],
"target": {
"namespace": "android_app",
"package_name": "com.example.myapp",
"sha256_cert_fingerprints": [
"AA:BB:CC:..."
]
}
}
]
  • Google Play App Signing を使っている場合は Play Console の「アプリの署名」から アプリ署名鍵の SHA-256 を取得する(アップロード鍵ではない)
  • 検証コマンド:
Terminal window
adb shell pm verify-app-links --re-verify com.example.myapp
adb shell pm get-app-links com.example.myapp

BundleID/カスタム URI スキーム (myapp://...)#

myapp://item/123 のようにアプリ独自のスキームを宣言してアプリを開く方式。 歴史的に最初に使われたディープリンクで、今でも OAuth コールバックなど一部用途では現役。

セキュリティ面の懸念#

  • 誰でも同じスキームを宣言できる
    • 悪意あるアプリが先にインストールされていると、本来のアプリではなくそちらが起動される可能性
    • 認証コードやアクセストークンを URI で渡している場合、トークンの横取りに繋がる
  • ドメインのような所有権検証が存在しない
    • ユーザーから見て「これは正規アプリのリンクである」ことを保証できない
  • OS 側の挙動が不安定
    • iOS では同じスキームが複数ある場合、どのアプリが開くかは未定義

ベストプラクティス#

  • 新規実装ではまず Universal Links / App Links を第一候補にする
  • カスタム URI スキームを使う場合は:
    • スキーム名を アプリ固有かつ衝突しにくい命名 にする (例: com.example.myapp を逆ドメインで)
    • 認証コールバックには PKCE を併用し、トークン横取りを前提に防御
    • URI 経由で渡される値は 必ず検証 する(SQL/HTML エスケープ、整数範囲チェックなど)
    • 機微情報(JWT, refresh token など)を URI に載せない
    • OAuth であれば RFC 8252 (Native Apps) で推奨される Claimed HTTPS スキーム = Universal Links/App Links を優先

通常のディープリンクは「アプリがインストール済み」が前提だが、Deferred Deep Link は 未インストール時のフローも含めて目的画面まで誘導する 仕組み。

典型的なフロー:

  1. ユーザーが広告/SNS のリンクを踏む
  2. アプリ未インストールなので App Store / Play Store に誘導
  3. インストール後、初回起動時に「本来開きたかった画面」へ自動遷移
  4. ついでにインストールアトリビューション(どの広告から来たか)も取得

実装はストア側で URL パラメータを保持できないため、サードパーティ SDK の利用が一般的

  • AppsFlyer
  • Adjust
  • Branch.io
  • Firebase Dynamic Links (※ 2025/8/25 にサービス終了済み)

Firebase Dynamic Links が終了したことで、現状新規構築する場合は AppsFlyer / Adjust / Branch などの有償 SDK か、自前実装(初回起動時にクリップボードや IP+UserAgent でマッチング)するしかない。 自前実装は精度・プライバシー両面で難があるため、本格運用では SDK 採用が現実的。

🦋 Flutter での設定#

Flutter ではプラットフォーム側 (iOS/Android) のネイティブ設定 + Dart 側でリンクを受け取るパッケージを組み合わせる。

代表的なパッケージ:

  • app_links (推奨。uni_links の後継的存在)
  • uni_links (メンテ停止気味)
  • go_router (ルーティング側で deep link を吸収できる)
import 'package:app_links/app_links.dart';
import 'package:flutter/material.dart';
class DeepLinkHandler extends StatefulWidget {
final Widget child;
const DeepLinkHandler({super.key, required this.child});
@override
State<DeepLinkHandler> createState() => _DeepLinkHandlerState();
}
class _DeepLinkHandlerState extends State<DeepLinkHandler> {
final _appLinks = AppLinks();
@override
void initState() {
super.initState();
_initDeepLinks();
}
Future<void> _initDeepLinks() async {
// 起動時に URI で開かれた場合
final initialUri = await _appLinks.getInitialAppLink();
if (initialUri != null) {
_handleUri(initialUri);
}
// 起動中にリンクで叩かれた場合
_appLinks.uriLinkStream.listen(_handleUri);
}
void _handleUri(Uri uri) {
// /items/123 のようなパスを解析してナビゲーション
debugPrint('deep link: $uri');
}
@override
Widget build(BuildContext context) => widget.child;
}

go_router を使う場合は GoRouterredirectroutesuri.path をそのままハンドリングできるので、より簡潔に書ける。

iOS 側設定#

  1. ios/Runner/Runner.entitlements に Associated Domains を追加
<key>com.apple.developer.associated-domains</key>
<array>
<string>applinks:example.com</string>
</array>
  1. Xcode の Signing & Capabilities でも同じ設定を行う
  2. https://example.com/.well-known/apple-app-site-associationHTTPS かつ Content-Type application/json かつリダイレクトなし で配置
  3. カスタムスキームも併用する場合は Info.plist に追加
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLName</key>
<string>com.example.myapp</string>
<key>CFBundleURLSchemes</key>
<array>
<string>myapp</string>
</array>
</dict>
</array>

Android 側設定#

android/app/src/main/AndroidManifest.xml<activity> に intent-filter を追加:

<activity
android:name=".MainActivity"
android:exported="true"
... >
<!-- App Links (HTTPS) -->
<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"
android:host="example.com"
android:pathPrefix="/items" />
</intent-filter>
<!-- カスタムスキーム (任意) -->
<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>
</activity>
  • android:autoVerify="true" を付けることで App Links として OS が assetlinks.json を自動取得・検証する
  • Play Store にアップロード後は アプリ署名鍵 の SHA-256 を assetlinks.json に登録する必要がある(ローカルビルド時のデバッグ署名とは別物)

動作確認コマンド#

Terminal window
# iOS Simulator
xcrun simctl openurl booted "https://example.com/items/123"
xcrun simctl openurl booted "myapp://item/123"
# Android (実機 / エミュレータ)
adb shell am start -a android.intent.action.VIEW -d "https://example.com/items/123" com.example.myapp
adb shell am start -a android.intent.action.VIEW -d "myapp://item/123" com.example.myapp

⚠️ その他知っておきたいこと#

1. アプリ未起動 / 起動済みで挙動が違う#

  • コールドスタート: getInitialAppLink() で取得
  • バックグラウンドから復帰: uriLinkStream (StreamSubscription) で受け取り

両方を実装しないと「起動済みのときだけ動かない」「初回だけ動かない」というバグが起きやすい。

2. ナビゲーションスタックの設計#

ディープリンクで深い階層に直接飛ばす場合、戻るボタンの遷移先がない、というのが頻出問題。

  • 例: 商品詳細にディープリンクで飛んできたユーザーが「戻る」を押したらアプリが落ちる
  • 対策: ホーム→商品一覧→詳細、のようにスタックを擬似的に積む or go_routerinitialLocation 設計をする

3. ログインが必要な画面への遷移#

ディープリンクで飛ばしたい画面が認証必須の場合、

  1. リンク受信 → 認証状態チェック
  2. 未認証ならログイン画面へ、ログイン後に本来の URI へ復元

という「保留 URI」の管理が必要。go_routerredirectstate.matchedLocation が便利。

4. AASA / assetlinks.json のキャッシュ#

  • iOS は AASA を Apple の CDN 経由でキャッシュ してから端末に配布する仕組みのため、自社サーバの AASA を更新しても すぐには端末に反映されない
  • 開発中は アプリの再インストール と、設定 > 開発者 > Associated Domains Development の有効化が役立つ
  • Android は adb shell pm verify-app-links --re-verify で再検証可能

Apple CDN にキャッシュされている AASA を直接確認する#

Apple 側がどの AASA をキャッシュしているかは、以下の URL から 直接取得できる。実機が見る AASA とほぼ同じ内容になっているはずなので、デプロイ後の反映確認はここを見るのが一番早い。

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

例:

Terminal window
# Apple CDN にキャッシュされた example.com の AASA を確認
curl -sS https://app-site-association.cdn-apple.com/a/v1/example.com | jq .
# 自社サーバ上の AASA との差分を見たい場合
diff \
<(curl -sS https://example.com/.well-known/apple-app-site-association | jq -S .) \
<(curl -sS https://app-site-association.cdn-apple.com/a/v1/example.com | jq -S .)

ポイント:

  • <domain-name> には スキームなし のドメインを入れる (例: example.com)
  • 自社の AASA を更新した直後はここがまだ古いことがある。反映には数十分〜数時間かかる ケースもある
  • ここで取得できる JSON が「自社サーバの最新版と一致しているか」が、iOS の Universal Links が動く前提条件
  • 404 や古い内容が返る場合、AASA の Content-Type / リダイレクト / HTTPS 証明書まわりで弾かれている可能性が高いので、自社サーバ側の応答(ヘッダ含む)を再確認する

5. 検証ツール#

6. プライバシーへの配慮#

クリップボード経由で deferred deep link を実装すると、iOS 14 以降は クリップボードアクセス通知 がユーザーに表示されたり、App Tracking Transparency (ATT) の同意が必要になるケースがある。3rd party SDK を使う場合も、SDK が裏でやっている挙動を把握しておく。

7. ブラウザ → アプリ起動の “クッション”#

SNS の WebView (Instagram, LINE 内ブラウザ等) からは Universal Links が WebView 内で開いてしまい、アプリに飛ばない ことがある。 これに対しては中継ページで「アプリで開く」ボタンを置く、intent:// URI (Android Chrome) を使う、などのフォールバックを検討する。

あとがき#

ディープリンクは「ただ URL を貼るだけ」に見えて、

  • ドメイン検証 (AASA/assetlinks.json)
  • 署名証明書の SHA-256
  • アプリ側の intent-filter / Associated Domains
  • 起動状態によるハンドリング差
  • ナビゲーションスタック設計

と複数レイヤが絡む地味に難しい領域。 特にカスタム URI スキームは「動くから OK」で済ませがちだが、OAuth 等で使うとセキュリティリスクに直結するので、まずは Universal Links / App Links を第一に検討するのが鉄則。

Firebase Dynamic Links 終了で Deferred Deep Link は選択肢が減ったので、必要なら早めに代替 SDK の評価を始めるのがよさそう。

参考#

モバイルアプリ開発 ディープリンクについて
https://tutttuwi.github.io/posts/2026-05-11_flutterアプリ開発-ディープリンクについて/
Author
Tomoaki Tsutsui
Published at
2026-05-11
License
CC BY-NC-SA 4.0