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
2. Deep Link Phishing
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;
}
5. Link Token Enumeration
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
1. Prefer Universal Links / App Links
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)
}
3. Use Signed Deep Links for Sensitive Operations
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
4. Expire Deep Link Tokens
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 |
5. Rate Limit Deep Link Resolution
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);
6. Log Deep Link Access
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.