Cybersecurity · Authentication
Mobile OAuth: Why a Custom URI Scheme Isn't Safe
PKCE stops a stolen OAuth code from being redeemed. It doesn't stop the wrong app from getting your redirect first. Here's the fix most mobile apps skip.
Aman Chaudhary
5 min read
Sponsored
An Android app’s OAuth redirect can be hijacked by a second app installed on the same phone. Not through some exotic exploit. Just a myapp://callback custom URI scheme, which any other app on the device can claim by registering the same scheme. PKCE was already implemented correctly. It didn’t matter, not for this specific hole.
Quick answer: custom URI scheme redirects (myapp://callback) can be registered by more than one app on the same device, which means your OAuth redirect can land in the wrong app. PKCE limits what that app can do with an intercepted code, but the actual fix is a claimed HTTPS redirect, Universal Links on iOS or App Links on Android, verified against a domain you control.
If you already know what PKCE is and just want the mobile-specific redirect fix, skip to “the actual fix” below. If you want the full picture of what OAuth 2.1 changed and whether PKCE mechanics matter for your stack, we’ve covered that ground already: what changed from OAuth 2.0 to 2.1 and how PKCE itself works for SPAs. This post picks up where those leave off, specifically for native mobile apps.
The gap PKCE doesn’t close
PKCE is designed to solve authorization code interception: an attacker who captures your code in transit shouldn’t be able to redeem it, because redemption requires a code_verifier that never left your app. That part works as advertised.
What PKCE doesn’t touch is a more basic question: which app receives the redirect in the first place? On both iOS and Android, a custom URI scheme is just a string in a manifest file. Nothing prevents a second, unrelated app from registering myapp:// too. If it does, and it’s installed on the same device, the OS’s routing decision for your OAuth callback becomes ambiguous, and in the worst case, the wrong app gets the redirect, authorization code and all.
PKCE means that app can’t exchange the code for tokens. But it did receive a callback meant for you, and depending on your identity provider’s callback payload, that can leak more than just an unusable code, state parameters, user identifiers, or error details that shouldn’t reach an app you don’t control. “The attacker’s app got a code it can’t redeem” is a much better outcome than a full token theft. It’s still not the same as “the redirect went where it was supposed to go.”
The actual fix: claim the redirect with a domain
Universal Links on iOS and App Links on Android exist specifically to close this gap. Instead of an arbitrary scheme string, your redirect URI is a real HTTPS URL on a domain you own, like https://yourapp.com/callback. The OS verifies your claim to that URL by fetching a file you host at a well-known path:
- iOS:
apple-app-site-associationat your domain’s.well-known/path, referencing your app’s Team ID and bundle identifier - Android: a Digital Asset Links JSON file at
/.well-known/assetlinks.json, referencing your app’s package name and signing certificate fingerprint
Because that file lives on a domain only you control, no other app can forge the claim. A second app can still request the same custom scheme string, but it cannot host a verification file on your domain. That’s the actual difference between “first app to register a string wins” and “cryptographically provable ownership.”
The tradeoff is setup cost: you need a real domain, HTTPS, and a correctly formatted verification file, versus adding a string to a manifest. For a custom scheme in local development, that overhead isn’t worth it. For a production app handling real user tokens, it is, every time.
The second mistake: doing this in a WebView
While auditing the redirect scheme, check whether the login itself happens in your app’s WebView instead of a system browser component. It’s a related mistake with a similar root cause: your app controlling something the OS is supposed to arbitrate.
RFC 8252 recommends against WebView-based OAuth directly, for two concrete reasons. A WebView doesn’t share session state with the device’s actual browser, so a user already logged into your identity provider elsewhere gets prompted to log in again, which is worse UX for no security benefit. And because your app’s own code hosts the WebView, it could in principle inspect what the user types into it, including their password. Users, and increasingly app store reviewers, treat that as a trust problem rather than a convenience.
Use SFSafariViewController or ASWebAuthenticationSession on iOS, Chrome Custom Tabs on Android. The AppAuth libraries (AppAuth-iOS, AppAuth-Android) default to the correct browser component and won’t quietly fall back to a WebView, which makes them the easiest path to getting this right without hand-rolling it.
What to check this week
Pull up your app’s redirect URI configuration. If it’s a bare custom scheme with no domain verification behind it, that’s the finding. Confirm whether your identity provider’s dashboard shows a claimed HTTPS redirect or a custom scheme, and if it’s the latter in production, that’s the fix to schedule before anything else on this list. This is also a good screening question for a mobile developer hire: ask a candidate to explain why a custom URI scheme redirect is a security gap, not just whether they’ve used AppAuth. Anyone who’s actually hit this in production will have an answer ready.
Frequently asked questions
- Is a custom URI scheme like myapp:// unsafe for OAuth redirects?
- It's unsafe as a production default, yes. Neither iOS nor Android reserves custom URI schemes to a single app, so a second app that registers the identical scheme can receive your OAuth redirect instead of you. It's fine for local development and testing, where the risk is low and the convenience is high. Production apps should use Universal Links or App Links instead.
- Doesn't PKCE already protect against this?
- PKCE protects the token exchange, not the redirect itself. If a malicious app intercepts your authorization code via a hijacked custom scheme, PKCE stops that app from redeeming the code, because it doesn't have your app's code_verifier. But the interception still happened. Depending on what else that code exposes (some identity providers embed extra data in the callback), 'the attacker's app got a code it can't use' is a better outcome than a full account takeover, but it's not the same as 'the attacker never got the redirect.'
- What's the difference between Universal Links, App Links, and a custom URI scheme?
- A custom URI scheme (myapp://) is just a string any app can register, unverified. Universal Links (iOS) and App Links (Android) are HTTPS URLs tied to a real domain, and the OS only routes them to your app after verifying a file you host at that domain (apple-app-site-association for iOS, a Digital Asset Links JSON file for Android). That hosted file is what makes the claim provable instead of just first-come-first-served.
- Why shouldn't I just handle the OAuth login inside my app's WebView?
- Two reasons. A WebView doesn't share session cookies with the user's actual browser, so someone already logged into your identity provider elsewhere has to log in again from scratch. More importantly, your app's own code controls the WebView, so in principle it could read what the user types into it, including their password. RFC 8252 recommends against it directly, and app store reviewers increasingly treat WebView-based login as a trust red flag. Use SFSafariViewController or ASWebAuthenticationSession on iOS, Chrome Custom Tabs on Android.
- Do the AppAuth libraries handle this correctly out of the box?
- Yes, for the redirect and browser-component parts. AppAuth-iOS and AppAuth-Android implement RFC 8252's recommendations by default, including using the system browser component rather than a WebView. They don't automatically configure Universal Links or App Links for you, that still requires hosting the verification file on your domain, but they won't silently let you fall back to an insecure WebView flow either.
Sources
Sponsored
More from this category
More from Cybersecurity
Sponsored
Discussion
Join the conversation.
Comments are powered by GitHub Discussions. Sign in with your GitHub account to leave a comment.
Sponsored