skip to content

A React Native app's sign-in link, myapp://auth/magic?token=…&next=/settings, arrives through Linking; what must the handler check before acting on it?

level: seniorimportance: should knowfreq 35%

answer

  1. anyone can craft this URL
  2. allowlist scheme, host and path
  3. bare RN's URL parses only http(s) hosts
  4. token is a claim, the server decides
  5. next is an in-app route, never a URL

basics

~10 s

Treat the URL as attacker-controlled: accept only the expected scheme, host and path, shape-check the token and let the server validate it, map next to an allowlisted in-app route, and never act without confirmation.

solid answer

~50 s

Any app, web page or message can hand your app a URL, so the `Linking` string is **untrusted input**. Accept only the exact shapes you expect: the scheme, host and path of the magic-link route, compared as a whole prefix rather than with a loose `includes`. Parse parameters carefully: in a bare app, React Native's own `URL` polyfill only fills `hostname` and `pathname` for `http(s)` URLs and does not reject malformed input, though `searchParams` works for any scheme (the `expo` package replaces it with a WHATWG-compliant `URL`). Check the token's format locally, then send it to the server, which decides whether it is genuine, unexpired and unused. Treat `next` as a key into an allowlist of in-app routes, never as a URL to pass to `Linking.openURL` or a WebView. Finally, a link must not perform a sensitive action by itself; show a confirmation screen instead.

code

typescript · 26 lines
typescript
const TOKEN_PATTERN = /^[A-Za-z0-9_-]{32,128}$/;
const ALLOWED_NEXT = new Set(['/home', '/settings', '/orders']);
const ACCEPTED_PREFIXES = [
  'https://app.example.com/auth/magic?',
  'myapp://auth/magic?',
];

export type MagicLink = { token: string; next: string };

export function parseMagicLink(raw: string): MagicLink | null {
  if (!ACCEPTED_PREFIXES.some(prefix => raw.startsWith(prefix))) {
    return null;
  }
  let params: URLSearchParams;
  try {
    params = new URL(raw).searchParams;
  } catch {
    return null;
  }
  const token = params.get('token') ?? '';
  const next = params.get('next') ?? '/home';
  if (!TOKEN_PATTERN.test(token)) {
    return null;
  }
  return { token, next: ALLOWED_NEXT.has(next) ? next : '/home' };
}

go deeper

for a junior

Recall that deep-link URLs can come from anywhere, so the app must check their shape and not act on them blindly.

for a middle

Explain allowlisting scheme, host and path, the limits of bare React Native's URL polyfill for custom schemes, and why the server validates the token.

for a senior

Harden the flow: allowlisted next routes, no side effects on arrival, single handling, no token logging, and a clear split between client shape checks and server validation.

for a principal

Set a policy for which actions any deep link may trigger at all, and require server-side binding for every link that carries a credential.

## Why a deep link is untrusted A deep link is a string delivered to your app by the operating system on behalf of **someone else**: the mail app, a web page, another installed app, or a QR code. Nothing guarantees who wrote it. Custom schemes in particular can be sent by any app on the device, and even a verified `https` link can be crafted by anyone who knows the path format. So the rule for `Linking.getInitialURL()` and the `'url'` event is the same as for a request body: **parse, validate, then act**. ## Step 1: accept only the shapes you expect Decide which links the app supports and reject everything else before looking at parameters: - the scheme (`myapp:` or `https:`), - the host (`app.example.com` for the verified link), - the exact path (`/auth/magic`). Compare whole prefixes such as `https://app.example.com/auth/magic?` rather than testing `url.includes('example.com')`, which also matches `https://example.com.attacker.net/...`. ## Step 2: parse with React Native's URL in mind A bare React Native app gets React Native's own global `URL` and `URLSearchParams` polyfills, which are not the full WHATWG implementation. (An app that includes the `expo` package gets a WHATWG-compliant `URL` global instead, which parses custom-scheme hosts and throws on invalid input.) | Member | Behaviour in React Native's own polyfill (bare app) | |---|---| | `new URL(raw)` | does not throw on malformed input when no base is given | | `hostname`, `host`, `origin`, `pathname` | matched with patterns for `http://` and `https://` only; for `myapp://auth/magic` they come back empty or `/` | | `protocol` | works for any scheme | | `searchParams` | works for any scheme; each pair is split on `=`, keeping the text between the first and second `=`, and decoded with `decodeURIComponent`, which throws on a malformed `%` sequence | Practical consequences: 1. In a bare app, do not check a custom-scheme link with `url.hostname === 'auth'`; it will never match, and a developer may "fix" that by dropping the check. A whole-prefix comparison works the same under both URL implementations. 2. Wrap parsing in `try/catch` so a malformed link is rejected rather than crashing the handler. 3. Prefer tokens without `=` padding (base64url without padding), since a padded value is cut at the second `=`. In an Expo app, `Linking.parse()` from `expo-linking` returns `hostname`, `path` and `queryParams` for custom schemes, backed by that compliant `URL`. ## Step 3: validate each parameter for its own purpose - **`token`**: check its shape locally (length and character set) only to reject junk early. Its validity is the **server's** decision: it must be single-use, short-lived and bound to the account that requested it. The app never treats the presence of a token as being signed in. - **`next`**: a post-login destination is a classic open-redirect vector. Treat it as a key into an allowlist of in-app routes (`/settings`, `/orders`) and fall back to Home for anything else. Never pass it to `Linking.openURL`, a WebView `source`, or `fetch`. - **IDs** (for example `orderId`): check the format, then let the server authorise access; a link cannot grant permission to someone else's order. ## Step 4: no side effects on arrival A link can be triggered without the user's intent: a web page can redirect to it, another app can fire it. So a link should **navigate**, not **act**. Deleting data, confirming a payment or changing an email address should land on a screen where the user confirms. Magic-link sign-in is acceptable because the server enforces single use and binding, but it should still show who is being signed in. ## Step 5: handle it once and do not leak it - Remember which URL was handled, since `getInitialURL()` returns the launch URL again after a reload. - Do not log full link URLs; they contain bearer tokens. - Clear the token from any in-memory state once it has been exchanged. ## Summary checklist 1. Allowlist scheme, host and path as a whole. 2. Parse inside `try/catch`, aware of the polyfill's limits. 3. Shape-check the token; let the server validate it. 4. Map `next` to an allowlisted route. 5. Require confirmation for side effects. 6. Handle once; never log tokens.

  • Why is a next=https://… parameter dangerous in a React Native magic link?
    If the app passes it to `Linking.openURL` or loads it in a WebView after sign-in, an attacker can send a real sign-in link that lands the user on a look-alike page, which then asks for credentials in a trusted-looking flow. Mapping `next` onto a fixed set of in-app routes removes that path entirely.
  • Is a domain-verified https link safe to trust where a custom scheme is not?
    Verification proves which app receives the link, not who wrote it. Anyone can still type or generate `https://app.example.com/auth/magic?token=garbage&next=...`. Verification reduces interception of links meant for you, but the handler must validate parameters exactly as it would for a custom scheme.

saying these in an interview costs you the question

  • A token in the link means the user is already authenticated
  • Checking url.includes('example.com') is enough to trust the host
  • In a bare React Native app, URL.hostname is reliable for custom-scheme links
  • A domain-verified https link can be trusted without validation
  • Passing next straight to Linking.openURL after login is fine