Skip to content
Tolinku
Tolinku
Sign In Start Free
Deep Linking · · 6 min read

Deep Linking Security: Preventing Hijacking and Abuse

By Tolinku Staff
|
Tolinku deep linking fundamentals dashboard screenshot for deep linking blog posts

A deep link is a door into your app. If you do not secure it, attackers can exploit it. Custom URL scheme hijacking, deep link phishing, server-side request forgery via redirect URLs, and parameter injection are all real attack vectors. This guide covers the security risks and how to defend against them.

For privacy considerations, see deep linking and privacy: what you need to know. For general deep linking challenges, see common deep linking challenges and how to solve them.

Attack Vectors

1. Custom URL Scheme Hijacking

The attack: On iOS, any app can register any custom URL scheme. A malicious app registers bankapp:// and intercepts URLs meant for the real banking app. It could capture sensitive parameters like authentication tokens, transaction IDs, or user identifiers.

Risk level: High for apps using custom URL schemes with sensitive data.

Defense: Use Universal Links instead of custom URL schemes. Universal Links require domain verification via the AASA file, which proves that the app developer controls the domain. Only one app can be verified for a given domain.

// BAD: Custom scheme with sensitive data
// myapp://reset-password?token=abc123
// Any app registering "myapp://" could intercept this token

// GOOD: Universal Link with domain verification
// https://app.example.com/reset-password?token=abc123
// Only the verified app opens this URL

The attack: An attacker sends a user a legitimate-looking deep link that opens the real app but with malicious parameters. For example:

https://app.example.com/transfer?to=attacker-account&amount=1000

If the app blindly executes the transfer without confirmation, the user loses money.

Risk level: High for apps that perform actions from deep link parameters.

Defense: Never execute sensitive actions directly from deep link parameters. Always require user confirmation.

func handleDeepLink(_ url: URL) {
    guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { return }

    if components.path == "/transfer" {
        let to = components.queryItems?.first(where: { $0.name == "to" })?.value
        let amount = components.queryItems?.first(where: { $0.name == "amount" })?.value

        // DO NOT execute transfer directly
        // Instead, show a confirmation screen
        showTransferConfirmation(to: to, amount: amount)
    }
}

3. Parameter Injection

The attack: An attacker crafts a deep link with parameters designed to exploit the app's logic:

https://app.example.com/search?q=<script>alert('xss')</script>
https://app.example.com/profile?id=../../admin
https://app.example.com/redirect?url=https://evil.com

Risk level: Medium. Mobile apps are less susceptible to XSS than web apps, but WebViews within apps can be vulnerable.

Defense: Validate and sanitize all parameters from deep links.

function validateDeepLinkParams(params: URLSearchParams): boolean {
  // Whitelist allowed parameters
  const allowedParams = ['id', 'ref', 'campaign', 'q'];
  for (const [key] of params) {
    if (!allowedParams.includes(key)) {
      console.warn(`Unexpected parameter: ${key}`);
      return false;
    }
  }

  // Validate parameter formats
  const id = params.get('id');
  if (id && !/^[a-zA-Z0-9_-]+$/.test(id)) {
    console.warn(`Invalid ID format: ${id}`);
    return false;
  }

  // Block redirect URLs to external domains
  const redirectUrl = params.get('url');
  if (redirectUrl) {
    const url = new URL(redirectUrl);
    if (url.hostname !== 'app.example.com') {
      console.warn(`Blocked external redirect: ${redirectUrl}`);
      return false;
    }
  }

  return true;
}

4. Open Redirect

The attack: If your deep link handler redirects to a URL specified in a parameter, attackers can use your domain for phishing:

https://app.example.com/redirect?to=https://evil-site.com/fake-login

The victim sees app.example.com in the link, trusts it, and ends up on the attacker's phishing page.

Risk level: High. This is an OWASP Top 10 vulnerability.

Defense: Never redirect to user-supplied URLs. If you must redirect, validate against a whitelist of allowed domains.

const ALLOWED_REDIRECT_DOMAINS = [
  'app.example.com',
  'www.example.com',
  'docs.example.com'
];

function safeRedirect(targetUrl: string, fallback: string): string {
  try {
    const url = new URL(targetUrl);
    if (ALLOWED_REDIRECT_DOMAINS.includes(url.hostname)) {
      return targetUrl;
    }
  } catch {
    // Invalid URL
  }
  return fallback;
}

The attack: If your deep links use sequential or predictable tokens, attackers can enumerate them:

https://app.example.com/invite/001
https://app.example.com/invite/002
https://app.example.com/invite/003  (trying to find valid invite codes)

Risk level: Medium. Depends on what the tokens grant access to.

Defense: Use cryptographically random tokens.

import { randomBytes } from 'crypto';

function generateDeepLinkToken(): string {
  // 128-bit random token, URL-safe base64
  return randomBytes(16).toString('base64url');
  // Result: something like "dGhpcyBpcyBhIHRlc3Q"
}

Security Best Practices

Universal Links and App Links are inherently more secure than custom URL schemes because they require domain verification. Only the app that controls the domain (proven via AASA or assetlinks.json) can handle the URLs.

Feature Custom URL Scheme Universal Links / App Links
Ownership verification None Domain verification required
Hijacking possible Yes No (one verified app per domain)
HTTPS encryption No (custom scheme) Yes
Web fallback No Yes

2. Validate All Input

Treat every deep link URL as untrusted input, regardless of where it came from:

func handleDeepLink(_ url: URL) {
    // 1. Verify the scheme and host
    guard url.scheme == "https",
          url.host == "app.example.com" else {
        return
    }

    // 2. Parse and validate the path
    let pathComponents = url.pathComponents.filter { $0 != "/" }
    guard pathComponents.count >= 1,
          pathComponents.count <= 5 else {
        return // Reject abnormally long paths
    }

    // 3. Validate each parameter
    guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { return }
    for item in components.queryItems ?? [] {
        guard item.value?.count ?? 0 <= 256 else {
            return // Reject abnormally long values
        }
    }

    // 4. Route to the handler
    router.handle(url)
}

For deep links that grant access to sensitive content (password reset, email verification, payment confirmation), sign the URL with a secret:

import { createHmac } from 'crypto';

function signDeepLink(url: string, secret: string): string {
  const signature = createHmac('sha256', secret)
    .update(url)
    .digest('hex')
    .substring(0, 16);

  const separator = url.includes('?') ? '&' : '?';
  return `${url}${separator}sig=${signature}`;
}

function verifyDeepLink(url: string, secret: string): boolean {
  const urlObj = new URL(url);
  const sig = urlObj.searchParams.get('sig');
  if (!sig) return false;

  urlObj.searchParams.delete('sig');
  const expected = createHmac('sha256', secret)
    .update(urlObj.toString())
    .digest('hex')
    .substring(0, 16);

  return sig === expected;
}

// Generate: https://app.example.com/reset-password?token=abc123&sig=a1b2c3d4e5f6g7h8
// Verify on the app side before processing

Deep link tokens should have a limited lifetime:

interface DeepLinkToken {
  token: string;
  createdAt: Date;
  expiresAt: Date;
  used: boolean;
  purpose: 'password_reset' | 'email_verify' | 'invite';
}

function validateToken(token: DeepLinkToken): boolean {
  if (token.used) return false;              // Single use
  if (token.expiresAt < new Date()) return false;  // Expired
  return true;
}
Purpose Recommended Expiry
Password reset 1 hour
Email verification 24 hours
Invite link 7 days
Referral link 30 days
Content deep link No expiry needed

Protect your deep link server from enumeration and abuse:

import rateLimit from 'express-rate-limit';

const deepLinkLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,  // 15 minutes
  max: 100,                   // 100 requests per window
  standardHeaders: true,
  legacyHeaders: false,
  message: 'Too many requests'
});

app.use('/links', deepLinkLimiter);

Monitor for suspicious patterns:

function logDeepLinkAccess(req: Request, deepLink: DeepLink) {
  const logEntry = {
    timestamp: new Date().toISOString(),
    ip: req.ip,
    userAgent: req.headers['user-agent'],
    url: req.url,
    deepLinkId: deepLink.id,
    outcome: deepLink.resolved ? 'resolved' : 'failed'
  };

  // Detect suspicious patterns
  if (isRapidAccess(req.ip, deepLink.id)) {
    flagForReview(logEntry);
  }

  analytics.log(logEntry);
}

WebView Security

If your deep link opens content in a WebView (common for hybrid apps), additional precautions apply:

// iOS WKWebView security configuration
let config = WKWebViewConfiguration()

// Disable JavaScript if not needed
config.preferences.javaScriptEnabled = false

// Restrict navigation to your domain
func webView(_ webView: WKWebView,
             decidePolicyFor navigationAction: WKNavigationAction,
             decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
    guard let url = navigationAction.request.url,
          url.host == "app.example.com" else {
        decisionHandler(.cancel) // Block navigation to other domains
        return
    }
    decisionHandler(.allow)
}

Tolinku for Secure Deep Linking

Tolinku uses Universal Links and App Links exclusively (no custom URL schemes), providing domain-verified deep linking. See the Universal Links documentation for security details.

For privacy, see deep linking and privacy: what you need to know. For the full guide, see the complete guide to deep linking in 2026.

Get deep linking tips in your inbox

One email per week. No spam.

Ready to add deep linking to your app?

Set up Universal Links, App Links, deferred deep linking, and analytics in minutes. Free to start.