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?
answer
- anyone can craft this URL
- allowlist scheme, host and path
- bare RN's URL parses only http(s) hosts
- token is a claim, the server decides
- next is an in-app route, never a URL
basics
~10 sTreat 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 sAny 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 linesconst 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
Recall that deep-link URLs can come from anywhere, so the app must check their shape and not act on them blindly.
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.
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.
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