OAuth 2.0 Client Credentials Misuse in Public Apps

OAuth 2.0 Client Credentials Misuse in Public Apps

OAuth 2.0 has become the cornerstone of modern authorization, powering secure API access for countless applications. Yet not every OAuth flow fits every scenario. While some flows are designed for backend, server-to-server communications, others are intended for public clients. In this piece, we explore why using the OAuth client credentials flow - which is built for confidential, server-side apps - in mobile, native, and thick client applications is inherently unsafe. We break down the technical reasons behind these risks and illustrate how attackers might exploit them.


OAuth 2.0 terminology states that “public clients are unable to use registered client secrets, such as applications running in a browser or on a mobile device” oauth.net. Google similarly notes that installed apps “cannot keep secrets” and must use flows that don’t rely on confidential credentials​ developers.google.com. Auth0 and industry guides reinforce this: public apps must avoid any grant that requires a secret. In fact, OAuth flow guidance recommends that mobile/native apps use the Authorization Code flow with PKCE, and explicitly warns that Client Credentials is an improper grant type for public clients.

Lazy Summary

🔓
Exposed credentials:
Using the Client Credentials grant in Single Page Applications (SPAs), mobile apps, or desktop applications is inherently insecure. These are public clients, meaning they cannot securely store secrets like client_id and client_secret. Client secrets embedded in such apps can be trivially extracted through tools like strings, decompilers, or simple filesystem inspection. Once extracted, attackers can use the stolen credentials to obtain valid access tokens via the Client Credentials flow, impersonate the application, and access protected backend APIs.
📒
Mitigation Musts:

1. Never use the Client Credentials grant in public clients
The Client Credentials flow is designed for confidential, server‐side applications that can securely store a secret. In SPAs, mobile, or desktop apps, the client_secret is exposed and easily extracted, letting attackers impersonate the app and access protected APIs.

2. Use the Authorization Code Flow with PKCE in SPAs, mobile, and desktop apps
For apps installed on user devices, implement the Authorization Code Flow with Proof Key for Code Exchange (PKCE). This removes the need for a client secret and ties the authorization request to the client instance securely.


Let’s start by answering the basics:

What is OAuth 2.0?

OAuth 2.0 is an authorization framework standardized by the IETF (RFC 6749) that allows applications to securely access resources on behalf of a user without needing to expose their credentials. Instead, it uses short-lived access tokens and delegated permissions (scopes) to grant granular access. While often conflated with authentication, OAuth 2.0 is strictly an authorization protocol - OpenID Connect (OIDC) layers authentication on top of it.

How does OAuth 2.0 work?
OAuth 2.0 is an industry-standard framework for delegated authorization that enables one application to obtain scoped, time-limited access to a user’s protected resources without ever handling the user’s own credentials. 

It does this by introducing four distinct roles:

  1. Resource owner (the user)
  2. Client application requesting access,
  3. Authorization server (which authenticates the user and issues tokens),
  4. Resource server (which hosts the protected data and enforces token validation).

Rather than sharing the resource owner's username and password, the client obtains an access token, a string that encodes permissions (scopes), lifetime, and other attributes after the user grants consent. The authorization server issues this token (and optionally a refresh token) upon successful authentication and authorization, and the client then presents the access token in API requests to the resource server, which verifies the token’s validity and scope before returning the requested data.

For example, consider a third-party calendar-aggregation application (the client) that needs to read a user’s events from their Google Calendar (the resource server) without ever seeing the user’s Google credentials. The user (the resource owner) is redirected to Google’s authorization server, where they authenticate and consent to “Calendar.Read” scope. Upon approval, Google issues the calendar app an access token bound to that scope and with a fixed lifetime, rather than the user’s password. The calendar app then presents this bearer token in its API requests (e.g., Authorization: Bearer {ACCESS_TOKEN}), and Google’s Calendar API validates the token before returning the user’s event data. This flow cleanly separates the user’s credentials from the client, confines the client to the explicitly granted permissions, and allows the user to revoke access by invalidating the token at any time.


Now that we’ve seen OAuth 2.0 in action, let’s break down the four roles it defines.

OAuth 2.0 Roles:

1) Resource Owner
is the entity, typically an end-user, that owns the protected resources and can grant or deny access to them. In practice, this is often a person who consents to share specific data hosted on a service.

2) Client is the application (or service) requesting access to the resource owner’s protected data. Clients can be confidential (e.g., server-based apps holding a secret) or public (e.g., single-page or mobile apps) and must be registered with the authorization server before making requests.

3) Authorization Server authenticates the resource owner, obtains their consent for the requested scopes, and issues access tokens (and optionally refresh tokens) to the client. It exposes two primary endpoints:

  • Authorization Endpoint for obtaining authorization grants.
  • Token Endpoint for exchanging grants or credentials for tokens.

4) Resource Server hosts the actual protected APIs or data. It accepts and validates access tokens either by local JWT verification or by token introspection with the authorization server and enforces scope restrictions before delivering resources to the client.

Figure 1: Abstract Protocol Flow

With the four roles clear, we can examine the different ways a client can obtain an authorization grant.

Authorization Grant Types
OAuth 2.0 defines four primary authorization grant types, each specifying a different protocol interaction for how a client obtains an authorization grant (or token) from the authorization server. Selecting the right grant type depends on the client’s trust profile (confidential vs. public), the level of user interaction, and the deployment environment. RFC 6749 Section 1.3.1 categorizes the four standard grants as:

  • Authorization Code
  • Implicit
  • Resource Owner Password Credentials 
  • Client Credentials

Below is a detailed look at each grant type:

1) Authorization Code (with or without PKCE)
⤷️ Used by: Web applications and mobile apps (with PKCE).
Flow: The user is redirected to the authorization server to authenticate and approve access. The server returns an authorization code, which the client exchanges for an access token. This flow keeps the access token off the browser and supports secure token exchange, especially with PKCE for public clients.

2) Implicit (Deprecated)
⤷️ Used by: Browser-based Single Page Applications (SPAs).
Flow: The client receives the access token directly in the redirect URL after user authorization - no code exchange.Faster but less secure, as tokens are exposed to the browser. It’s now deprecated in favor of the Authorization Code with PKCE.

3) Resource Owner Password Credentials (ROPC)
⤷️ Used by: Highly trusted first-party apps.
Flow: User provides their username and password directly to the client. The client uses these credentials to obtain an access token. This flow is risky and generally discouraged, only recommended for trusted, first-party apps.

4) Client Credentials
⤷️Used by: Machine-to-machine (M2M) authentication, where no user is involved.
Flow: The client authenticates with its own credentials and receives an access token to access resources it controls or has been pre-approved for. No user involvement; access is scoped to the client itself.

Pro Tip: Choose Authorization Code (with PKCE) for nearly all modern web and mobile apps; fall back to Client Credentials for backend services. Implicit and Password grants are discouraged except in legacy or highly trusted environments.


Next, we’ll examine how OAuth 2.0 distinguishes between different client types, since this classification is critical for selecting the appropriate grant.

Client Types

OAuth 2.0 makes a clear distinction between clients that can keep their credentials safe and those that can’t. This classification directly influences which grant types are appropriate and secure for each client. Misunderstanding or ignoring these distinctions often leads to serious security flaws, especially in mobile apps and single-page applications.

⤷️ Confidential Clients
These are clients that can safely store their credentials without exposing them to users or attackers. Typically, this means the client runs on a backend server or another secure environment that restricts access to sensitive data. Examples include web applications with server-side components or services running in protected infrastructure.

⤷️ Public Clients
Public clients, on the other hand, can't securely store secrets. This includes applications that run on end-user devices, such as mobile apps or single-page applications (SPAs) in the browser. Because these environments are inherently exposed to the user, they can't protect client secrets or perform secure client authentication.


OAuth 2.0 Client Credentials Grant (RFC 6749) Step-by-Step

The Client Credentials grant is designed for confidential applications (server-side, machine-to-machine) that act on their own behalf. In this flow, the client uses its own credentials (client ID and secret) as the “authorization grant” to obtain an access token for resources under its control​. 

The diagram below illustrates the OAuth 2.0 Client Credentials grant (machine-to-machine). The confidential client authenticates using its own credentials to obtain an access token​.

In step 1, the client sends a token request to the authorization server (typically using HTTP Basic with the client’s ID and secret).

In step 2, the server validates the client and issues a bearer access token​.

In step 3, the client calls the protected API using the access token in the Authorization: Bearer <token> header.

The detailed flow is as follows:

1. Client registration:
Register the service (confidential client) with the authorization server. Obtain a client_id and client_secret. Store these credentials securely on the server – they must not be embedded in a public app.
2. Token request:
The client requests a token by making an HTTPS POST to the token endpoint with grant_type=client_credentials. For example, using HTTP Basic auth with the client ID and secret, and a form field for the grant:

POST /oauth/token HTTP/1.1
Host: auth.example.com
Authorization: Basic <base64(client_id:client_secret)>
Content-Type: application/x-www-form-urlencoded

grant_type=client_credentials&scope=api.read

The client may include a scope parameter to request specific permissions (optional, and limited to what was pre-authorized)​.
3. Server validation:
The authorization server authenticates the client using the provided credentials. If the credentials are valid and the scope (if any) is allowed, the AS issues an access token (typically a JWT or opaque token) to the client​. (No user is involved, so there is no refresh token by default in this flow – the client simply repeats this step when it needs a new token.)
4. Use token:
The client calls its own protected API by including the access token in an Authorization: Bearer header. The resource server verifies the token (for example, by checking its signature or introspecting it) and, if valid, processes the request. When the access token expires, the client can request a new one by repeating steps 2–4.


When OAuth Goes Wrong: Misusing the Client Credentials Grant

While OAuth 2.0 provides flexible flows tailored to a wide range of applications, it also introduces security trade-offs depending on how each grant type is implemented. One such grant Client Credentials, is intended strictly for confidential clients like backend services that can securely store secrets. However, developers sometimes mistakenly use this flow in public-facing applications such as mobile apps, SPAs, or desktop applications.

Implementing Client Credentials Grant in Public Apps

OAuth 2.0’s Client Credentials grant is strictly intended for machine-to-machine (confidential client) scenarios where the client can keep a secret hidden. Using it in mobile apps, SPAs, or desktop apps is dangerous because those apps cannot protect secrets. In fact, Google’s documentation explicitly notes that “installed apps cannot keep secrets”​. Similarly, OWASP’s testing guide classifies the client credentials flow as an “improper grant type” for public clients (like mobile apps)​. 

In practice, embedding or handling client secrets on-device almost guarantees leakage, allowing attackers to impersonate the app and request tokens or call APIs at will. Let’s look at three concrete examples, starting with Single-Page Applications, where secrets are trivially exposed.

Example 1: Single-Page Application (SPA)

Single-page applications (SPA), also known as browser-based applications, run entirely in the browser after loading the JavaScript and HTML source code from a web page. Since the entire source is available to the browser, they cannot maintain the confidentiality of a client secret.

const clientId     = "mySPAclient";
const clientSecret = "s3cr3tHere";  // Visible to everyone
fetch("https://auth.example.com/oauth/token", {
  method: "POST",
  headers: {
    "Authorization": "Basic " + btoa(`${clientId}:${clientSecret}`)
  },
  body: "grant_type=client_credentials"
});

Example 2: Mobile Apps

Like single-page apps, mobile apps also cannot maintain the confidentiality of a client secret. Because of this, mobile apps must also use an OAuth flow that does not require a client secret.

val json = JSONObject().apply {
    put("grant_type", "client_credentials")
    put("client_id", "your_client_id")
    put("client_secret", "your_client_secret")
    put("scope", "read write")
}

val body = RequestBody.create(
    MediaType.parse("application/json; charset=utf-8"),
    json.toString()
)

val request = Request.Builder()
    .url("https://your-auth-server.com/oauth/token")
    .post(body)
    .build()

OkHttpClient().newCall(request).enqueue(object : Callback {
    override fun onResponse(call: Call, response: Response) {
        val responseBody = response.body?.string()
        val token = JSONObject(responseBody ?: "").optString("access_token")
        println("Access Token: $token")
    }

    override fun onFailure(call: Call, e: IOException) {
        println("Token request failed: ${e.message}")
    }
})

Example 3: Desktop Apps

Desktop applications should be considered as public (non-confidential) clients. During installation, all the application’s binaries and files are copied into the local file system. Since they can be easily decompiled and inspected by anyone having access to the file system, applications should not contain any secrets. In this context, desktop application is similar to SPA web application, where JavaScript code can be entirely inspected.

import java.io.*;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
import javax.net.ssl.HttpsURLConnection;

public class ClientCredentialsGrant {
    public static void main(String[] args) {
        try {
            // Replace with your token endpoint
            URL url = new URL("https://your-auth-server.com/oauth/token");
            HttpsURLConnection con = (HttpsURLConnection) url.openConnection();
            con.setRequestMethod("POST");
            con.setDoOutput(true);

            // Set content type
            con.setRequestProperty("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8");

            // Set Basic Authorization header
            String clientId = "your_client_id";
            String clientSecret = "your_client_secret";
            String credentials = clientId + ":" + clientSecret;
            String encoded = Base64.getEncoder().encodeToString(credentials.getBytes(StandardCharsets.UTF_8));
            con.setRequestProperty("Authorization", "Basic " + encoded);

            // Request body
            String scope = "read write";
            String payload = "grant_type=client_credentials&scope=" + URLEncoder.encode(scope, "UTF-8");

            // Send the payload
            try (OutputStream os = con.getOutputStream()) {
                os.write(payload.getBytes(StandardCharsets.UTF_8));
            }

            // Read the response
            int responseCode = con.getResponseCode();
            InputStream inputStream = (responseCode == 200)
                ? con.getInputStream()
                : con.getErrorStream();

            try (BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream, StandardCharsets.UTF_8))) {
                StringBuilder response = new StringBuilder();
                String line;
                while ((line = reader.readLine()) != null) {
                    response.append(line.trim());
                }
                System.out.println("Response: " + response);
            }

        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

Exploiting Client Credentials grant in Public Applications

After reviewing how the Client Credentials grant is misused across SPAs, mobile apps, and desktop clients, let’s walk through a step-by-step attack scenario in a mobile application to demonstrate just how easily this mistake can be exploited in the real world.

Step 1: Extracting Secrets from the Mobile Application
The attacker begins by downloading the target mobile app’s APK (Android) or IPA (iOS) file from a public app store or other distribution channels. This binary contains all the code and resources the app needs to run, including potentially embedded secrets.
Attackers can extract these secrets through a combination of static analysis, dynamic analysis, and network interception:

🔍 Reverse Engineering: Attackers use decompilation tools such as Jadx, APKTool, or MobSF to reverse engineer the mobile app binary. These tools enable inspection of the app's Java/Kotlin source code or smali bytecode, as well as its resources and configuration files.

For example, attackers often find hardcoded OAuth client credentials like:

val clientId = "mobileAppClient"
val clientSecret = "m0b1l3S3cr3t"

Even if obfuscated, such static strings can typically be recovered with enough effort. Obfuscation only slows down an attacker - it does not provide real security.

With these credentials, the attacker can impersonate the application and obtain access tokens from the authorization server, potentially accessing protected APIs or sensitive data.

📁 Configuration Files: Developers sometimes mistakenly place secrets in easily accessible configuration files, such as:

  • Android: AndroidManifest.xml, res/values/strings.xml, or files inside the assets/ directory
  • iOS: Info.plist or plaintext .plist files in the app bundle

    Since APK and IPA files are just ZIP archives, these files can be extracted and read in seconds.
    Example:
<!-- AndroidManifest.xml -->
<meta-data
    android:name="com.example.CLIENT_SECRET"
    android:value="m0b1l3S3cr3t" />
<!-- Info.plist -->
<key>ClientSecret</key>
<string>m0b1l3S3cr3t</string>

🧪 Dynamic Analysis: On rooted (Android) or jailbroken (iOS) devices, attackers can use Frida, Objection, or Xposed to hook into the app’s runtime behavior.
This allows them to:

  • Intercept function calls where client secrets are loaded
  • Extract OAuth tokens from memory
  • Bypass obfuscation and encryption layers dynamically

This is especially effective if the app tries to load secrets at runtime from local files or shared preferences.

🌐 Network Interception: If the app does not employ robust certificate pinning, attackers can perform Man-in-the-Middle (MitM) attacks using tools like Burp Suite.
By intercepting HTTPS traffic, attackers can observe:

  • Client ID and client secret being used in token requests
  • OAuth token exchanges and associated scopes
  • API calls made with access tokens
POST /oauth/token HTTP/1.1
Host: your-auth-server.com
Content-Type: application/json
Accept: application/json
User-Agent: okhttp/4.9.0

{
  "grant_type": "client_credentials",
  "client_id": "mobileAppClient",
  "client_secret": "m0b1l3S3cr3t",
  "scope": "read write"
}

📥 Insecure Token Storage: Once tokens are obtained, many apps store them insecurely in plaintext or unencrypted storage areas such as Android’s SharedPreferences or iOS’s NSUserDefaults, exposing them to extraction on compromised devices.

Step 2: Obtaining an Access Token
With the stolen credentials, the attacker now mimics the app’s behavior by making a direct POST request to the OAuth token endpoint:

curl -X POST https://your-auth-server.com/oauth/token \
  -H "Content-Type: application/json" \
  -d '{"grant_type":"client_credentials","client_id":"mobileAppClient","client_secret":"m0b1l3S3cr3t","scope":"read write"}'

This request results in a valid access token for the app’s service account, often with elevated privileges.

Note: Unlike user-based flows, this token represents the application itself, not an end-user. The server now trusts the attacker as if they were the mobile app.

Step 3: Lateral Movement & Privilege Abuse
Because the token is issued via the Client Credentials grant, it is not associated with any user - it represents the application's identity. In many systems, these tokens carry elevated privileges to perform backend operations like:

  • Accessing sensitive data stores (e.g., user profiles, billing info)
  • Invoking internal APIs not meant for public use
  • Modifying configuration or deleting resources

Unless strict scoping and rate-limiting are enforced on the token, the attacker can exfiltrate data, or disrupt services. Worse still, revoking this kind of access usually requires administrative intervention such as rotating secrets and invalidating issued tokens across services making incident response more difficult.

Here’s something we saw in the wild!!!

During a security assessment of a mobile app’s “Forgot Password” feature, we expected a generic message, “If the email is registered, you’ll receive a reset link”. Instead, the API response included the OAuth credentials - specifically, the client_id and client_secret of the user account. Critically, this information was returned for every valid email address submitted to the endpoint.

This behavior effectively leaks valid client credentials tied to user accounts. An attacker could automate password reset requests across a list of known or guessed email addresses, harvesting the client_id and client_secret for each.
Exposing these credentials in a public endpoint immediately hands attackers the keys to bypass authentication mechanisms and take over user accounts.


Congrats, you've come all the way to the:
Exploitation Example

During a security engagement, we assessed a desktop application that used the OAuth 2.0 Client Credentials grant type. The application was a Human Resources, used by organizations to manage employee records, payroll processing, and other internal workflows. Because the application ran on client machines (Windows and macOS) and stored configuration files locally, we suspected that its OAuth secrets might be exposed.

Specifically, we wanted to determine whether an attacker could extract the client_id and client_secret from the installed software and then use those credentials to obtain tokens that granted access to protected APIs, potentially allowing manipulation of user data, configuration settings, or tenant-wide administrative functions.

Step 1: Locating Embedded Credentials
Upon installing the application on a Windows host, we examined the default installation path:

C:\Program Files (x86)\Example Services\TenantUserService\

On macOS, the equivalent path was:

/Library/Application Support/Example Services/services/TenantUserService/

We found in the appsettings.json file sensitive information in plaintext, including:

  • client_id
  • client_secret
  • authorization_url
  • audience

These values were embedded in a JSON configuration file accessible to all local users.

Step 2: Requesting an Access Token
We crafted a POST request to the OAuth token endpoint with the extracted client credentials and an audience targeting the Auth0 Management API (https://acne.eu.auth0.com/api/v2/), which provides administrative functions like creating users, reading identity provider tokens, and managing grants.

Request:

POST /oauth/token HTTP/1.1
Host: example.auth0.com
Content-Type: application/json; charset=utf-8
Content-Length: 218
Connection: close
Cache-Control: no-transform
Expect: 100-continue

{
  "grant_type": "client_credentials",
  "client_id": "[REDACTED]",
  "client_secret": "[REDACTED]",
  "audience": "https://example.auth0.com/api/v2/"
}

Response:

HTTP/1.1 200 OK
Date: Mon, 1 Aug 2023 07:38:31 GMT
Content-Type: application/json
Content-Length: 798
Connection: close

{
  "access_token": "eyJhbGciOiJ[REDACTED]",
  "scope": "create:client_grants read:users update:users delete:users create:users read:users_app_metadata update:users_app_metadata create:users_app_metadata read:user_idp_tokens",
  "expires_in": 86400,
  "token_type": "Bearer"
}

The returned token carried administrative scopes, granting the ability to manipulate Auth0 tenant resources, read or modify user profiles, create and delete users, and more.

Step 3: Validating Elevated Token Privileges
With the elevated token in hand, we verified that it indeed provided full administrative access by invoking a protected Auth0 Management API endpoint:

Request: List all users

GET /api/v2/users?page=0&per_page=50 HTTP/1.1
Host: example.auth0.com
Authorization: Bearer eyJhbGciOiJ[REDACTED]

Response:

HTTP/1.1 200 OK
Date: Mon, 1 Aug 2023 08:15:31 GMT
Content-Type: application/json
Content-Length: 1024
Connection: keep-alive

[
  {
    "user_id": "605b72a6e7a3e80015d7a2b9",
    "email": "alice@example.com",
    "name": "Alice Johnson",
    …
  },
  {
    "user_id": "605b72a6e7a3e80015d7a2c0f",
    "email": "bob@example.com",
    "name": "Bob Smith",
    …
  }
 …omitted for brevity…
]

This confirmed the token’s effective scope: we could enumerate all users, read their metadata, and perform management operations without any additional authentication or MFA checks.

Impact:


By embedding the client_id and client_secret within local configuration files, the application exposes these credentials to any user or malicious process with file system access. Once obtained, these secrets allow attackers to request access tokens directly from the authorization server - tokens that may carry elevated scopes such as administrative access to sensitive APIs. 

This grants unauthorized actors the ability to perform critical actions like user management, data manipulation, or tenant-wide configuration changes without any end-user authentication or oversight. As a result, the misuse of this grant type in a desktop environment undermines the core security assumptions of OAuth 2.0 and can lead to full system compromise, data breaches, and regulatory non-compliance.

Remediation:


To mitigate the risks associated with using the Client Credentials grant in public applications, follow these best practices:

Do not use Client Credentials grant in Public Apps

Client Credentials are meant for confidential server-side applications. Public clients like mobile apps, SPAs, and desktop applications cannot keep secrets safe and should never use this grant type. Use Authorization Code Flow with PKCE for mobile, desktop, and browser-based apps. 

PKCE enhances the standard Authorization Code Flow by introducing a dynamically generated code verifier and code challenge. This mechanism binds the authorization request to the client that initiated it, mitigating risks such as authorization code interception and injection attacks. Importantly, PKCE eliminates the need for a client secret in public clients, like SPAs, mobile, and desktop apps, that cannot securely store secrets. Even if an attacker intercepts the authorization code, they cannot exchange it for tokens without the original code verifier, which is never transmitted over the network.


And, the final thoughts are…


Using the Client Credentials grant in a public client is like handing out a house key in public and expecting no one to use it. Public applications, whether SPAs, mobile, or desktop, cannot keep secrets. Once a secret is exposed, anyone can impersonate the app and act on its behalf.

Always, and always, treat public clients as untrusted environments.
If your app cannot protect secrets, it must use flows designed for public clients, such as Authorization Code Flow with PKCE,, which do not rely on storing secrets at all.

Where to Go Next:

  • Stay updated on security best practices by following the Sentry Blog for insights on application monitoring and security.
  • Ready to boost application security?

Application Penetration Testing

With over 1,400 successful security assessments, our industry-recognized experts are at the forefront of application security. Our team is certified, extensively trained, and ready to help you achieve your security goals.

Book your free 30-Minute Consultation