← All posts
·11 min read·Kennedy Owusu

We shipped a default that silently failed in Gmail. Here's what we changed.

Our auth email templates accepted custom URL schemes. Gmail strips those. We found it building on our own platform — here's the failure, the full universal-links setup, and the guardrails now in the dashboard.

A password-reset flow on a Flutter app built on Koolbase looked finished. The email sent. SPF, DKIM, and DMARC all passed. The button rendered. And when you tapped it, nothing happened.

Not an error. Not a browser. Nothing.

The link was a custom URL scheme — myapp://reset?token=... — and Gmail strips those. The button was there; the href was gone. On desktop and mobile, in the world's most popular mail client, the reset flow was a dead button that looked exactly like a delivery failure. The first hour of debugging went to the wrong layer entirely: mail logs, sender domains, suppression lists.

The link was fine. The scheme was the problem. And our dashboard had cheerfully accepted it, with a help string that listed custom schemes as a normal option.

This post is two things: the full production setup for auth links that open your app and work in every mail client, and what we changed in Koolbase so the next person doesn't lose that hour.

Why custom schemes fail in email

Custom URL schemes (myapp://) feel like the natural choice for mobile deep links. No domain setup, no hosting, and they work perfectly when you test them with adb shell am start or by pasting into Notes.

But webmail clients don't honor them as hrefs. Gmail in particular strips or ignores non-https link targets — a defensible anti-phishing stance that happens to kill your auth flow. The cruel part is the failure mode: everything looks delivered and rendered, so you debug email infrastructure while the actual problem is one URL scheme.

The rule worth internalizing: custom schemes are fine for development and internal testing; they are not shippable in email. For production email links you need https URLs on a domain you control — Universal Links on iOS, App Links on Android.

What we changed

Before the walkthrough, the platform side, because it's the part that should have caught this:

The dashboard now warns on custom-scheme templates. Enter anything that doesn't start with https:// in a Verification or Password Reset URL field and you get an inline warning: custom URL schemes are not clickable in most email clients, including Gmail. The mistake is now caught at configuration time instead of in production.

The hosted https fallback is documented as the recommended default. Leaving those fields blank has always produced a Koolbase-hosted reset and verification page that works in every mail client with zero app-side setup. That behavior was previously described as suitable only for development, which undersold it and nudged people toward the option that breaks. It's now the documented default, with universal links positioned as the upgrade — which is how Supabase, Firebase, and Auth0 all treat the same choice.

If you want branded links that open your app, the rest of this post is the setup.

The architecture

The target behavior has three cases, and your setup has to handle all of them:

  1. App installed, link tapped on the phone → the OS intercepts the URL and opens your app directly on the right screen. No browser flash.
  2. App not installed, or link opened on desktop → the URL loads in a browser and lands on a working web page that completes the flow.
  3. The OS hasn't verified your domain yet (fresh install, propagation lag) → same as case 2. Degraded, not broken.

That means one https URL doing double duty: an app-open path and a web path. The pieces:

  • Two verification files served from your domain (/.well-known/)
  • Platform declarations in your app (an iOS entitlement, an Android intent filter)
  • Web fallback pages at the link paths
  • The email templates pointing at your domain

None of it is hard. All of it is fussy, and half the failure modes are silent.

Step 1: the .well-known files

Both platforms verify domain ownership by fetching a file from your site. This is what stops any app from claiming it handles links for google.com.

iOS: https://yourdomain.com/.well-known/apple-app-site-association — JSON, served with no file extension:

{
  "applinks": {
    "apps": [],
    "details": [{
      "appID": "TEAMID.com.example.app",
      "paths": ["/reset*", "/verify*"],
      "components": [
        { "/": "/reset*" },
        { "/": "/verify*" }
      ]
    }]
  }
}

TEAMID is your Apple Developer Team ID; the rest is your bundle identifier. paths is the legacy key and components the modern one — including both keeps older iOS versions working, which is why the example looks redundant. Scope them to exactly the routes your app handles; there's no reason to claim your whole site.

Android: https://yourdomain.com/.well-known/assetlinks.json:

[{
  "relation": ["delegate_permission/common.handle_all_urls"],
  "target": {
    "namespace": "android_app",
    "package_name": "com.example.app",
    "sha256_cert_fingerprints": [
      "AA:BB:CC:...",
      "DD:EE:FF:..."
    ]
  }
}]

Here's the trap that silently breaks Android verification for almost everyone on Play: if you use Play App Signing, the fingerprint users' devices see is Play's app signing key, not your upload keystore. Your local keytool output verifies your upload cert — which is not what signs the build users install. Get the app signing key's SHA-256 from Play Console (Test and release → App signing), and list both fingerprints: Play's key covers store installs, the upload key covers local release builds. One fingerprint means one of those two worlds silently fails verification.

A related gotcha during development: flutter run --release still signs with the debug keystore unless you've configured a release signing config. That build's fingerprint matches neither of the above, so verification fails on your own test device while the config is perfectly correct. Add the debug fingerprint as a third entry while testing, and strip it before a production release.

Step 2: serve them correctly

Uploading the files isn't enough. Two server-side requirements, both verifiable with curl before you touch your app:

curl -sI https://yourdomain.com/.well-known/apple-app-site-association

You need two things in that response:

A bare 200 — no redirects. iOS does not follow redirects when fetching the AASA file. If your apex domain 301s to www, or your host injects an https-upgrade redirect on that path, verification fails with no error surfaced anywhere. This is the first thing to check and the last thing anyone checks.

content-type: application/json. The AASA file has no extension, so most servers won't guess the type. On Apache, a two-line .htaccess inside .well-known/ fixes it:

<Files "apple-app-site-association">
  ForceType application/json
</Files>

Run the curl before writing a line of app code. If the headers are wrong, nothing downstream can work, and the failure will look like a mysterious app-side bug.

Step 3: app declarations

iOS — in Xcode, add the Associated Domains capability to your app target with one entry:

applinks:yourdomain.com

Android — a new intent filter in AndroidManifest.xml, alongside (not merged into) any existing custom-scheme filter:

<intent-filter android:autoVerify="true">
  <action android:name="android.intent.action.VIEW"/>
  <category android:name="android.intent.category.DEFAULT"/>
  <category android:name="android.intent.category.BROWSABLE"/>
  <data android:scheme="https" android:host="yourdomain.com"/>
  <data android:pathPrefix="/reset"/>
  <data android:pathPrefix="/verify"/>
</intent-filter>

Two notes. autoVerify="true" is what makes Android fetch your assetlinks.json at install time and open links without a disambiguation dialog. And keep this filter separate from your myapp:// scheme filter — mixing schemes in a single filter breaks verification.

Your router then needs to handle both URL shapes. If your deep-link handling was built for myapp://reset?token=..., the incoming universal link arrives as https://yourdomain.com/reset?token=... — same screen, different URL structure. In the custom-scheme form, reset is the host; in the universal-link form it's the path. Routing logic that branches on the host will silently miss. Check the mapping before you test on device, or the app will open and land somewhere confusing — which is a harder failure to diagnose than the app not opening at all.

While you're in the templates: confirm your verification URL points at your verify route and your reset URL at your reset route. Swapping those two values produces emails that open the app on the wrong screen with a valid token — a working-but-wrong flow, and the worst kind to debug.

Step 4: the web fallback

This is the step that separates a demo from production. The moment your reset email points at https://yourdomain.com/reset?token=..., that URL will be opened in browsers — by desktop users, by users without the app, by anyone whose OS hasn't verified the domain yet. If there's nothing at that path, your password-reset email leads to a 404 for a large share of real users.

The fallback doesn't need to be elaborate. A static page that reads the token from the query string and forwards to your backend's hosted reset form does the job. App users never see it (the OS intercepts before any HTTP happens); everyone else gets a working flow:

<script>
  var token = new URLSearchParams(location.search).get('token');
  var dest = 'https://api.example.com/auth/reset?token='
    + encodeURIComponent(token || '');
  if (token) location.replace(dest);
</script>

One subtlety: do this forward client-side, not as a server redirect rule. Server-level rewrites near your link paths are exactly the kind of thing that accidentally catches /.well-known/ requests — and you already know how iOS feels about redirects there.

On Koolbase, the hosted reset and verification pages are the same ones you get by leaving the template fields blank, so the fallback is genuinely a few lines pointing back at them.

Step 5: testing, and the traps

The build you already have installed cannot pass this test. Entitlements and manifest declarations ship inside the binary — install a fresh build first.

Then the traps, each of which cost real time:

  • Test from a mail client, not Safari. iOS deliberately does not hand typed or pasted URLs to apps — Universal Links only trigger from taps in other apps. Tapping the button in Mail or the Gmail app is the real test; pasting the URL into Safari proves nothing and will convince you it's broken.
  • Debug builds can't be launched by a link on iOS 14+. A debug build only launches from Xcode or Flutter tooling, so a link tap will appear to fail even with a perfect setup. Build Release before testing — in Xcode, Product → Scheme → Edit Scheme → Run → Build Configuration.
  • Apple caches your AASA on its CDN. A freshly added domain can take a while to propagate, so a correct setup can still fail right after install. Apple provides a developer mode that skips the cache for testing — check the current Associated Domains documentation for the exact syntax, as it has changed between releases, and remove it before store builds.
  • On Android, ask the OS what it thinks: adb shell pm get-app-links com.example.app reports the verification state per domain. verified is what you want; a numeric state means the assetlinks fetch or match failed. After fixing the file, force a recheck with adb shell pm verify-app-links --re-verify com.example.app rather than reinstalling.
  • Verify the web path with a follow-redirects curl (curl -L) — a bare directory path like /reset typically 301s to /reset/ before serving, which is harmless but will make a naive check look like a failure.

The complete flow

With all of it in place, the same email works everywhere: tapped on a phone with the app, it opens directly on the reset screen — no browser, no flash. Opened anywhere else, it lands on a working web form. Gmail is happy because the link is https. Your users are happy because the button does something. And the whole thing was verifiable with curl before a single build was cut.

The unglamorous summary: two JSON files, two response headers, one entitlement, one intent filter, two fingerprints, and a fallback page. Every one of them fails silently when wrong, and every one of them is checkable in isolation. Check them in order, and the thing works the first time you tap it.

And if you'd rather not do any of it: leave the template fields blank. The hosted pages work in every mail client, and you can add universal links whenever the branded experience is worth the afternoon.


Koolbase is a backend for Flutter and React Native apps — auth, database, storage, functions, and over-the-air code push. koolbase.com

Try Koolbase

Backend-as-a-service for Flutter and React Native, with over-the-air code push built in.

Start Building