RORK LABJP
PLAY — Google Play's target API level deadline passed on August 31. Whether the project your builder generates has kept pace is worth verifying yourself rather than assumingPOLICY — Play has sharpened its definition of a low-value app: rehashes of existing apps, or thin wrappers around a third-party model with no useful function of their ownDATA — User Data requirements apply to third-party AI integrations too. Disclosing what gets sent to an external model is the responsibility of whoever publishes the appAPPLE — Enforcement of Guideline 2.5.2 has tightened around apps that execute arbitrary code at runtime, putting the line between a tool and an app under closer scrutinyBUILD — Compiling a signed native binary server-side through a reproducible pipeline sits outside 2.5.2, and the technical distinction is worth stating preciselyIOS — iOS 27 reaches general release in September, supporting iPhone 11 and later. Building the device check into an annual routine takes the surprise out of itPLAY — Google Play's target API level deadline passed on August 31. Whether the project your builder generates has kept pace is worth verifying yourself rather than assumingPOLICY — Play has sharpened its definition of a low-value app: rehashes of existing apps, or thin wrappers around a third-party model with no useful function of their ownDATA — User Data requirements apply to third-party AI integrations too. Disclosing what gets sent to an external model is the responsibility of whoever publishes the appAPPLE — Enforcement of Guideline 2.5.2 has tightened around apps that execute arbitrary code at runtime, putting the line between a tool and an app under closer scrutinyBUILD — Compiling a signed native binary server-side through a reproducible pipeline sits outside 2.5.2, and the technical distinction is worth stating preciselyIOS — iOS 27 reaches general release in September, supporting iPhone 11 and later. Building the device check into an annual routine takes the surprise out of it
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.jsonExpo197Rork550indie development39

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-02
I counted how many versions expo install starts recommending after one patch bump
What expo install recommends is tied to the patch version of expo itself. I measured the ledger diff against the npm registry, how far npm install drifts, and the one check worth adding to a generated project.
Dev Tools2026-08-22
Every bulk replace exited zero. The damage was in the lines I did not delete
Run a bulk replace over generated code and the breakage lands on the neighbouring lines, not the matched ones. Here is what broke in a live project, and a dependency-free guard that checks the invariants a replace must preserve.
Dev Tools2026-08-21
The four acceptance checks I still run after the build turns green
A successful build does not mean a shippable build. Here are the four failure classes an AI build loop cannot see, and a dependency-free script that inspects the artifact itself before you submit.
📚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
See all →