Domain Association
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
rpIddomain to vouch for your app. - Verified
httpsredirects — 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 10000This 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 nextnpx expo run:androidif theandroid/directory doesn't exist yet.
Extract the SHA-256 fingerprint
keytool -list -v \
-keystore ./debug.keystore \
-alias androiddebugkey -storepass android -keypass androidCopy 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=developermakes 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.appleTeamIdsets the development team for code signing in the generated Xcode project, sonpx expo run:ioscan 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, thennpx 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-knownassetlinks.json (Android)
Create assetlinks/public/.well-known/assetlinks.json with your package name (from app.json → android.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>"]
}
}webcredentialsis what passkeys check.applinks.details[].componentslists thehttpspaths 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
cd ./assetlinks
npx vercelThen 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 redirectsiOS 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.appIf 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_IDinwagmi.config.tsto 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.