Skip to content

Domain Association

Link your app to a domain you own

Some features require the OS to verify that your app and your domain belong together:

  • Android checks that the installed APK's signing-cert SHA-256 matches what the domain publishes in /.well-known/assetlinks.json. (Expo guide)
  • iOS checks that the app's Team ID + bundle identifier appear in the domain's /.well-known/apple-app-site-association (AASA) file, and that the app declares the domain in its Associated Domains entitlement. (Expo guide)

Set this up once, and it unlocks:

  • Passkeys — WebAuthn requires the rpId domain to vouch for your app.
  • Verified https redirects — links on your domain that return into the app, used by Magic Link (App Links / Universal Links) and optionally by the OAuth redirect.

That means: a stable Android signing keystore, the Associated Domains entitlement on iOS, two hosted verification files, and an rpId pointing at the domain that serves them.

1. Android: sign every build with the same keystore

Android only trusts the association if the installed APK's signing cert matches the fingerprint your domain publishes. Two defaults get in the way:

  • Debug builds are signed with an auto-generated keystore that differs per machine — every contributor would have a different fingerprint, and at most one could match.
  • You can't fix it by editing android/app/build.gradle: Expo regenerates the native project on every prebuild, wiping manual changes.

The fix: commit one shared keystore, and apply it with a config plugin so every regeneration picks it up.

Create a debug keystore

(source)

keytool -genkey -v -keystore debug.keystore -storepass android -alias androiddebugkey \
  -keypass android -keyalg RSA -keysize 2048 -validity 10000

This creates a debug.keystore in your project root. Commit it so every build (and every contributor) signs with the same cert.

Apply it with a config plugin

Create withDebugKeystore.js in the project root — it points the debug signingConfig at the committed keystore:

const { withAppBuildGradle } = require("expo/config-plugins");
 
/**
 * Points Android's debug signingConfig at the committed keystore in the
 * project root instead of the auto-generated one.
 *
 * Reason: Android passkeys (WebAuthn) require the installed APK's SHA-256
 * to match what the RP publishes in `/.well-known/assetlinks.json`. If
 * every contributor signs with `~/.android/debug.keystore` (different
 * per machine), only one of them matches. Signing every build with the
 * shared committed keystore makes the fingerprint stable across the team.
 *
 * Applied on every prebuild, so `pnpm android`, `npx expo run:android`,
 * and `eas build` all produce APKs signed with the same cert.
 */
const STORE_FILE_LINE = "storeFile file('debug.keystore')";
 
module.exports = function withDebugKeystore(config) {
  return withAppBuildGradle(config, (config) => {
    if (!config.modResults.contents.includes(STORE_FILE_LINE)) {
      throw new Error(
        `withDebugKeystore: did not find "${STORE_FILE_LINE}" in app/build.gradle — Expo prebuild template may have changed`,
      );
    }
    config.modResults.contents = config.modResults.contents.replace(
      STORE_FILE_LINE,
      // The path is resolved from the `android/app` directory, so go up two
      // levels to reach the project root where `debug.keystore` lives.
      "storeFile file('../../debug.keystore')",
    );
    return config;
  });
};

Register it in app.json under plugins:

{
  "expo": {
    "plugins": [
      "./withDebugKeystore",
      // ...
    ],
  },
}

The plugin takes effect when the native project is regenerated — run npx expo prebuild --clean, or it happens automatically on the next npx expo run:android if the android/ directory doesn't exist yet.

Extract the SHA-256 fingerprint

keytool -list -v \
  -keystore ./debug.keystore \
  -alias androiddebugkey -storepass android -keypass android

Copy the line under Certificate fingerprints starting with SHA256: — it goes into assetlinks.json below.

2. iOS: add the Associated Domains entitlement

Grab your Team ID from the Apple Developer membership page, then declare it and the domain in app.json:

{
  "expo": {
    "ios": {
      "bundleIdentifier": "<your bundle id>",
      "appleTeamId": "<your team id>", 
      "associatedDomains": [ 
        "webcredentials:<your domain>", 
        "applinks:<your domain>?mode=developer"
      ] 
    },
  },
}
  • webcredentials: is the entry passkeys check; applinks: is the one Universal Links (Magic Link) check.
  • ?mode=developer makes development builds fetch the AASA file directly from your origin instead of Apple's CDN, which can cache a stale copy for up to ~24h after you deploy. App Store builds strip the flag, so production traffic still goes through the CDN.
  • appleTeamId sets the development team for code signing in the generated Xcode project, so npx expo run:ios can sign without opening Xcode.

Associated Domains is a build-time entitlement, not runtime config. After adding or changing an entry, regenerate the native project and rebuild — npx expo prebuild --clean, then npx expo run:ios. Re-running against an already-built binary won't pick it up.

3. Create and host the verification files

Create a folder for the two /.well-known/ files:

mkdir -p assetlinks/public/.well-known

assetlinks.json (Android)

Create assetlinks/public/.well-known/assetlinks.json with your package name (from app.jsonandroid.package) and the SHA-256 fingerprint extracted in step 1:

[
  {
    "relation": [
      "delegate_permission/common.handle_all_urls",
      "delegate_permission/common.get_login_creds"
    ],
    "target": {
      "namespace": "android_app",
      "package_name": "<your app package name>",
      "sha256_cert_fingerprints": ["<your sha256 fingerprint>"]
    }
  }
]

apple-app-site-association (iOS)

Create assetlinks/public/.well-known/apple-app-site-association (no file extension) with your Team ID and bundle identifier:

{
  "applinks": {
    "details": [
      {
        "appIDs": ["<your team id>.<your bundle id>"],
        "components": [{ "/": "/verify-email*" }]
      }
    ]
  },
  "webcredentials": {
    "apps": ["<your team id>.<your bundle id>"]
  }
}
  • webcredentials is what passkeys check.
  • applinks.details[].components lists the https paths that should open your app — /verify-email* is the one the Magic Link guide uses (the trailing * also matches the ?code=... query string). Don't claim paths your app doesn't handle: every Safari navigation to a claimed URL gets intercepted by your app.

Apple requires the extension-less AASA file to be served as JSON, so pin its Content-Type with an assetlinks/vercel.json:

{
  "outputDirectory": "public",
  "headers": [
    {
      "source": "/.well-known/apple-app-site-association",
      "headers": [{ "key": "Content-Type", "value": "application/json" }]
    }
  ]
}

Host on Vercel

npm
cd ./assetlinks
npx vercel

Then verify both files deployed correctly:

curl -i https://<vercel_project_name>.vercel.app/.well-known/assetlinks.json
curl -i https://<vercel_project_name>.vercel.app/.well-known/apple-app-site-association
# expect 200 + application/json for both, with no redirects

iOS devices don't fetch the AASA from your origin — they go through Apple's CDN (unless the ?mode=developer flag from step 2 is active). Check what the CDN sees:

curl https://app-site-association.cdn-apple.com/a/v1/<vercel_project_name>.vercel.app

If the CDN payload is stale after a deploy, development builds with ?mode=developer bypass it; alternatively, toggle Settings → Developer → Universal Links → Associated Domains Development on the test device (the Developer menu appears once the device has been connected to Xcode).

4. Point the SDK at the domain

  • Change RP_ID in wagmi.config.ts to the deployed domain (no scheme): <vercel_project_name>.vercel.app.
  • If you specify an Access Control List of whitelisted Origins on the ZeroDev Dashboard, add https://<vercel_project_name>.vercel.app/ to the allowlist.