The OAuth Code Apigee Let Me Choose

When external OAuth fields were absent, Apigee fell back to attacker-controlled form parameters and persisted chosen authorization codes or opaque tokens.

0Complexity

Research details

Product
Google Cloud Apigee
Affected versions
1-17-0-apigee-6
Patch status
Reported
Bounty
Not awarded
Reported
Apr 9, 2026
Disclosure timeline
  1. Reported attacker-controlled external OAuth artifact handling on runtime 1-17-0-apigee-6
  2. Submitted a confidential-client BFF proof of concept with PKCE
References

Introduction

An authorization server is supposed to choose its authorization codes. A client may carry a code through a redirect and exchange it later, but it does not get to decide what that code is. That rule is so basic that it usually disappears into the wallpaper of an OAuth review.

While tracing Apigee's in runtime 1-17-0-apigee-6, I found a path where a request could fill in that blank itself. Three form parameters were enough to reach it:

Reserved request parameters
external_auth_code
external_access_token
external_refresh_token

The names belong to a legitimate feature. Apigee can import authorization codes and tokens issued by another authorization system. The problem was the default path: even when a proxy did not configure any external artifact, the runtime still looked for those names in the incoming form. If it found one, it carried the supplied value into persistence instead of generating a new credential.

Authorization-code fixation was the cleanest result. I could choose a code, have Apigee bind it to a victim test identity, and later redeem the value I already knew. The same trust mistake reached opaque access and refresh tokens, producing fixation or rebinding depending on the OAuth operation.

The attack still required a usable authorization flow and the ability to lead a victim through it; this was not a magic token endpoint that compromised arbitrary users from one unauthenticated request. What made it interesting was that ordinary OAuth defenses continued to work. Client authentication and PKCE could both succeed while the authorization server stored an identifier chosen by the attacker.

A legitimate path for foreign credentials

Apigee supports deployments where another system performs part of the OAuth work. A proxy can call an external service, validate its response, place an externally issued artifact in a flow variable, and tell OAuthV2 where that variable lives:

Intended external-token configuration
<ExternalAuthorization>true</ExternalAuthorization>
<ExternalAccessToken>external.idp.access_token</ExternalAccessToken>
<ExternalRefreshToken>external.idp.refresh_token</ExternalRefreshToken>

Those settings do two related but different jobs. ExternalAuthorization changes how client credentials are validated. The artifact elements identify the flow variables from which Apigee should import an external code or token. If an artifact element is absent, there is no configured source to import from.

That was the expectation I carried into the decompiled runtime. The constructors immediately challenged it. The authorization-code step always created a resolver for an external code, and the access-token step did the same for external access and refresh tokens:

GenerateAuthorizationCodeExecution.java
this.externalAuthorizationCode = buildRef(
    stepDefinition.getExternalAuthorizationCode(),
    OAuthStepConstants.EXTERNAL_AUTH_CODE);
GenerateAccessTokenExecution.java
this.accessTokenRef = buildRef(
    stepDefinition.getExternalAccessToken(),
    OAuthStepConstants.EXTERNAL_ACCESS_TOKEN);
this.refreshTokenRef = buildRef(
    stepDefinition.getExternalRefreshToken(),
    OAuthStepConstants.EXTERNAL_REFRESH_TOKEN);

At first that looked like ordinary setup code. The surprise was hidden in buildRef():

AbstractOAuthStepExecution.java
public static RefOrValue buildRef(String variable, String constantName) {
    if (StringUtils.isNotEmpty(variable)) {
        return RefOrValue.forReference(variable);
    }
    return RefOrValue.forKeyFromMap(constantName);
}

An explicitly configured element became a reference to a flow variable. An absent element did not become null or an empty resolver. It became a lookup for a constant name in a map supplied later.

This was the small line that changed the meaning of the whole feature. “No external artifact was configured” had quietly become “look for a default external artifact somewhere else.” The next question was where that map came from.

The fallback landed in the request body

In the authorization-code path, Apigee collected the incoming query and form parameters, then resolved the external code against the form map:

GenerateAuthorizationCodeExecution.java
RequestParameters parameters = fetchQueryParamsAndFormParams(messageContext);
Map<String, String> form = parameters.getFormParameters();

OAuthClientContext clientContext = new OAuthClientContext();
clientContext.setExternalAuthorizationCode(
    this.externalAuthorizationCode.resolve(messageContext, form));

With an ExternalAuthorizationCode element present, the resolver read the configured flow variable. Without it, the resolver read the external_auth_code key from the request form. The same construction existed for external_access_token and external_refresh_token.

The token path made the separation especially visible:

GenerateAccessTokenExecution.java
clientContext.setExternalAuthorization(stepDefinition.isExternalAuthorization());
clientContext.setAccessToken(
    this.accessTokenRef.resolve(messageContext, formParameters));
clientContext.setExternalRefreshToken(
    this.refreshTokenRef.resolve(messageContext, formParameters));

The first line recorded whether the policy used external client authorization. The following lines resolved the token values regardless. Leaving ExternalAuthorization at its default false did not make the request-map fallback disappear, and omitting the external-artifact elements was what selected that fallback in the first place.

Reading a suspicious parameter is not yet a vulnerability. The value still had to survive long enough to become a real credential. The authorization-code persistence path did exactly that:

OAuth2RuntimeServiceImpl.java
authorizationCode.setOrganizationName(oauthContext.getOrganizationName());
if (authorizationCode.getExternalAuthorizationCode() != null) {
    authorizationCode.setCode(authorizationCode.getExternalAuthorizationCode());
} else {
    generateAuthorizationCodeValue(authorizationCode, organizationName);
}

If the request supplied external_auth_code, Apigee stored that value as the code. Its generator ran only when the external field was null. I followed equivalent branches for opaque access and refresh tokens: an existing external value was preserved, while the normal generator was the fallback.

The flaw was therefore not a broken random-number generator. The generator was bypassed by a value whose provenance had changed on the way in:

How request data became an OAuth artifact
  1. Request contains external_auth_code or an external token value
  2. Missing policy element selects the request-map fallback
  3. Resolver copies the form value into the OAuth context
  4. Persistence sees a non-null external artifact
  5. Apigee stores it instead of generating a new identifier

One fallback, four observed outcomes

I built a proxy with authorization, token, refresh, and identity-check endpoints. The identity endpoint verified an access token and returned the test end_user stored with it. That gave me a simple instrument panel: I could see both the credential string Apigee accepted and the identity attached to it.

I reproduced four paths on the tested runtime. Two produced direct fixation. During authorization-code generation, the redirect returned my chosen external_auth_code; exchanging it produced a token carrying the victim test identity. An implicit grant behaved the same way with external_access_token: Apigee returned the opaque value I had supplied, and that bearer token resolved to the victim test identity.

The other two paths produced rebinding rather than a fresh fixed credential. During an authorization-code exchange, a known external_access_token moved from the victim test identity to the attacker test identity. The refresh path accepted a victim's external_refresh_token, attached it to attacker context, and then issued attacker-context access from the reused value. All four outcomes began with the same fallback, but the distinction matters: fixation chooses an identifier before Apigee binds it, while rebinding changes the identity behind an identifier that already exists.

The rebinding tests also exposed a very practical nuisance. Token data could remain cached on a message processor after the backing record changed. I either retried against another processor or waited through the documented 180-second cache period before deciding which identity owned the reused value. OAuth research occasionally includes staring at a clock while a cache finishes lying to you.

I traced one more code-supported path: a supplied external_refresh_token could survive an authorization-code exchange and be preserved when the new token object was enriched. I did not exercise that variant in the live proof of concept because it required influence over the exchange request itself. I kept it separate from the four observed results rather than turning a code path into a claimed exploit.

PKCE protected the attacker's transaction

The authorization-code result raised an obvious objection. A modern confidential web application keeps its client secret on the server and uses PKCE. Even if an attacker knows a code string, how would they complete an exchange without the application's secret and verifier?

The answer is that the attacker does not need to steal either one. They can begin a legitimate login in their own browser and let the application's create the transaction for them.

I built that version next. The BFF stored the attacker's state and PKCE verifier server-side. The attacker then placed the public authorization parameters, together with a chosen external_auth_code, in a cross-origin form. A victim with an active test SSO session submitted the form, causing Apigee to bind the chosen code to the victim identity.

The callback in the victim's browser failed, as it should have, because that browser did not own the attacker's BFF transaction cookie. But the failed callback did not invalidate the code. The attacker already knew its value and still owned the transaction that had created the state and verifier.

The confidential-client chain

Nothing had to break inside the BFF. It verified its own state, used its own secret, supplied the correct verifier, and kept the access token away from the browser. PKCE prevented a stranger from redeeming someone else's code; it did not help when the attacker owned the legitimate transaction and Apigee let the victim identity be attached to a code the attacker had chosen for it.

Disabled, according to whom?

What I like about this bug is that I initially treated external authorization as disabled. There was a flag for it, the flag was false, end of story. Or so I thought. The code path kept going: the artifact resolver had its own fallback and happily took the missing value from the request.

Now, when something looks safely tucked behind a flag, I keep reading. What happens when the element is missing? Where does the value come from then? Sometimes there is another path nobody thinks of as part of the feature anymore. Here, that forgotten path was the whole bug.

Select text to leave a comment.