Index

Summary

Mobile OAuth scheme hijacking has been documented since 2017 and demonstrated practically since 2023. Every published writeup assumes the target Android application is a public OAuth client: a `client_id` is extractable, no `client_secret` exists, and the attack ends at code capture. PKCE is defeated because the attacker generates their own `code_verifier`, and the token exchange succeeds without further authentication.

A subset of Android applications do not fit this model. Their developers registered the OAuth client on Google Cloud Console as a "Web application" instead of "Android". The resulting client carries a `client_secret`, and Google's token endpoint requires that secret for the authorization code exchange. Apps in this configuration ship the secret in their APK, and the standard scheme hijack fails at exchange unless the attacker also extracts and presents the secret.

No published offensive research demonstrates this end to end. This post documents the missing half of the chain through a live case study on ColorNote (100M+ Play Store installs), shows the exchange succeeding with the secret it and explains why Google's own documentation contributes to the misconfiguration.

What the Existing Research Covers

Ruhr University Bochum identified in 2017 that PKCE does not protect against attacker initiated flows. Ostorlab demonstrated practical exploitation across Google, Facebook, Cognito, and Okta clients in 2023. Djini.ai expanded the catalog in 2026 with `prompt=none` silent hijacks and cross platform `client_id` confusion. All three of these works are exhaustive on the public client variant.

Their attack chain looks like this:

  1. Extract the `client_id` from the target's manifest or DEX
  2. Register an `intent-filter` for the target's OAuth redirect scheme
  3. Initiate an OAuth flow with the target's `client_id` and an attacker generated `code_verifier`
  4. User approves the consent screen (or no UI shows with `prompt=none` plus an active session)
  5. Authorization code arrives in the attacker's app
  6. Attacker POSTs the code with their own `code_verifier` to the token endpoint
  7. Server returns `access_token`, `refresh_token`, `id_token`

Step 6 is where the existing research ends in terms of published evidence. Ostorlab's writeup includes a screenshot of a captured authorization code labeled "leaked OAuth grant." Djini's case studies describe the exchange happening but do not show the resulting tokens or any subsequent identity exfiltration. The implicit assumption across all three is that step 6 succeeds because the target is a public client and only `client_id` plus `code_verifier` are required.

What Happens When the Exchange Fails

Run Ostorlab's attack against an arbitrary Google OAuth application and step 6 sometimes returns this:

HTTP/1.1 400 Bad Request
Content-Type: application/json

{
  "error": "unauthorized_client",
  "error_description": "Client authentication failed."
}

The captured authorization code is valid. The PKCE `code_verifier` matches the challenge. The `redirect_uri` matches what was sent at /authorize. Everything the public client model requires is satisfied. The token endpoint still rejects the request.

The reason: that specific OAuth client was registered as a confidential client, and the token endpoint will not exchange a code without the `client_secret`. The attacker has a working scheme hijack but no usable tokens.

The published research never addresses this case because it is invisible from the outside. If a security researcher tests Ostorlab's attack on a public client app, they see the full chain succeed. If they test it on a confidential client app and the exchange fails, they assume their PoC is broken, not that they have hit a different vulnerability class entirely.

The split happens at OAuth client registration time on Google Cloud Console. The developer sees an Application type dropdown with these options: Android, iOS, Web application, Chrome extension, Desktop app, TVs and limited input devices, Universal Windows Platform.

Field Android type Web application type
client_id issued Yes Yes
client_secret issued No Yes
Client verification Package name + SHA-1 signing cert client_secret on token exchange
Token exchange requires secret No Yes
Ostorlab attack works Yes (end to end) Fails at exchange
Suitable for mobile Yes No

The Web application type is intended for server side applications that can protect a secret. Selecting it for a mobile app produces a credential that the developer then has to embed somewhere accessible to the app at runtime. In practice that means the APK.

Developer tutorials still exist online that instruct readers to select "Web application" as the Application type when generating a `client_id` for an Android app. Some of these tutorials lead the reader through embedding the resulting `client_secret` in Android source code. The misconfiguration is not exotic; it is taught.

Extracting the Secret from the APK

ColorNote Notepad Notes (`com.socialnmobile.dictapps.notepad.color.note`, 100M+ Play Store installs) is one example. Static analysis of versions 4.8.2 and 4.8.4 surfaces these strings in DEX class `sm.X4.i.s()`:

client_id     = 908669027715.apps.googleusercontent.com
client_secret = SNZBS6...REDACTED  (24-char base64url, redacted pending rotation)
token_endpoint = https://accounts.google.com/o/oauth2/token
grant_type    = authorization_code

The presence of a `client_secret` in the DEX is the signal that this client was registered as a Web application type. Android client types do not issue a secret. There is no legitimate reason for an Android app to ship a string named `client_secret`.

The OAuth redirect is registered on an exported activity with a custom URI scheme and no App Links verification:

<activity
    android:name="...RedirectOauthReceiverActivity"
    android:exported="true">
    <intent-filter>
        <data android:scheme=
          "com.googleusercontent.apps.908669027715"/>
    </intent-filter>
</activity>

The custom scheme is claimable by any app on the device. The `client_id` is right next to it in the manifest. The `client_secret` is in the bytecode. The token endpoint is documented by Google. Every piece needed to complete the attack is in the APK, which is itself a public download.

The Exchange Succeeds

Attempting the standard public client exchange against ColorNote's token endpoint:

POST /o/oauth2/token HTTP/1.1
Host: accounts.google.com

grant_type=authorization_code
&code=4/0AeoWuM-7YG6P4...
&client_id=908669027715.apps.googleusercontent.com
&redirect_uri=com.googleusercontent.apps.908669027715:/
&code_verifier=GTEIZDk1XCN0rNYi...

----

HTTP/1.1 401 Unauthorized
{"error":"invalid_client","error_description":"client_secret is missing"}

Adding the extracted `client_secret`:

POST /o/oauth2/token HTTP/1.1
Host: accounts.google.com

grant_type=authorization_code
&code=4/0AeoWuM-7YG6P4...
&client_id=908669027715.apps.googleusercontent.com
&client_secret=SNZBS6...REDACTED
&redirect_uri=com.googleusercontent.apps.908669027715:/
&code_verifier=GTEIZDk1XCN0rNYi...

----

HTTP/1.1 200 OK
{
  "access_token":"ya29.a0AQvPyIPhO6kQtut7uIYgKAUTktnil...",
  "expires_in":3599,
  "refresh_token":"1//05EDY0o4lN2nqCgY1ARAAGAUSNwF...",
  "scope":"openid email profile",
  "token_type":"Bearer",
  "id_token":"eyJhbGciOiJSUzI1NiIs..."
}

Following the access_token to Google's userinfo endpoint returns the victim's name, email, profile picture, and account identifier. The refresh_token survives password resets and provides indefinite access until the victim explicitly revokes the application from their Google account settings, which most users never check.

The application itself does not need to be installed on the victim's device for any of this to work. The attacker's app initiates the flow with the extracted credentials. The user sees a real Google consent screen displaying ColorNote's name, taps Allow because the screen looks legitimate (because it is legitimate), and the attacker app catches the redirect via the claimed custom scheme.

Live Proof of Concept

The recording below captures the full chain end to end against a controlled test account. The attacker app initiates the OAuth flow with the extracted credentials, the Google consent screen renders ColorNote's branding and account picker, the redirect is intercepted via the claimed custom scheme, the leaked `client_secret` completes the exchange, and the resulting `access_token` resolves the victim identity from Google's `/userinfo` endpoint.

End-to-end OAuth ATO PoC against ColorNote: scheme hijack, leaked `client_secret`, token exchange, victim identity resolution.
Direct download (MP4)

Google's Contradictory Documentation

This misconfiguration does not exist in a vacuum. Google's own developer documentation contains direct contradictions on the question of whether mobile apps should ever hold OAuth secrets.

From Google's OAuth 2.0 overview page:

"The process results in a client ID and, in some cases, a client secret, which you embed in the source code of your application. (In this context, the client secret is obviously not treated as a secret.)" developers.google.com/identity/protocols/oauth2

From Google's OAuth Best Practices page:

"Only store these credentials in secure storage, for example using a secret manager such as Google Cloud Secret Manager. Do not hardcode the credentials, commit them to a code repository or publish them publicly." developers.google.com/identity/protocols/oauth2/resources/best-practices

From the Android Developer Blog:

"Note that the 'client secret' is really a secret that you should never reveal in your Android client." android-developers.googleblog.com

From Google's OAuth for Native Apps documentation:

"Installed apps are distributed to individual devices, and it is assumed that these apps cannot keep secrets." developers.google.com/identity/protocols/oauth2/native-app

All four pages are currently live as of this writing. A developer reading the first page is told to embed the secret in source code. A developer reading the second page is told never to hardcode credentials. A developer reading the third page is told never to reveal it in the Android client. A developer reading the fourth page is told installed apps cannot keep secrets.

ColorNote followed the first instruction. The Web application type is the dropdown option that produces the credential the first page tells developers to embed. The other three pages would have led the developer to either select the Android type (no secret) or to keep the secret server side.

Detecting Both Populations

Both populations are visible from static analysis of a downloaded APK. Detection requires three checks:

  1. Does the manifest declare an `intent-filter` with a Google custom URI scheme (`com.googleusercontent.apps.*`) on an exported activity without `autoVerify="true"`?
  2. Is the corresponding `client_id` extractable from the manifest scheme or DEX strings?
  3. Does the DEX bytecode contain a string near the OAuth token endpoint that matches the `client_secret` format (a 24 character base64url string adjacent to the token endpoint URL)?

Conditions 1 and 2 are sufficient for the standard public client attack (Ostorlab, Djini). Condition 3 separates the public client population (no secret in APK) from the Web client population (secret in APK). Both populations are exploitable but require different exchange parameters.

Disclosure to affected vendors completed before publication of this writeup.

September 30, 2026 and What It Does Not Fix

Google's Android Developer Verification rollout begins enforcement on September 30, 2026 in Brazil, Indonesia, Singapore, and Thailand. From that date, apps must be registered to a developer with a verified identity in order to be installed or updated on certified Android devices in those four markets. The `Android Developer Verifier` system service has been appearing on devices since April 2026; the "advanced flow" for sideloading unregistered apps launched globally in August 2026; global enforcement of the verification requirement is scheduled for 2027 and later.

This raises the cost of the local-app side of the attack but does not close it. Verification confirms developer identity, not app content (Google's own framing), so a verified-but-malicious developer can still publish a claimer app that registers the target's custom scheme. Sideloading via ADB or the advanced flow remains available for users who consent to the friction. Outside the four pilot markets, the verification requirement does not apply until 2027.

None of this affects credentials already shipped. APK mirror sites archive every version of every application. A `client_secret` extracted from a 2019 release of an app is still usable in 2026 unless the developer rotates it server side. App updates that remove the secret from new versions do not invalidate the secret on the OAuth provider. The vulnerability persists until the credential is rotated, regardless of how many app updates have shipped or which markets enforce developer verification.

The verification rollout also does not address the underlying client type misconfiguration. A developer can still register a new OAuth client of the wrong type, simply with App Links instead of a custom scheme as the redirect. The local scheme-hijack attack vector gains friction in the four pilot markets; the embedded secret attack vector remains open for any future leak that exposes the secret through other means (server side breach, version control commit, third party SDK).

Closing

Researchers approaching mobile OAuth hijacking from the public client angle have built a complete toolkit for that population. Researchers approaching credential leakage from the server side angle have characterized M2M misuse of the Client Credentials grant. The intersection, mobile applications running the Authorization Code flow under a confidential client registration, has fallen between both communities. The result is a population of apps with documented anti patterns, working attack chains, and no published exploit demonstrations until now.

The fix on the developer side is simple: select Android, not Web application, when registering an OAuth client for a mobile app. If an existing client was registered as a Web application and the secret has shipped in any APK version, rotate the secret immediately and move the token exchange to a server side proxy. Use App Links with `autoVerify` for the redirect. Treat any credential that has ever appeared in a downloadable binary as permanently compromised. Lastly there are many more apps including POPULAR social media apps with 100M+ downloads which are vulnerable to token theft via this method - we are just not calling everyone out in this blog.

References

Share this research:
Twitter/X · LinkedIn · Hacker News