Deep Linking in Expo React Native with Branch.io — Without the SDK

What if your managed Expo app could handle universal links on both iOS and Android — using Branch.io but without installing their heavy SDK?

Deep linking is one of those features that sounds simple in theory — a user taps a link, and the app opens to the right screen. In practice, especially in a managed Expo workflow where the android/ and ios/ directories don’t exist in your repository, it gets complicated fast. You’re dealing with Apple Universal Links, Android App Links, link resolution, platform-specific configuration, and the challenge of making it all work at build time via app.config.ts.

In this guide, we walk through the complete deep linking implementation for a healthcare mobile app called HealthSync — using Branch.io’s REST API directly (no SDK), expo-linking for URL handling, and custom app.config.ts configuration for both platforms. The primary use case is a password reset flow where a user taps a link in an email and lands directly on the “Set New Password” screen in the app.


Table of Contents

  1. Why Branch.io — And Why Not Their SDK?
  2. Project Setup & Dependencies
  3. Branch.io Dashboard Setup
  4. Apple Developer Console — Associated Domains
  5. Configuring app.config.ts for Deep Linking
  6. The Deep Link Hook — Full Implementation
  7. Navigation — Routing Deep Links to Screens
  8. Backend — Generating Branch.io Deep Links
  9. Backend — Token Verification & Password Reset
  10. The Email Template — Dual Links
  11. The Complete Flow — End to End
  12. Key Takeaways

1. Why Branch.io — And Why Not Their SDK?

Why Branch.io at all?

Deep linking on mobile is fragmented. On iOS, you need Universal Links (HTTPS links that open your app instead of Safari). On Android, you need App Links (verified HTTPS intents). Both require hosting configuration files on your domain (.well-known/apple-app-site-association for iOS, .well-known/assetlinks.json for Android).

Branch.io handles all of this for you:

What Branch.io provides Without Branch.io
Hosted apple-app-site-association file You must host it on your domain
Hosted assetlinks.json file You must host it on your domain
Short link generation via API You build your own link service
Link metadata (custom payloads) You encode everything in URL params
Deferred deep linking (link works even if app isn’t installed) Not possible without a redirect service
Analytics and attribution Manual tracking

Why NOT use the Branch SDK?

The official react-native-branch SDK:

  • Adds significant binary size (~2-3 MB)
  • Requires native module linking (complex in managed Expo)
  • Has historically had compatibility issues with newer Expo SDK versions
  • Needs a custom Expo plugin to configure (more moving parts)
  • Does much more than we need (attribution, analytics, referrals)

Our use case is simple: resolve a Branch.io link to extract a token, then navigate to a screen. Branch.io exposes a REST API for exactly this:

GET https://api2.branch.io/v1/url?url={branch_link}&branch_key={key}

This returns the metadata we embedded when creating the link. No SDK needed — just a fetch() call.


3. Project Setup & Dependencies

The beauty of this approach is the minimal dependency footprint.

Install Dependencies

npx expo install expo-linking expo-constants

That’s it. No react-native-branch. No custom native modules.

Key Versions

{
  "expo-linking": "^8.0.11",
  "expo-constants": "~18.0.13"
}

Required Environment Variables

# Branch.io
BRANCH_LIVE_KEY_PATIENT=key_live_xxxxxxxxxxxx
BRANCH_DEFAULT_DOMAIN_PATIENT=healthsync.app.link
BRANCH_ALTERNATE_DOMAIN_PATIENT=healthsync-alternate.app.link

# Backend also needs:
BRANCH_DEEP_LINK_URL=https://api2.branch.io/v1/url
BRANCH_SECRET_KEY=key_live_xxxxxxxxxxxx

4. Branch.io Dashboard Setup

Step 1: Create a Branch.io Account and App

  1. Go to Branch.io Dashboard
  2. Sign up or log in
  3. Create a new app (e.g., HealthSync Patient)
  4. Note your Branch Key (starts with key_live_ for production, key_test_ for testing)

Step 2: Configure Link Domain

  1. Navigate to ConfigurationLink Settings
  2. Under Custom Link Domain, note your default domain: healthsync.app.link
  3. Optionally configure an alternate domain: healthsync-alternate.app.link
  4. These domains are what you’ll use in app.config.ts for Universal Links and App Links

Step 3: Configure iOS

  1. In the Branch dashboard, go to ConfigurationLink SettingsiOS
  2. Enter your Bundle Identifier (e.g., com.healthsync.patient)
  3. Enter your Apple App Prefix (Team ID — found in Apple Developer Portal under Membership)
  4. Enable Universal Links
  5. Branch will automatically host the apple-app-site-association file at https://healthsync.app.link/.well-known/apple-app-site-association

Step 4: Configure Android

  1. In the Branch dashboard, go to ConfigurationLink SettingsAndroid
  2. Enter your Package Name (e.g., com.healthsync.patient)
  3. Enter your SHA-256 Certificate Fingerprint (use keytool -list -v -keystore your-keystore.jks)
  4. Enable App Links
  5. Branch will automatically host the assetlinks.json file at https://healthsync.app.link/.well-known/assetlinks.json

Step 5: Configure Redirects

  1. Under Redirects, set:
    • Default URL: Your web app (e.g., https://app.healthsync.com)
    • iOS App Store URL: Your App Store listing
    • Android Play Store URL: Your Play Store listing
  2. These are where users land if the app isn’t installed

Important: The SHA-256 fingerprint must match your production signing key. For Expo EAS builds, you can find this in the EAS dashboard under your project’s credentials. If it doesn’t match, Android App Links verification will silently fail and links will open in the browser instead of the app.


5. Apple Developer Console — Associated Domains

For Universal Links to work on iOS, your app needs the Associated Domains capability.

Step 1: Enable Associated Domains Capability

  1. Go to Apple Developer Portal
  2. Navigate to Certificates, Identifiers & ProfilesIdentifiers
  3. Select your App ID (or create one matching your bundle identifier)
  4. Under Capabilities, enable “Associated Domains”
  5. Save

Step 2: Regenerate Provisioning Profile

  1. If you’re using a provisioning profile, regenerate it after enabling the capability
  2. For Expo EAS builds, this is handled automatically when you configure associatedDomains in app.config.ts

Note: You do NOT need to manually create an apple-app-site-association file. Branch.io hosts it on their domain (healthsync.app.link). Your app just needs to declare that it trusts that domain via associatedDomains in the config.


6. Configuring app.config.ts for Deep Linking

Since we’re in a managed Expo workflow, all native configuration happens in app.config.ts and is applied at build time.

// app.config.ts

import dotenv from "dotenv";
dotenv.config();

export default {
  name: "HealthSync",
  slug: "healthsync-patient",

  // Custom URL scheme — enables direct deep links like:
  // com.healthsync.patient://reset-password?token=xyz
  scheme: "com.healthsync.patient",

  ios: {
    bundleIdentifier: "com.healthsync.patient",
    // Associated Domains — tells iOS to check these domains
    // for an apple-app-site-association file
    associatedDomains: [
      `applinks:${process.env.BRANCH_DEFAULT_DOMAIN_PATIENT}`,
      `applinks:${process.env.BRANCH_ALTERNATE_DOMAIN_PATIENT}`,
    ],
  },

  android: {
    package: "com.healthsync.patient",
    // Intent Filters — tells Android which URLs this app handles
    intentFilters: [
      {
        action: "VIEW",
        autoVerify: true, // Triggers Android App Links verification
        data: [
          {
            scheme: "https",
            host: process.env.BRANCH_DEFAULT_DOMAIN_PATIENT,
          },
          {
            scheme: "https",
            host: process.env.BRANCH_ALTERNATE_DOMAIN_PATIENT,
          },
        ],
        category: ["BROWSABLE", "DEFAULT"],
      },
    ],
  },

  // ... rest of config
};

What each piece does:

scheme — Registers a custom URL scheme. This allows direct deep links like com.healthsync.patient://reset-password?token=abc123 that bypass Branch.io entirely. Useful for testing and as a fallback.

associatedDomains (iOS) — At build time, Expo writes these into the app’s entitlements file. When the app is installed, iOS checks each domain for an apple-app-site-association file. If the file lists this bundle ID, iOS will route matching HTTPS links to the app instead of Safari.

intentFilters (Android) — At build time, Expo writes these into AndroidManifest.xml. The autoVerify: true flag triggers App Links verification — Android checks the domain for an assetlinks.json file. If verified, matching HTTPS links open the app directly without a disambiguation dialog.

Two domains — We configure both the default and alternate Branch domains. This provides redundancy and supports Branch.io’s link routing infrastructure.

No Custom Expo Plugins Needed

Unlike push notifications (which required five custom config plugins), deep linking with this approach needs zero custom plugins. Everything is handled by Expo’s built-in associatedDomains and intentFilters config options.


7. The Deep Link Hook — Full Implementation

This is the core of our deep linking logic — a React hook that listens for incoming URLs and resolves them:

// hooks/useBranchDeepLink.ts

import { useEffect, useState } from "react";
import * as Linking from "expo-linking";
import Constants from "expo-constants";

interface BranchParams {
  token?: string;
  type?: string;
}

// Branch key from app.config.ts → extra → BRANCH_LIVE_KEY_PATIENT
const BRANCH_KEY = Constants.expoConfig?.extra?.BRANCH_LIVE_KEY_PATIENT;

export function useBranchDeepLink() {
  const [params, setParams] = useState<BranchParams | null>(null);

  useEffect(() => {
    // Handle the URL that launched the app (cold start)
    const handleInitialURL = async () => {
      const url = await Linking.getInitialURL();
      if (url) {
        await resolveDeepLink(url);
      }
    };

    // Handle URLs received while the app is already running (warm start)
    const subscription = Linking.addEventListener("url", ({ url }) => {
      resolveDeepLink(url);
    });

    handleInitialURL();

    return () => {
      subscription.remove();
    };
  }, []);

  const resolveDeepLink = async (url: string) => {
    // Ignore Expo development client URLs
    if (url.includes("expo-development-client")) {
      return;
    }

    if (url.includes("app.link")) {
      // ── Branch.io Universal Link ──
      // The OS opened the app with a Branch URL like:
      // https://healthsync.app.link/reset-abc123
      //
      // We need to resolve this to get the embedded metadata.
      try {
        const response = await fetch(
          `https://api2.branch.io/v1/url?url=${encodeURIComponent(url)}&branch_key=${BRANCH_KEY}`,
        );

        if (!response.ok) {
          return;
        }

        const text = await response.text();
        const data = JSON.parse(text);

        // Extract the custom_object we embedded when creating the link
        const customObject = data?.data?.custom_object;
        if (customObject?.token && customObject?.type === "forgot-password") {
          setParams({
            token: customObject.token,
            type: customObject.type,
          });
        }
      } catch (error) {
        console.error("Error resolving Branch link:", error);
      }
    } else {
      // ── Direct Custom Scheme URL ──
      // e.g., com.healthsync.patient://reset-password?token=xyz
      const parsed = Linking.parse(url);
      const queryParams = parsed.queryParams;

      if (parsed.hostname === "reset-password" && queryParams?.token) {
        setParams({
          token: queryParams.token as string,
          type: "forgot-password",
        });
      }
    }
  };

  return params;
}

How It Works — Step by Step

1. Two entry points for URLs:

  • Linking.getInitialURL() — Called once on mount. Returns the URL that launched the app from a killed state (user tapped a link when the app wasn’t running).
  • Linking.addEventListener("url", ...) — Fires when a URL arrives while the app is already open (user tapped a link and the app was in the background).

2. Branch.io link resolution:
When the URL contains app.link, we know it’s a Branch universal link. Instead of importing the Branch SDK, we make a simple GET request to their REST API:

GET https://api2.branch.io/v1/url
  ?url=https://healthsync.app.link/reset-abc123
  &branch_key=key_live_xxxxxxxxxxxx

Response:

{
  "data": {
    "custom_object": {
      "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
      "type": "forgot-password"
    },
    "$canonical_url": "...",
    "~creation_source": "API"
  }
}

3. Direct scheme fallback:
If the URL uses the custom scheme (com.healthsync.patient://reset-password?token=xyz), we parse it directly with Linking.parse(). No API call needed — the token is right there in the query params.

4. Development URL filtering:
During development with Expo Go, the dev client URL (expo-development-client://...) is detected on app launch. We filter these out to avoid false positive deep link handling.


8. Navigation — Routing Deep Links to Screens

The useBranchDeepLink hook returns the resolved params. The AppNavigator watches for changes and navigates accordingly:

// navigation/AppNavigator.tsx

import { useBranchDeepLink } from "../hooks/useBranchDeepLink";

export default function AppNavigator() {
  const [isNavigationReady, setIsNavigationReady] = useState(false);
  const navigationRef = useRef<NavigationContainerRef<any>>(null);
  const branchParams = useBranchDeepLink();

  // Navigate when deep link params arrive AND navigation is ready
  useEffect(() => {
    if (
      branchParams?.token &&
      branchParams?.type === "forgot-password" &&
      isNavigationReady &&
      navigationRef.current
    ) {
      navigationRef.current.navigate("SetNewPassword", {
        token: branchParams.token,
      });
    }
  }, [branchParams, isNavigationReady]);

  return (
    <NavigationContainer
      ref={navigationRef}
      onReady={() => setIsNavigationReady(true)}
    >
      <Stack.Navigator>
        {/* Auth screens */}
        <Stack.Screen name="Login" component={LoginScreen} />
        <Stack.Screen name="SetNewPassword" component={SetNewPasswordScreen} />
        {/* ... other screens */}
      </Stack.Navigator>
    </NavigationContainer>
  );
}

Why wait for isNavigationReady?

The NavigationContainer takes time to mount and become ready. If we try to call navigate() before it’s ready, the call silently fails. By tracking isNavigationReady via the onReady callback, we ensure navigation only fires when the container can handle it.

Why the useEffect dependency array matters

The effect depends on both branchParams and isNavigationReady. This handles two timing scenarios:

  1. Link arrives before navigation is ready (cold start) — branchParams sets first, then isNavigationReady triggers the effect
  2. Link arrives after navigation is ready (warm start) — isNavigationReady is already true, branchParams change triggers the effect

The SetNewPassword Screen

When the user lands on this screen via deep link, the token is validated with the backend before showing the form:

// features/auth/screens/SetNewPasswordScreen.tsx

export default function SetNewPasswordScreen() {
  const route = useRoute();
  const { setNewPassword, validateToken } = useAuthApi();
  const token = (route.params as { token?: string })?.token || "";

  const [validating, setValidating] = useState(true);

  useEffect(() => {
    const validate = async () => {
      if (!token) {
        setValidating(false);
        return;
      }
      try {
        await validateToken(token);
        setValidating(false);
      } catch (err) {
        // Show error — token expired or invalid
        setValidating(false);
      }
    };
    validate();
  }, [token]);

  if (validating) {
    return <ActivityIndicator size="large" />;
  }

  // ... render password form
}

This validation step is critical — it catches expired tokens (10-minute TTL) and already-used tokens (one-time links) before the user fills out the form.


9. Backend — Generating Branch.io Deep Links

When a user requests a password reset from the mobile app, the backend generates a Branch.io deep link containing an embedded JWT token.

JWT Token Generation

// handlers/forgot_password_send_email.js

const jwt = require("jsonwebtoken");

async function generateSessionToken(user_name, user_type) {
  const jwtSecret = process.env.JWT_SECRET_KEY;
  const date = new Date(Date.now() + 10 * 60000); // 10 minutes expiry

  const token = jwt.sign(
    {
      user_name,
      user_type,
      exp: Math.floor(date.getTime() / 1000),
    },
    jwtSecret,
    { algorithm: "HS256" }
  );

  return token;
}

Branch.io Deep Link Creation

The backend calls Branch.io’s link creation API to generate a short URL with embedded metadata:

const https = require("https");

const branchSecretKey = process.env.BRANCH_LIVE_KEY_PATIENT;
const branchDeepLinkUrl = process.env.BRANCH_DEEP_LINK_URL;
// BRANCH_DEEP_LINK_URL = https://api2.branch.io/v1/url

async function generateDeepLink(sessionToken) {
  const branchUrl = new URL(branchDeepLinkUrl);

  const requestBody = JSON.stringify({
    branch_key: branchSecretKey,
    duration: 600, // Link valid for 10 minutes (matches JWT expiry)
    data: {
      custom_object: {
        token: sessionToken,
        type: "forgot-password",
      },
    },
  });

  const options = {
    hostname: branchUrl.hostname,
    path: branchUrl.pathname,
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      "Content-Length": requestBody.length,
    },
  };

  return new Promise((resolve, reject) => {
    const req = https.request(options, (res) => {
      let responseData = "";

      res.on("data", (chunk) => {
        responseData += chunk;
      });

      res.on("end", () => {
        if (res.statusCode === 200) {
          const parsedResponse = JSON.parse(responseData);
          if (parsedResponse.url) {
            resolve(parsedResponse.url);
            // Returns something like: https://healthsync.app.link/reset-abc123
          } else {
            reject(new Error("No deep link URL found in the response."));
          }
        } else {
          reject(new Error(`HTTP ${res.statusCode}: ${responseData}`));
        }
      });
    });

    req.on("error", (error) => {
      reject(new Error(`Request failed: ${error.message}`));
    });

    req.write(requestBody);
    req.end();
  });
}

Branch.io API — Request & Response

Request:

POST https://api2.branch.io/v1/url
Content-Type: application/json

{
  "branch_key": "key_live_xxxxxxxxxxxx",
  "duration": 600,
  "data": {
    "custom_object": {
      "token": "eyJhbGciOiJIUzI1NiJ9.eyJ1c2VyX25hbWUi...",
      "type": "forgot-password"
    }
  }
}

Response:

{
  "url": "https://healthsync.app.link/reset-abc123"
}

Key parameters:

  • branch_key — Your Branch.io live key (authenticates the request)
  • duration — How long the link metadata is accessible (in seconds). We set this to 600 (10 minutes) to match the JWT expiry
  • data.custom_object — Any JSON payload you want the mobile app to receive when it resolves the link

The Main Handler — Tying It Together

module.exports.sendLink = async (event) => {
  const userName = event.pathParameters.user_name;
  const isMobile = event?.queryStringParameters?.type === "mobile";

  // 1. Validate user exists and is active
  const userInfo = await cognitoHelper.cognitoUser(userName);

  // 2. Generate a JWT session token (10 min expiry)
  const sessionToken = await generateSessionToken(userName, userType);

  // 3. Generate the web reset link (direct URL with token in query param)
  const webResetPasswordLink =
    `https://${forgotPasswordLink}?token=${sessionToken}`;

  // 4. Generate the mobile reset link (Branch.io deep link)
  const mobilePasswordResetLink = await generateDeepLink(sessionToken);

  // 5. Store the token in database (for one-time use verification)
  await ForgotPasswordTokens.updateOne(
    { user_name: userName },
    { $set: { token: sessionToken } },
    { upsert: true }
  );

  // 6. Send email with both links
  await helpers.sendSESEmail(
    emailAddress,
    process.env.SENDER_EMAIL,
    JSON.stringify({
      user: fullName,
      mobile_reset_link: mobilePasswordResetLink,
      reset_link: webResetPasswordLink,
      support_email: process.env.SUPPORT_EMAIL,
      support_phone: process.env.SUPPORT_PHONE,
    }),
    process.env.FORGOT_PASSWORD_EMAIL_TEMPLATE
  );

  return {
    statusCode: 201,
    body: JSON.stringify({
      message: "Password reset link has been sent to your registered email address",
    }),
  };
};

10. Backend — Token Verification & Password Reset

When the user submits their new password from the app, the backend verifies the token and updates the password.

Token Storage Model

// entities/ForgotPasswordTokens.js

const ForgotPasswordTokensSchema = new Schema({
  user_name: { type: String },
  token: { type: String },
  created_at: {
    type: Number,
    default: () => Math.floor(Date.now() / 1000),
  },
  updated_at: {
    type: Number,
    default: () => Math.floor(Date.now() / 1000),
  },
});

Verification Endpoint

// handlers/forgot_password_verify.js — PATCH /forgot-password-verify

module.exports.verify = async (event) => {
  const userData = JSON.parse(event.body);
  // { new_password, confirm_password, token, timezone }

  const jwtSecret = process.env.JWT_SECRET_KEY;

  // 1. Verify JWT hasn't expired
  const tokenStatus = isTokenExpired(userData.token, jwtSecret);
  if (tokenStatus === "expired") {
    return { statusCode: 400, body: "The link has expired" };
  }
  if (tokenStatus === "invalid") {
    return { statusCode: 400, body: "Invalid token" };
  }

  // 2. Decode JWT to get user info
  const decodedData = jwt.decode(userData.token, jwtSecret, {
    algorithm: "HS256",
  });

  // 3. Check token exists in database (one-time use enforcement)
  const tokenDetails = await ForgotPasswordTokens.find({
    token: userData.token,
  });
  if (tokenDetails.length !== 1) {
    return { statusCode: 400, body: "The link has expired" };
  }

  // 4. Delete token immediately (prevents reuse)
  await ForgotPasswordTokens.deleteOne({ token: userData.token });

  // 5. Validate password strength
  const validatedResult = await validatePassword(userData);
  if (!validatedResult.success_status) {
    return { statusCode: 400, body: validatedResult.message };
  }

  // 6. Update password in Cognito (auth provider)
  await cognitoHelper.cognitoResetPassword({
    username: decodedData.user_name,
    password: userData.new_password,
  });

  // 7. Update password in database
  await User.updateOne(
    { user_name: decodedData.user_name },
    { $set: { password: encrypted, updated_at: now } }
  );

  // 8. Force global sign-out (invalidate all sessions)
  await cognitoHelper.forceGlobalSignOut(decodedData.user_name);

  return { statusCode: 201, body: "Password reset successfully." };
};

Token Security — Three Layers of Protection

┌────────────────────────────────────────────────────────┐
│              Token Security Layers                     │
├────────────────────────────────────────────────────────┤
│                                                        │
│  Layer 1: JWT Expiration (10 minutes)                  │
│  ├─ Signed with HS256 algorithm                        │
│  ├─ Server verifies signature + expiry                 │
│  └─ Cannot be tampered with                            │
│                                                        │
│  Layer 2: Database One-Time Use                        │
│  ├─ Token stored in ForgotPasswordTokens collection    │
│  ├─ Checked before allowing password reset             │
│  ├─ Deleted immediately after use                      │
│  └─ Second use → "The link has expired"                │
│                                                        │
│  Layer 3: Branch.io Link Duration                      │
│  ├─ Link metadata expires after 600 seconds            │
│  ├─ After expiry, API returns no custom_object         │
│  └─ App cannot resolve the token                       │
│                                                        │
│  Bonus: Global Sign-Out After Reset                    │
│  ├─ All active sessions invalidated via Cognito        │
│  └─ User must log in again with new password           │
│                                                        │
└────────────────────────────────────────────────────────┘

Password Validation Rules

const passwordSchema = new passwordValidator();
passwordSchema
  .is().min(8)         // Minimum 8 characters
  .has().uppercase()   // At least one uppercase letter
  .has().lowercase()   // At least one lowercase letter
  .has().digits()      // At least one digit
  .has().symbols();    // At least one symbol (!@#$%^&*.:/?)

11. The Email Template — Dual Links

The email template uses Handlebars-style conditionals to show different content for mobile and non-mobile users:

<!-- forgot_password_email.html -->

{{#if mobile_reset_link}}
  <!-- Mobile user: show both options -->
  <p>To proceed with the password reset, please follow
     only one of the links below:</p>

  <p>If you are on your phone,
    <a href={{mobile_reset_link}}>
      click here to reset using your mobile app.
    </a>
  </p>

  <p>or</p>

  <p>If you are on your computer,
    <a href={{reset_link}}>
      click here to reset using your browser.
    </a>
  </p>
{{else}}
  <!-- Non-mobile user: web link only -->
  <p>To proceed with the password reset,
     please follow the link below:</p>
  <a href={{reset_link}}>[Password Reset Link]</a>
{{/if}}

Why dual links?

  • The mobile link (mobile_reset_link) is a Branch.io deep link → opens the app → navigates to Set New Password screen
  • The web link (reset_link) is a direct URL → opens the web portal → shows the password reset form
  • Both links use the same JWT token — the backend verification is identical regardless of which link was used

This gives the user a choice. If they’re reading the email on their phone, they tap the mobile link and stay in the app. If they’re on a computer, they use the web link. Both paths lead to the same outcome with the same security guarantees.


12. The Complete Flow — End to End

Here’s the full password reset flow from start to finish:

┌─────────────────────────────────────────────────────────────┐
│                  PASSWORD RESET FLOW                        │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  1. USER REQUESTS RESET                                     │
│     App → POST /forgot-password-send-link/{username}        │
│            ?type=mobile                                     │
│                                                             │
│  2. BACKEND GENERATES TOKENS                                │
│     ├─ JWT session token (10 min expiry)                    │
│     ├─ Store token in ForgotPasswordTokens (MongoDB)        │
│     ├─ Call Branch.io API → get short URL                   │
│     └─ Generate web reset URL                               │
│                                                             │
│  3. SEND EMAIL                                              │
│     AWS SES → user's registered email                       │
│     ├─ Mobile link: https://healthsync.app.link/abc123      │
│     └─ Web link: https://app.healthsync.com/reset?token=... │
│                                                             │
│  4. USER TAPS MOBILE LINK                                   │
│     ├─ iOS: Universal Link → opens app directly             │
│     └─ Android: App Link → opens app directly               │
│                                                             │
│  5. APP RESOLVES LINK                                       │
│     useBranchDeepLink() hook                                │
│     ├─ Detects "app.link" in URL                            │
│     ├─ GET api2.branch.io/v1/url?url=...&branch_key=...    │
│     ├─ Extracts custom_object.token                         │
│     └─ Sets params: { token, type: "forgot-password" }      │
│                                                             │
│  6. APP NAVIGATES                                           │
│     AppNavigator detects branchParams change                │
│     └─ navigate("SetNewPassword", { token })                │
│                                                             │
│  7. APP VALIDATES TOKEN                                     │
│     SetNewPasswordScreen mounts                             │
│     ├─ Shows loading spinner                                │
│     ├─ Calls validateToken(token) → backend                 │
│     └─ If valid → show password form                        │
│        If expired → show error toast                        │
│                                                             │
│  8. USER SUBMITS NEW PASSWORD                               │
│     App → PATCH /forgot-password-verify                     │
│     { token, new_password, confirm_password }               │
│                                                             │
│  9. BACKEND VERIFIES & UPDATES                              │
│     ├─ Verify JWT signature + expiry                        │
│     ├─ Check token exists in DB (one-time use)              │
│     ├─ Delete token from DB                                 │
│     ├─ Validate password strength                           │
│     ├─ Update password in Cognito                           │
│     ├─ Update password in MongoDB                           │
│     ├─ Force global sign-out                                │
│     └─ Return success                                       │
│                                                             │
│ 10. APP SHOWS SUCCESS                                       │
│     ├─ "Password Changed" screen                            │
│     ├─ User taps "Login"                                    │
│     └─ Redirected to login screen                           │
│                                                             │
└─────────────────────────────────────────────────────────────┘

13. Key Takeaways

  1. You don’t need the Branch SDK. Branch.io’s REST API (api2.branch.io/v1/url) lets you both create and resolve deep links with simple HTTP calls. This keeps your bundle size small and avoids native module compatibility headaches in managed Expo.

  2. expo-linking handles both link types. getInitialURL() catches cold-start links, addEventListener("url") catches warm-start links. Together, they cover every scenario.

  3. Associated Domains (iOS) and Intent Filters (Android) are all you need in app.config.ts. No custom Expo config plugins required for deep linking — Expo’s built-in config options handle the native setup.

  4. Branch.io hosts the verification files for you. The apple-app-site-association and assetlinks.json files are hosted on your app.link domain automatically. You just need to enter your bundle ID, package name, and signing certificate fingerprint in the Branch dashboard.

  5. Always support both link types. Branch.io links for normal usage, custom scheme links as a fallback. The hook handles both transparently.

  6. Match your expiry times. JWT token (10 min), Branch link duration (600 seconds), and database token TTL should all align. A mismatch creates confusing edge cases where the link works but the token is expired, or vice versa.

  7. One-time tokens are essential for password reset. Storing the token in the database and deleting it after use ensures each reset link can only be used once — even if the JWT hasn’t technically expired yet.

  8. Wait for navigation readiness. Always gate deep link navigation on isNavigationReady. Cold-start deep links arrive before the NavigationContainer mounts — without this check, the navigate() call silently fails.

  9. Filter development URLs. In development, Expo injects its own URLs (expo-development-client://). Always filter these in your deep link handler to avoid false matches.

  10. Force global sign-out after password reset. After a successful reset, invalidate all existing sessions. This ensures that any compromised session (the reason the user is resetting their password) is immediately terminated.


Built with Expo SDK, React Native, Branch.io REST API, Node.js, and AWS SES. Tested on both iOS and Android devices in production.