Shipmind Labs

A deep link routing table is an authorisation boundary

· 8 min read

A deep link is a URL that anyone can send you. It arrives from an email, a text message, another app, or a QR code on a poster, and it picks a screen in an app that may already be signed in. The code that decides which screen is usually a chain of startsWith checks plus a params object holding whatever came after the last slash, a switch statement standing where an authorisation boundary belongs.

We build mobile apps alongside the backends they talk to, often role-specific apps for couriers, warehouse staff and operations teams, and notification services that fan out to push, WhatsApp and Telegram. Every one of those messages carries a link back into the app. That link is the least trusted input the app receives, and it is the input we have repeatedly seen handled with the least ceremony. We wrote our thinking down as a library, deeplinkmap, because the argument is easier to make in types than in prose.

What the chain of startsWith checks actually decides#

Four things go wrong, and they go wrong together.

Parameters are strings from a stranger, passed straight into a screen that expects an id, so /order/DROP TABLE reaches the query builder. The fallback is "open whatever we matched last", so a link that matches nothing lands somewhere arbitrary, often on a screen nobody meant to expose. A link that arrives while nobody is signed in is opened anyway, and the screen fails oddly instead of the app asking for a login and resuming afterwards. And every link counts as equally trustworthy, although a universal link is one the operating system checked against a host that serves apple-app-site-association or assetlinks.json naming this app, while a custom scheme is a namespace any installed app can also register and any web page can link to.

None of these is a bug in the sense of a wrong line. They are the defaults you get when routing is written as dispatch rather than as a decision about what a stranger is allowed to ask for.

Declare the placeholder, or the table does not build#

The first rule we hold to is that a placeholder says what it may contain, in the table, before anything runs.

typescript
export type ParamKind = "string" | "number" | "uuid" | "slug";

export type ParamSpec = {
  readonly kind: ParamKind;
  /** Refuse anything shorter. Defaults to one character. */
  readonly minLength?: number;
  /** Refuse anything longer. Defaults to 128 characters. */
  readonly maxLength?: number;
  /** Closed range for a "number". Refused on any other kind. */
  readonly min?: number;
  readonly max?: number;
  /** The complete set of accepted values. Anything else is refused. */
  readonly oneOf?: readonly (string | number)[];
};

A kind says what a value looks like; a constraint says which of those values the screen actually has. :id declared as number reaches the screen as a number, already inside its range, and /order/1 OR 1=1 never gets that far.

The part that matters more than the validation is what happens when a route forgets to declare something. A route with a placeholder in its pattern and no declaration for it fails at construction. It throws InvalidRoute, because an undeclared placeholder would otherwise arrive as an unvalidated string from a stranger. Same for a pattern that repeats :id twice, a route name declared twice, a min above its max, a min on a slug, or a oneOf value that its own kind rejects. A table that cannot mean what it says refuses to exist rather than sending links quietly nowhere in the field.

This is the trade we like: the failure moves from a user's phone, months later, to a failing test or a failed boot on the machine of whoever edited the table.

typescript
const router = new Router(
  [
    {
      name: "order",
      pattern: "/order/:id",
      params: { id: { kind: "number", min: 1 } },
      requiresAuth: true,
    },
    {
      name: "article",
      pattern: "/article/:slug",
      params: { slug: "slug" },
      query: { ref: "slug" },
      accepts: ["universal", "scheme"],
    },
    // No accepts: a reset token only arrives through a verified link.
    { name: "reset", pattern: "/reset/:token", params: { token: "uuid" } },
  ],
  { schemes: ["myapp"], hosts: ["example.com", "*.example.com"] },
);

Read that table as a policy document and it answers questions a reviewer can hold in their head: which screens a stranger can address, what shape the arguments take, which of them need a session first.

The two doors are not equally trustworthy, so accepts is declared per route: universal, scheme, or both, defaulting to universal links only. The article route above opens to both, because the worst outcome of a forged article link is that somebody reads an article. The reset route says nothing, so it inherits the default and declines the unverified door entirely. A token that can change an account should not arrive through a namespace any installed app can register.

Hosts are compared whole. example.com matches example.com, and *.example.com matches exactly one label below it. example.com.evil.net ends with the declared host and is somebody else's site, which is exactly what a startsWith or endsWith check lets in, and following it is how an app becomes an open redirect wearing native UI. http belongs to a separate switch, allowInsecure, off by default and meant for local development. An allow-list that cannot mean what it says (*, *.com, a URL where a hostname belongs, a route open to no door at all) throws InvalidPolicy when the router is built.

A refusal is a value, not a missing else-branch#

Resolving a link returns one of two things, and the second one is as much a part of the API as the first.

typescript
export type Refusal = {
  readonly reason:
    | "unparseable"
    | "insecure-scheme"
    | "foreign-host"
    | "unknown-scheme"
    | "form-not-accepted"
    | "no-route"
    | "bad-parameter";
  readonly detail: string;
  readonly url: string;
};

export type Resolution<Name extends string = string> =
  | { readonly ok: true; readonly match: Match<Name> }
  | { readonly ok: false; readonly refusal: Refusal };

Seven named reasons, and no-route is one of them: a link that matches nothing goes nowhere, because "open whatever matched last" is how a stranger's link lands on a screen nobody meant to expose. A refusal comes back whole, so no screen is ever handed a half-filled parameter object built out of the placeholders that happened to validate before the one that did not. Query parameters are allow-listed on the same terms: declared ones are validated, everything else is dropped rather than passed on to a screen that might forward it somewhere.

The call site then reads as a short list of decisions rather than a funnel of guards:

typescript
function handle(delivery: Delivery | null) {
  if (delivery === null || delivery.status === "duplicate") return;
  if (delivery.status === "refused") return report(delivery.refusal);

  const { match } = delivery;
  if (match.requiresAuth && !session) {
    pending.hold(match);              // resume after signing in
    // ...send the user to sign in; the held match is replayed afterwards
  }
  // ...navigate
}

requiresAuth is answered by the table, not by each screen remembering to check, and the link that arrived too early is held as a validated match rather than as a raw URL replayed later through a code path nobody re-reads.

What it costs to run#

Less than the version it replaces, which is unusual enough to say plainly. The table is built once at startup, and the per-link work is a path comparison plus a handful of anchored regular expressions. The module imports nothing from React Native, since Linking hands it a string, so the whole routing table is testable in a plain test runner, on a laptop, without a device or a simulator. That is where the cost lands: the routes have tests, and a policy change is a diff in one file rather than an audit of every screen.

The operational payoff is probably the refusal reason. foreign-host and unknown-scheme in the logs are not noise. They are somebody trying links against your app, and they are legible because each refusal carries its reason and the URL as received, for logging, not for routing.

The habit underneath all of this is the one we apply to payment callbacks, webhook receivers and signed-document links alike: anything that arrives from outside gets a declared shape, a boundary that refuses in named ways, and a build that fails when the declaration is missing. A deep link is not a smaller case of that. It is the same case, holding a screen.

Was this useful?

Building something similar?

or email hello@shipmindlabs.com