RORK LABJP
EVENT — Apple holds its Surprise and Shine event today, September 9, starting at 10:00 Pacific. That lands in the small hours of September 10 in JapanEXPECT — Expected are the iPhone 18 Pro and Pro Max, a foldable, the 2nm A20 Pro chip, and release dates for iOS 27 and its sibling updatesWAIT — As this is written the event has not happened yet. Rumor-stage writing and post-announcement writing look identical once they are mixed togetherMAX — Since Rork Max generates native Swift, Apple news is not somebody else's problem. Worth repeating that the standard product still writes React NativeSIMULATOR — Rork Max compiles on cloud Macs and lets you check the result in a streaming iOS simulator inside the browser, with no Xcode and no Mac hardwareSEASON — A new OS is when automated build pipelines wobble most. An article selling convenience owes its readers a word about that wobbleEVENT — Apple holds its Surprise and Shine event today, September 9, starting at 10:00 Pacific. That lands in the small hours of September 10 in JapanEXPECT — Expected are the iPhone 18 Pro and Pro Max, a foldable, the 2nm A20 Pro chip, and release dates for iOS 27 and its sibling updatesWAIT — As this is written the event has not happened yet. Rumor-stage writing and post-announcement writing look identical once they are mixed togetherMAX — Since Rork Max generates native Swift, Apple news is not somebody else's problem. Worth repeating that the standard product still writes React NativeSIMULATOR — Rork Max compiles on cloud Macs and lets you check the result in a streaming iOS simulator inside the browser, with no Xcode and no Mac hardwareSEASON — A new OS is when automated build pipelines wobble most. An article selling convenience owes its readers a word about that wobble
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.jsonExpo203Rork558indie development40

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 $15 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-09-07
Adding .easignore stops EAS from reading .gitignore — count what actually ships to the build
EAS Build decides what to upload from .gitignore, and the moment you add .easignore the two swap places. Here is how I count the bundled files before sending, plus a measured result: once a parent directory is excluded, an exclamation mark cannot bring a file back.
Dev Tools2026-09-05
Your Widget Extension Can Submit a BGTaskScheduler Request. It Just Cannot Register the Handler
Calling BGTaskScheduler from a widget extension compiles, submits, and returns true — and then nothing runs. Here is why registration belongs to the host app only, and how I moved refresh ownership back where it belongs across my wallpaper apps.
Dev Tools2026-09-04
EAS secret visibility does not keep a value out of your app — deciding prefix and visibility separately
The EXPO_PUBLIC_ prefix decides what ships inside your app; EAS visibility decides who can read it. Why stacking them blanks a value on OTA updates, and how to check your build.
📚RECOMMENDED BOOKS
Build a Large Language Model (From Scratch)
Sebastian Raschka
LLM Dev
Prompt Engineering for LLMs
Berryman & Ziegler
Prompting
AI Engineering
Chip Huyen
AI Eng
* Contains affiliate links