Serving assetlinks.json from the wrong path
Published
On 2026-10-06 the dev.to account daniel_pertu published a build report about
CogniPrep, which the report describes as a Next.js app for practising employer
assessments, and the subject of the report is that the file proving the app owns
its domain is served from the wrong URL. The report states that
cogniprep.app/.well-known/assetlinks.json returns 404 and that
cogniprep.app/api/.well-known/assetlinks.json returns 200 with
content-type: application/json and the real contents, so the JSON itself is
correct and only its address is wrong
(the report).
What the report found
Section titled “What the report found”The report identifies the missing root-path file as a configuration that would cause verification to fail, and also examines the app’s manifest and how the Android build is declared to mobile browsers. Each finding is taken in turn below, in the terms the report and the platform documentation it concerns actually use (the report).
The one path Android fetches
Section titled “The one path Android fetches”The report states that CogniPrep ships as a website first and that there is an
Android wrapper built as a Trusted Web Activity, which it describes as Chrome’s
way of putting a web app in the Play Store without a WebView of your own. It
states that a Trusted Web Activity runs the site fullscreen with no address bar,
but only if it can prove that the Android package and the domain belong to the
same owner, and that the proof is Digital Asset Links: one JSON file, fetched
over HTTPS, from exactly one path, which it gives as
https://<your-domain>/.well-known/assetlinks.json
(the report).
Google’s own Digital Asset Links documentation states the same location in
stricter terms. It describes the protocol and API as enabling an app or website
to make public, verifiable statements about other apps or websites, and says
statements are contained in a statement list that is cleartext and publicly
accessible, in a location that is controlled by the principal and difficult to
spoof or tamper with. In its usage example the website publishes a statement
list at https://www.example.com/.well-known/assetlinks.json, and the page
states that this is the official name and location for a statement list on a
site, and that statement lists in any other location, or with any other name,
are not valid for that site. The example statement grants the Android app
permission to open links on the site, with the relation
delegate_permission/common.handle_all_urls and a target naming the
android_app namespace, a package_name, and sha256_cert_fingerprints, which
the page describes as the SHA256 fingerprints of the app’s signing certificate
(Google, Digital Asset Links getting started).
The route handler the report prints matches that shape. It returns a literal with
relation: ['delegate_permission/common.handle_all_urls'] and a target giving
namespace: 'android_app', package_name: 'app.cogniprep.twa', and a
sha256_cert_fingerprints array, and the report’s own verdict on it is right
relation, right namespace, right package, a real signing certificate
fingerprint, served from the wrong URL
(the report).
Why the file ended up under /api
Section titled “Why the file ended up under /api”The report states that the file lives at
app/api/.well-known/assetlinks.json/route.ts, that App Router maps directories
to URL segments, and that it is perfectly happy with directory names containing
dots, so .well-known is a segment, assetlinks.json is a segment, and the
resulting route is /api/.well-known/assetlinks.json. Its conclusion is that
nothing is broken and Next.js did exactly what the folder structure asked for
(the report).
The report locates the mistake in the api/ at the front of that path. It states
that every other server endpoint in the app lives under app/api/, because that
is where endpoints go, and that a file returning JSON from a GET handler reads
like an endpoint but is not one: it is a static well-known resource that happens
to be generated by code, and the thing consuming it is not the frontend. The
report states that Android’s verification service has no configuration, no base
path, and no override, and that it fetches /.well-known/assetlinks.json or it
fails
(the report).
The fixes are each described as one change. The first is to move the
folder to app/.well-known/assetlinks.json/route.ts, on the grounds that the
route path is the folder path. The second is to leave the handler where it is and
add a rewrites() entry in next.config.mjs mapping
/.well-known/assetlinks.json to /api/.well-known/assetlinks.json. The report
prefers the move, and its stated reason is that a rewrite is a second place that
has to stay true and that there is nothing API-shaped about the file
(the report).
Why nothing in the build complained
Section titled “Why nothing in the build complained”The report states that a missing assetlinks.json does not throw, does not log,
and does not 500, that the web app is completely unaffected, and that every
browser visitor gets the same site they always did. It states that the only
symptom lives on an Android device, where a Trusted Web Activity that fails
verification does not crash but degrades, opening the site in a Custom Tab with
the URL bar visible, so the app still works and stops looking like an app
(the report).
Chrome’s Trusted Web Activity quick start documents that fallback as the designed
behaviour rather than a quirk of this app. It states that a Trusted Web Activity
can launch a full screen browser tab without any browser UI, that the capability
is restricted to websites you own, and that ownership is proven by setting up
Digital Asset Links. It states that when a Trusted Web Activity is launched the
browser verifies the Digital Asset Links, and that if verification fails the
browser falls back to displaying the website as a Custom Tab, which it elsewhere
describes as launching the website as a Custom Tab with browser UI at the top of
the page. The same page instructs that after creating the assetlinks.json file
it be uploaded to the website at .well-known/assetlinks.json relative to the
root, and names using the wrong signature as a common cause of verification
failing
(Chrome, Trusted Web Activity quick start).
The report adds that the app’s own crawl rules hide the working copy. It states
that app/robots.ts emits Disallow: /api/ and Disallow: /*.json, so the one
URL that does serve the file is excluded twice over, by prefix and by extension,
and that there are 740 Disallow lines in the emitted file. It states that
Android does not read robots.txt, so this does not cause the failure, but that no
crawl of the site will surface the working copy or notice the missing one. It
also identifies a stale rule, Disallow: /manifest.ts, which it says blocks
nothing at all, because app/manifest.ts is a Next.js file convention and the
URL it produces is /manifest.webmanifest, and a rule written against the source
filename cannot match a request path
(the report).
More findings in the manifest
Section titled “More findings in the manifest”The report prints the app’s icon array, which declares /logo-light.png at
512x512 and image/png twice, once with "purpose": "any" and once with
"purpose": "maskable". It states that any is drawn as-is, that maskable is
drawn into whatever shape the launcher wants and needs roughly 20 percent of
padding on every side to survive the crop, and that reusing one square logo for
both means the maskable path is cropping artwork that was never laid out for it.
Its verdict is that listing it is not wrong, but that it is a claim about the
image rather than a property of the manifest, and that nothing validates it
(the report).
MDN documents the member that claim is made through. It describes purpose as a
case-sensitive keyword string that specifies one or more contexts in which the
icon can be used by the browser or operating system, states that browsers use
these values as hints to determine where and how an icon is displayed, and
defines maskable as indicating that the icon is designed with icon masks and
safe zone in mind, such that any part of the image outside the safe zone can be
ignored and masked away. It defines any as indicating that the icon can be used
in any context, and states that this is the default value
(MDN, icons).
The second manifest finding is the name. The report states that the manifest says
CogniPrep - Arctic Shores Practice and describes “all 14 Arctic Shores
psychometric games”, that this was true when it was written, and that the app now
covers dozens of assessment providers. It states that the installed app’s home
screen label is a string that nothing renders in the website, so looking at the
site will never show that it drifted. It also states that there is no
related_applications entry, so a mobile browser visiting the site has no way to
know the Android build exists
(the report).
MDN describes related_applications as the manifest member used to specify one
or more applications that are related to a web application, which may be
platform-specific applications or Progressive Web Apps, and states that it
enables the use of web APIs like Navigator.getInstalledRelatedApps() to check
whether a platform-specific version of the web app, or the web app itself, is
already installed on the device. MDN lists play among the example platform
values and marks the member experimental
(MDN, related_applications).
Why it matters for PWA developers
Section titled “Why it matters for PWA developers”The report names a shape shared by its findings: a value that is
only ever consumed by something outside the browser. It states that the
verification file is read by Android, that the manifest name is read by a
launcher, and that the maskable purpose is honoured by an icon mask the
developer does not control, and that none of them appear on a page, none fail a
type check, and none are exercised by a test that renders the app. Its stated
consequence is that a route handler returning a 200 with correct JSON feels
finished, and that the only way to find out it is finished at the wrong address
is to fetch the address the consumer actually uses, for which it gives
curl https://yourdomain.com/.well-known/assetlinks.json
(the report).
The platform documentation makes the same point from the consumer’s side. Google states that the statement list derives its value from being in a location controlled by the principal: the protocol does not authenticate the principal making the statement, but the statement is located in a specific location strongly associated with the principal and under the principal’s control. A path the principal chose for its own convenience is not that location, and Google’s page states outright that a statement list in any other location, or with any other name, is not valid for the site (Google, Digital Asset Links getting started).
Chrome’s quick start supplies the diagnostic that distinguishes the two ways a
Trusted Web Activity can fail. It states that a Trusted Web Activity will try to
adhere to the user’s default choice of browser, that if the default browser
supports Trusted Web Activities it will be launched, that failing that any
installed browser supporting them will be chosen, and that the default behaviour
is to fall back to a Custom Tabs mode. For that reason it instructs developers
debugging Trusted Web Activities to confirm which browser is in use, and prints
adb logcat -v brief | grep -e TWAProviderPicker as the way to check
(Chrome, Trusted Web Activity quick start).
Related entries on this site
Section titled “Related entries on this site”- Capabilities: follow this cross-reference alongside the report’s discussion of Trusted Web Activity integration.
- Storage: follow this cross-reference alongside the report’s discussion of a well-known file at a fixed origin-relative path.
- Performance: the report states its 404 is served as a full HTML page with a stylesheet and about a dozen script tags; follow this cross-reference alongside that finding.
Open and unverified
Section titled “Open and unverified”- Whether the missing
/.well-known/assetlinks.jsonis the only reason CogniPrep’s Trusted Web Activity fails verification, rather than one of several, is not confirmed. Chrome’s quick start names the wrong signing signature as a common cause of the same failure. - Whether the Android package
app.cogniprep.twais signed with the certificate whose fingerprint the route handler returns is not confirmed; the report prints the fingerprint truncated. - Whether moving the route handler to
app/.well-known/assetlinks.json/route.tsmakes verification succeed on any particular browser and Android version is not stated by the report. - Whether the roughly 20 percent of padding on every side that the report attributes to the maskable path corresponds to the safe zone any specific launcher applies is not confirmed.
- Whether a
related_applicationsentry would change what a mobile browser shows for this site is not confirmed; MDN marks the member experimental. - Whether the 740
Disallowlines the report counts in its emitted robots.txt affect anything other than crawling is not addressed.
Sources
Section titled “Sources”- Our Android verification file is served from /api, and Android only ever looks in /.well-known (dev.to)
- Digital Asset Links: Getting Started (developers.google.com)
- Trusted Web Activity quick start guide (developer.chrome.com)
- icons (developer.mozilla.org)
- related_applications (developer.mozilla.org)