◉RORK LABJP
●GPT-6.1 SOL — Added to Rork's model menu (09-29) with Low, Medium and High effort; included on Pro and Max●SONNET 5.5 — Claude Sonnet 5.5 is selectable: strong at UI design and faster than Sonnet 5 (09-28)●SDK 58 — Expo SDK 58 is out of beta as of 10/15 (beta since 09/15). Check your dependencies before upgrading●WIDGET — A write-up traces an iOS widget that renders black only in production builds●OBSERVE — EAS Observe now records native crashes too (SDK 57.0.21 or later, 10/07)●NEW — My App Open Ad Only Showed When the App…●GPT-6.1 SOL — Added to Rork's model menu (09-29) with Low, Medium and High effort; included on Pro and Max●SONNET 5.5 — Claude Sonnet 5.5 is selectable: strong at UI design and faster than Sonnet 5 (09-28)●SDK 58 — Expo SDK 58 is out of beta as of 10/15 (beta since 09/15). Check your dependencies before upgrading●WIDGET — A write-up traces an iOS widget that renders black only in production builds●OBSERVE — EAS Observe now records native crashes too (SDK 57.0.21 or later, 10/07)●NEW — My App Open Ad Only Showed When the App…
Articles/Dev Tools
⬡ Dev Tools/2026-09-03Beginner

When a link opens the browser instead of your app, look at the signing key before assetlinks.json

Android App Links fail silently when the certificate fingerprint does not match. Here is the real keytool output, the upload key versus app signing key trap, and a small checker you can run before you deploy.

Android App Linksassetlinks.jsonExpo220Rork590indie development42

It was late afternoon, with two test phones on the desk, when I noticed it. The app was installed, but tapping a link to my own site from an email opened the browser. Calling Linking.openURL from inside the app landed on exactly the right screen. Only links arriving from outside walked straight past the app.

As an indie developer running several apps at once, this kind of one-directional bug costs me the most time. Because one path still works, there is very little to grab onto when looking for the broken one.

Let me give you the ending first. In my case the location of assetlinks.json was fine and the JSON structure was fine. What was wrong was the fingerprint I had written inside it. I had pasted the fingerprint of the key I build with locally, which is not the key that signs the build users actually receive.

Since Android 12, unverified links do not open your app

Android App Links work by placing the same statement on two sides and matching them: a file on your site saying "this app may handle these URLs", and an intent filter in your app marked with autoVerify.

The part worth committing to memory is the default behavior from Android 12 (API level 31) onward. If verification has not succeeded, the link will not open your app unless the user turns it on by hand in system settings. It goes to the browser instead.

Which means a failed verification produces no error at all. Nothing crashes, no red line appears in the logs, and all you are left with is "I tapped a link and the browser opened." I lost half a day suspecting my own routing code first. When an entry point from outside breaks, I now look at the space between the app and the site before I look inside the app.

There are three steps, and the third is where people get stuck

In a Rork or Expo project, the app side goes in app.json (or app.config.ts).

{
  "expo": {
    "android": {
      "package": "net.example.myapp",
      "intentFilters": [
        {
          "action": "VIEW",
          "autoVerify": true,
          "data": [
            { "scheme": "https", "host": "example.com", "pathPrefix": "/app" }
          ],
          "category": ["BROWSABLE", "DEFAULT"]
        }
      ]
    }
  }
}

With autoVerify set to true, the device fetches https://example.com/.well-known/assetlinks.json at install time and checks whether your signature's fingerprint is listed there.

The file on the site side looks like this.

[
  {
    "relation": ["delegate_permission/common.handle_all_urls"],
    "target": {
      "namespace": "android_app",
      "package_name": "net.example.myapp",
      "sha256_cert_fingerprints": ["your fingerprint here"]
    }
  }
]

The serving requirements are almost boringly simple: HTTPS, no redirects, and a application/json content type. I run my Lab sites on Cloudflare and my reading blogs on WordPress, and while the former serves a static file without complaint, the latter can swallow requests to .well-known through its permalink rules. Opening the URL directly in a browser before you go further will save you an hour.

Then comes the third step, the fingerprint. That is the real subject here.

Paste what keytool prints, exactly as printed

The fingerprint comes from the certificate of your signing key. For a local keystore, keytool will read it. Here is the actual output from a sample keystore I generated.

$ keytool -list -v -keystore upload.jks -storepass changeit
Alias name: upload
Certificate fingerprints:
	 SHA256: 6E:66:8F:80:8A:86:71:93:42:09:66:DB:3B:7F:AB:AE:4B:52:35:EC:E2:C8:76:EA:D9:F8:C5:9B:E8:E6:51:F7
Signature algorithm name: SHA256withRSA

Take the string to the right of SHA256: and put it into sha256_cert_fingerprints with the colons intact and the letters uppercase. Counting the one above, it is 95 characters with colons and 64 hex digits without them. There is no need to tidy it by stripping colons or lowercasing. Leaving it untouched is safer.

If you only have openssl at hand, the same value comes out this way.

$ openssl x509 -in cert.pem -noout -fingerprint -sha256
sha256 Fingerprint=AF:B8:01:5B:D8:47:87:2A:59:A5:3F:F1:ED:61:2C:E3:14:D8:EE:AF:1F:BA:68:ED:A8:9A:31:FF:3C:67:7B:8D

Your local build and the shipped build carry different signatures

This is where I got stuck. When Play App Signing is enabled, the key you hold is the upload key, while the signature that actually rides on the APK reaching a user's device comes from the app signing key that Play keeps. Two different certificates, so two different fingerprints.

I generated two sample keystores and put their fingerprints side by side.

upload      = 6E:66:8F:80:8A:86:71:93:42:09:66:DB:3B:7F:AB:AE:4B:52:35:EC:E2:C8:76:EA:D9:F8:C5:9B:E8:E6:51:F7
appsigning  = AE:4E:BC:69:E7:8E:51:0F:DC:BA:C2:38:CC:7A:2E:37:0A:E7:57:2C:5C:11:9E:67:35:AB:04:43:E7:11:07:4B
match       = no

That explains the symptom where links open on a locally installed build but not on the one downloaded from the store. The local build is signed with the upload key, so it matches an assetlinks.json that lists only the upload fingerprint. The shipped build carries a different key, so it does not.

The fix is small: list both fingerprints. sha256_cert_fingerprints is an array and takes more than one entry. You will find the app signing key's fingerprint on the app integrity (app signing) page in Play Console.

KeyWhere it is usedWhere to read the fingerprint
Upload keyLocal builds, submissions to Playkeytool -list -v on your keystore
App signing keyThe build users installPlay Console, app signing page

If the submission itself is being rejected over key mismatch, Fixing the 'Signed With the Wrong Key' Error When Uploading a Rork App to Google Play covers that closer.

Hand the comparison to a script instead of your eyes

Comparing 64 hex digits by eye was beyond me. I keep a short script that reads the file and does the matching mechanically.

import json, re, sys
 
REL = "delegate_permission/common.handle_all_urls"
FP_RE = re.compile(r"^(?:[0-9A-F]{2}:){31}[0-9A-F]{2}$")
 
def check(path, package, expected):
    problems = []
    try:
        doc = json.loads(open(path, encoding="utf-8").read())
    except json.JSONDecodeError as e:
        return [f"not valid JSON: {e}"]
 
    if not isinstance(doc, list):
        problems.append("top level is not an array (wrap even a single statement in [])")
        doc = [doc]
 
    hit = False
    for i, st in enumerate(doc):
        tgt = st.get("target", {})
        if tgt.get("namespace") != "android_app":
            continue
        if tgt.get("package_name") != package:
            problems.append(f"[{i}] package_name is {tgt.get('package_name')!r}")
            continue
        hit = True
        if REL not in st.get("relation", []):
            problems.append(f"[{i}] relation is missing {REL}")
        fps = tgt.get("sha256_cert_fingerprints", [])
        for fp in fps:
            if not FP_RE.match(fp):
                problems.append(f"[{i}] fingerprint format looks off: {fp[:20]}...")
        # ignore colons and case only when comparing
        norm = {fp.replace(":", "").upper() for fp in fps}
        if expected.replace(":", "").upper() not in norm:
            problems.append(f"[{i}] the key you asked about is not listed")
 
    if not hit:
        problems.append(f"no android_app statement targets {package}")
    return problems
 
if __name__ == "__main__":
    path, package, expected = sys.argv[1], sys.argv[2], sys.argv[3]
    found = check(path, package, expected)
    print(f"--- {path}")
    print("  nothing found" if not found else "")
    for p in found:
        print(f"  NG: {p}")

Here is the real output across four files, each broken in a different way.

--- samples/ok.json
  nothing found
--- samples/obj.json
  NG: top level is not an array (wrap even a single statement in [])
--- samples/nocolon.json
  NG: [0] fingerprint format looks off: 6E668F808A8671934209...
--- samples/badrel.json
  NG: [0] relation is missing delegate_permission/common.handle_all_urls

And here is the mismatch itself, reproduced. A file listing only the upload fingerprint, checked against the shipped build's app signing key.

--- samples/uploadonly.json
  NG: [0] the key you asked about is not listed
--- samples/both.json
  nothing found

The file with both fingerprints passed against the app signing key and against the upload key. Running those two checks before you deploy means a fingerprint will no longer send your users to the browser without saying a word.

One note on intent: the format check is strict about uppercase and colons, while the matching step ignores both. I want what I write into the file to follow the documented example, and at the same time I do not want a formatting difference to hide a real mismatch during comparison.

Ask the device what it decided

Even with a correct file, a link will not open until the device has finished verifying. You can read that state on a real phone.

# check the current verification state
adb shell pm get-app-links net.example.myapp
 
# ask the device to verify again
adb shell pm verify-app-links --re-verify net.example.myapp

Each domain in the output carries a state, and verified is the one you want. If it stays none or legacy_failure, either the file is not being served correctly or the fingerprint still does not line up. The device fetches the file once at install time, so after fixing it you need to reinstall the app or run the --re-verify command above.

If you want to think about where the user lands after that link, Landing Users on the Right Screen Right After Install — Deferred Deep Links for Rork Apps approaches the same journey from a different side.

One thing to do next

Open your assetlinks.json and look at whether sha256_cert_fingerprints holds a single entry. If it does, find out whether that one belongs to your local keystore or to the key Play holds. In most cases, adding one more line is the whole fix.

I did not catch it either until I tested the shipped build. If this shortens someone else's detour a little, I am glad.

Share

Thank You for Reading

Rork Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.

  • ✦Copy-paste ready implementation code
  • ✦New advanced guides published daily
  • ✦$5/mo or $10 for lifetime access
View Membership →

If you found this article helpful, a small tip ($1.50) would mean a lot to us. Your support helps keep this site ad-free and covers server and hosting costs.

Related Articles

⬡ Dev Tools2026-10-15
When EXPO_PUBLIC_ Values Read Fine in Dev but Come Back undefined in Production: Telling the Spellings Apart
The API URL reads fine while developing and turns undefined once the app is built. The cause was how process.env was written. I lined up babel-preset-expo's output for dev and production and tabulated which spellings get replaced and which are left behind.
⬡ Dev Tools2026-10-13
Before You Ship: Go Looking for Your Own Keys Inside the Exported Expo Bundle
A key can be missing from your source and still be sitting inside the finished app. I ran expo export on Expo SDK 57, searched the bundle with grep and a short Node script, and recorded which secrets show up and which don't.
⬡ Dev Tools2026-10-07
Japanese Headings Won't Turn Bold on Android: How to Trace and Fix a fontWeight That Does Nothing
A troubleshooting note for Rork apps where headings look bold on iPhone but stay thin on Android. How to tell the symptoms apart, pick weights through fontFamily, and verify the fix.